aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/occlusion.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/occlusion.js')
-rw-r--r--public/bluebey-studio/src/occlusion.js184
1 files changed, 184 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/occlusion.js b/public/bluebey-studio/src/occlusion.js
new file mode 100644
index 0000000..3016dd7
--- /dev/null
+++ b/public/bluebey-studio/src/occlusion.js
@@ -0,0 +1,184 @@
+// src/occlusion.js
+//
+// A tiny "hide ぶるべー behind this" mask editor.
+//
+// WHY: the background is a fixed image, so hiding the character behind a tree
+// is a 2D masking job, not a depth-estimation one. The user taps the object;
+// a magic wand (see floodfill.js) marks it. The editor hands back two PNGs:
+//
+// mask - opaque where the object is, used by the export to punch the
+// character out there (`destination-out`)
+// inverse - transparent where the object is, used as a CSS `mask-image` on
+// the WebGL canvas so the live preview hides it too
+//
+// Both are in the canvas' CSS pixel space, so the editor is handed the
+// backdrop *as already painted* (fit, scale, offset, blur, darken applied by
+// main.js) rather than the raw photo. That keeps the mask aligned with what
+// the export will draw.
+
+import { floodFill } from './floodfill.js';
+
+const DEFAULT_TOLERANCE = 32;
+
+/**
+ * @param {HTMLCanvasElement} backdrop the backdrop painted at the canvas' CSS
+ * size (the same pixels the export will composite)
+ * @param {HTMLCanvasElement|null} character a transparent-background render of
+ * the character, drawn faintly so the user can aim
+ * @param {{ left:number, top:number, width:number, height:number }} rect the
+ * canvas' on-screen rectangle
+ * @returns {Promise<{mask:string, inverse:string}|null>} null when cancelled
+ */
+export function openOcclusionEditor({ backdrop, character = null, rect }) {
+ return new Promise((resolve) => {
+ const width = Math.max(1, Math.round(rect.width));
+ const height = Math.max(1, Math.round(rect.height));
+
+ const root = document.createElement('div');
+ root.className = 'occlusion-editor';
+ root.style.cssText = [
+ 'position:fixed',
+ `left:${rect.left}px`,
+ `top:${rect.top}px`,
+ `width:${width}px`,
+ `height:${height}px`,
+ 'z-index:100',
+ 'background:#111',
+ 'overflow:hidden',
+ 'touch-action:none',
+ 'user-select:none',
+ 'font:13px system-ui, "Hiragino Sans", "Yu Gothic UI", sans-serif',
+ ].join(';');
+
+ // The backdrop, exactly as the studio paints it.
+ const photo = document.createElement('canvas');
+ photo.width = width;
+ photo.height = height;
+ photo.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;cursor:crosshair;';
+ const pctx = photo.getContext('2d', { willReadFrequently: true });
+ pctx.drawImage(backdrop, 0, 0, width, height);
+ const pixels = pctx.getImageData(0, 0, width, height).data;
+
+ // A faint copy of ぶるべー, so the user can see what is being covered.
+ const ghost = document.createElement('canvas');
+ ghost.width = width;
+ ghost.height = height;
+ ghost.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:0.55;pointer-events:none;';
+ if (character) ghost.getContext('2d').drawImage(character, 0, 0, width, height);
+
+ // The mask: opaque white where the object is.
+ const mask = document.createElement('canvas');
+ mask.width = width;
+ mask.height = height;
+ mask.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;opacity:0.55;pointer-events:none;';
+ const mctx = mask.getContext('2d');
+ const maskImage = mctx.createImageData(width, height);
+
+ const bar = document.createElement('div');
+ bar.style.cssText = [
+ 'position:absolute',
+ 'left:0', 'right:0', 'top:0',
+ 'display:flex', 'flex-wrap:wrap', 'gap:8px', 'align-items:center',
+ 'padding:8px 10px',
+ 'background:rgba(20,16,28,0.86)', 'color:#fff',
+ 'box-sizing:border-box',
+ ].join(';');
+
+ const title = document.createElement('strong');
+ title.textContent = '隠すマスク';
+
+ const hintText = document.createElement('span');
+ hintText.textContent = '隠したい物(木・建物など)をタップ。何回でも追加できます';
+ hintText.style.cssText = 'opacity:0.8;flex:1 1 220px;';
+
+ const toleranceWrap = document.createElement('label');
+ toleranceWrap.style.cssText = 'display:flex;gap:6px;align-items:center;white-space:nowrap;';
+ toleranceWrap.append(document.createTextNode('にじみ'));
+ const tolerance = document.createElement('input');
+ tolerance.type = 'range';
+ tolerance.min = '0';
+ tolerance.max = '100';
+ tolerance.step = '1';
+ tolerance.value = String(DEFAULT_TOLERANCE);
+ tolerance.style.width = '110px';
+ toleranceWrap.append(tolerance);
+
+ const button = (label, onClick) => {
+ const el = document.createElement('button');
+ el.type = 'button';
+ el.textContent = label;
+ el.style.cssText = 'padding:4px 10px;border:1px solid rgba(255,255,255,0.35);border-radius:6px;background:rgba(255,255,255,0.1);color:#fff;cursor:pointer;';
+ el.addEventListener('click', onClick);
+ return el;
+ };
+
+ const undoStack = [];
+
+ bar.append(
+ title,
+ hintText,
+ toleranceWrap,
+ button('クリア', () => {
+ undoStack.length = 0;
+ maskImage.data.fill(0);
+ mctx.putImageData(maskImage, 0, 0);
+ }),
+ button('キャンセル', () => { cleanup(); resolve(null); }),
+ button('決定', () => {
+ cleanup();
+ resolve({ mask: mask.toDataURL('image/png'), inverse: invertedDataUrl(mask, width, height) });
+ }),
+ );
+
+ root.append(photo, ghost, mask, bar);
+ document.body.append(root);
+
+ function cleanup() {
+ root.remove();
+ window.removeEventListener('keydown', onKeydown);
+ }
+
+ /** @param {string} dataUrl */
+ function invertedDataUrl(source, w, h) {
+ const out = document.createElement('canvas');
+ out.width = w;
+ out.height = h;
+ const ctx = out.getContext('2d');
+ ctx.fillStyle = '#fff';
+ ctx.fillRect(0, 0, w, h);
+ ctx.globalCompositeOperation = 'destination-out';
+ ctx.drawImage(source, 0, 0);
+ return out.toDataURL('image/png');
+ }
+
+ photo.addEventListener('pointerdown', (event) => {
+ if (event.button !== 0) return;
+ const r = photo.getBoundingClientRect();
+ const before = new Uint8ClampedArray(maskImage.data);
+ const count = floodFill({
+ pixels,
+ mask: maskImage.data,
+ width,
+ height,
+ x: event.clientX - r.left,
+ y: event.clientY - r.top,
+ tolerance: Number(tolerance.value),
+ });
+ if (!count) return;
+ undoStack.push(before);
+ if (undoStack.length > 5) undoStack.shift();
+ mctx.putImageData(maskImage, 0, 0);
+ });
+
+ // Ctrl/⌘+Z takes back the last tap, which is the mistake people make most.
+ function onKeydown(event) {
+ if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z') return;
+ const before = undoStack.pop();
+ if (!before) return;
+ maskImage.data.set(before);
+ mctx.putImageData(maskImage, 0, 0);
+ event.preventDefault();
+ }
+ window.addEventListener('keydown', onKeydown);
+ });
+}