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 };
+}