diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte index 787819a..abb55d8 100644 --- a/frontend/src/capture/MeasurePanel.svelte +++ b/frontend/src/capture/MeasurePanel.svelte @@ -193,9 +193,8 @@

+ PhotoStage paints opaque checkerboard on non-slab (iOS-safe under the + CSS pan/zoom/tilt transform — alpha punch left Zoom/Pan blank for Ty). --> { if (!canvas) return; - // Mutate the photo bitmap in place (same approach as Mask/SessionCapture). - // Offscreen createImageData→drawImage can fail to composite on some WebKit - // builds, leaving only the checkerboard with the slab alpha wiped. - // Tape mode: punch non-slab to alpha 0 so the CSS stage checkerboard shows. - // Bbox mode: paint opaque checkerboard (no CSS grid behind the stage). + // Mutate the photo bitmap in place (same approach as Mask). + // Tape + bbox both paint opaque checkerboard on non-slab. Do NOT punch + // alpha to 0: a mostly-transparent canvas inside the CSS-transformed + // photo-layer composites blank on iOS Safari (Ty: checkerboard + tape, + // no wood). Opaque bitmap stays visible under pan/zoom/tilt. if (maskBitset && maskW > 0 && maskH > 0) { const cw = canvas.width; const ch = canvas.height; const img = ctx.getImageData(0, 0, cw, ch); - const px = img.data; - const tile = 8; - const punch = mode === 'tape'; - const matched = maskW === cw && maskH === ch; - const pixels = matched ? Math.min(maskW * maskH, (px.length / 4) | 0) : 0; - if (matched) { - for (let p = 0; p < pixels; p++) { - if (maskBitset[p]) continue; // slab: keep photo - const i = p * 4; - if (punch) { - px[i + 3] = 0; - } else { - const x = p % maskW; - const y = (p / maskW) | 0; - const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2; - const g = light ? 220 : 180; - px[i] = g; px[i + 1] = g; px[i + 2] = g; px[i + 3] = 255; - } - } - } else { - for (let y = 0; y < ch; y++) { - const sy = Math.min(maskH - 1, Math.round((y / ch) * maskH)); - for (let x = 0; x < cw; x++) { - const sx = Math.min(maskW - 1, Math.round((x / cw) * maskW)); - if (maskBitset[sy * maskW + sx]) continue; - const i = (y * cw + x) * 4; - if (punch) { - px[i + 3] = 0; - } else { - const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2; - const g = light ? 220 : 180; - px[i] = g; px[i + 1] = g; px[i + 2] = g; px[i + 3] = 255; - } - } - } - } + applyMaskOverlayRgba(img.data, cw, ch, maskBitset, maskW, maskH); ctx.putImageData(img, 0, 0); } if (mode === 'tape') return; @@ -442,7 +408,8 @@ pointer-events: none; } .vb > :global(*) { pointer-events: auto; } - .photo-layer { will-change: transform; pointer-events: none; } + /* No will-change: iOS Safari can blank transformed translucent layers. */ + .photo-layer { pointer-events: none; } .photo-layer canvas { border-radius: 0; background: transparent; pointer-events: none; } .band { position: absolute; left: 0; right: 0; background: #000; opacity: 0.3; pointer-events: none; z-index: 1; } .band.top { top: 0; } diff --git a/frontend/src/capture/maskOverlay.test.ts b/frontend/src/capture/maskOverlay.test.ts new file mode 100644 index 0000000..c92eb81 --- /dev/null +++ b/frontend/src/capture/maskOverlay.test.ts @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { applyMaskOverlayRgba } from './maskOverlay.ts'; + +test('keeps slab pixels and paints opaque checkerboard on non-slab (matched dims)', () => { + const w = 4; + const h = 2; + const rgba = new Uint8ClampedArray(w * h * 4); + for (let p = 0; p < w * h; p++) { + rgba[p * 4] = 180; + rgba[p * 4 + 1] = 90; + rgba[p * 4 + 2] = 40; + rgba[p * 4 + 3] = 255; + } + const bitset = new Uint8Array([1, 1, 0, 0, 1, 1, 0, 0]); + const { kept, covered } = applyMaskOverlayRgba(rgba, w, h, bitset, w, h); + assert.equal(kept, 4); + assert.equal(covered, 4); + assert.deepEqual([...rgba.slice(0, 4)], [180, 90, 40, 255]); + assert.equal(rgba[2 * 4 + 3], 255); + assert.ok(rgba[2 * 4] > 100 && rgba[2 * 4] < 230); +}); + +test('never leaves transparent holes (iOS transform-safe)', () => { + const w = 3; + const h = 3; + const rgba = new Uint8ClampedArray(w * h * 4).fill(200); + for (let i = 3; i < rgba.length; i += 4) rgba[i] = 255; + const bitset = new Uint8Array(w * h); + bitset[4] = 1; + applyMaskOverlayRgba(rgba, w, h, bitset, w, h); + for (let i = 3; i < rgba.length; i += 4) { + assert.equal(rgba[i], 255); + } + assert.equal(rgba[4 * 4], 200); +}); diff --git a/frontend/src/capture/maskOverlay.ts b/frontend/src/capture/maskOverlay.ts new file mode 100644 index 0000000..f02b9e2 --- /dev/null +++ b/frontend/src/capture/maskOverlay.ts @@ -0,0 +1,58 @@ +/** + * Paint approved-mask non-slab pixels onto decoded photo RGBA. + * + * Tape Zoom/Pan wraps this canvas in a CSS-transformed photo-layer. Punching + * non-slab to alpha 0 leaves a mostly-transparent canvas; iOS Safari WebKit + * then composites that layer as blank — Ty's failure: CSS checkerboard + tape + * fixture only, no wood. Always paint an opaque checkerboard for non-slab + * (same as Mask/bbox) so the bitmap stays fully opaque under transforms. + */ +export type MaskOverlayBackdrop = 'checkerboard'; + +export function applyMaskOverlayRgba( + rgba: Uint8ClampedArray, + width: number, + height: number, + bitset: Uint8Array, + maskW: number, + maskH: number, + _backdrop: MaskOverlayBackdrop = 'checkerboard', +): { kept: number; covered: number } { + const tile = 8; + let kept = 0; + let covered = 0; + const matched = maskW === width && maskH === height; + const paintCover = (i: number, x: number, y: number) => { + const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2; + const g = light ? 220 : 180; + rgba[i] = g; + rgba[i + 1] = g; + rgba[i + 2] = g; + rgba[i + 3] = 255; + covered++; + }; + if (matched) { + const pixels = Math.min(maskW * maskH, (rgba.length / 4) | 0); + for (let p = 0; p < pixels; p++) { + if (bitset[p]) { + kept++; + continue; + } + paintCover(p * 4, p % maskW, (p / maskW) | 0); + } + } else if (maskW > 0 && maskH > 0) { + for (let y = 0; y < height; y++) { + const sy = Math.min(maskH - 1, Math.round((y / height) * maskH)); + for (let x = 0; x < width; x++) { + const sx = Math.min(maskW - 1, Math.round((x / width) * maskW)); + const i = (y * width + x) * 4; + if (bitset[sy * maskW + sx]) { + kept++; + continue; + } + paintCover(i, x, y); + } + } + } + return { kept, covered }; +}