// 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); }); }