From f044f7756f0208035dc54ed8eac8a47ecefffd86 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 16 Oct 2026 06:06:20 +0700 Subject: [PATCH] fix(heal): take df44200's outer-union band back, and let it rest off the photo The band is traced as one outer boundary again, as df44200 left it, so a stroke that crosses itself no longer fills as a figure-eight with its lobes cut open; and an idle band is opacity 0 again, so no wash of the tool's own stays on the repair once the pointer is off it. --- docker/frontend/src/styles/app.css | 4 +- docker/frontend/src/ui/ImageStage.tsx | 242 +++++++++++--------------- 2 files changed, 100 insertions(+), 146 deletions(-) diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index c796807..40fd926 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -827,9 +827,7 @@ button.hint:disabled { cursor: default; } stroke-width: 1.5px; } .brush-stroke-contour[data-idle] { - opacity: 0.5; - stroke: var(--accent, #e67e22); - fill: rgba(230, 126, 34, 0.1); + opacity: 0; } /* The × on the spot the pointer chose. It sits on the stroke's corner and is the diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 1256a24..13e024c 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -93,124 +93,65 @@ const wmBoxOf = (mark: WmMark, fontPx: number) => { let contourCanvas: HTMLCanvasElement | null = null; let contourCtx: CanvasRenderingContext2D | null = null; -// Helper to calculate signed polygon area -const polygonArea = (pts: { x: number; y: number }[]): number => { - let a = 0; - for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { - a += pts[j].x * pts[i].y - pts[i].x * pts[j].y; - } - return Math.abs(a * 0.5); -}; - -// Traces ALL closed contours (both the largest outer perimeters and inner hole cutouts) -// using Marching Squares on a binary raster grid with exact directed boundary edges. -const traceAllContours = ( - alpha: Uint8ClampedArray | Uint8Array, +// Moore-Neighbor tracing (8-connected) to find the outermost perimeter of a binary mask +const traceOuterBoundary = ( + alpha: Uint8ClampedArray, width: number, height: number -): { x: number; y: number }[][] => { - if (width < 2 || height < 2) return []; - - // 1-pixel border pad to guarantee contours close cleanly without boundary clipping - const gw = width + 2; - const gh = height + 2; - const grid = new Uint8Array(gw * gh); - - for (let y = 0; y < height; y++) { - const rowIn = y * width; - const rowOut = (y + 1) * gw + 1; +): { x: number; y: number }[] => { + let startX = -1; + let startY = -1; + for (let y = 0; y < height && startX === -1; y++) { + const rowOffset = y * width; for (let x = 0; x < width; x++) { - if (alpha[(rowIn + x) * 4 + 3] > 64) { - grid[rowOut + x] = 1; + if (alpha[(rowOffset + x) * 4 + 3] > 64) { + startX = x; + startY = y; + break; } } } + if (startX === -1) return []; - // Directed segments on cell edge midpoints keeping inside (1-pixels) on the RIGHT: - // top: (2*x + 1, 2*y), right: (2*x + 2, 2*y + 1), bottom: (2*x + 1, 2*y + 2), left: (2*x, 2*y + 1) - const nextSegment = new Map(); - const addSegment = (from: number, to: number) => { - let arr = nextSegment.get(from); - if (!arr) { - arr = []; - nextSegment.set(from, arr); - } - arr.push(to); - }; + // Directions: 0: R, 1: DR, 2: D, 3: DL, 4: L, 5: UL, 6: U, 7: UR + const dx = [1, 1, 0, -1, -1, -1, 0, 1]; + const dy = [0, 1, 1, 1, 0, -1, -1, -1]; - const ptKey = (px: number, py: number) => (py << 16) | (px & 0xffff); - const keyX = (k: number) => k & 0xffff; - const keyY = (k: number) => k >> 16; + const contour: { x: number; y: number }[] = []; + let currX = startX; + let currY = startY; + let enterDir = 4; // came from left + contour.push({ x: currX, y: currY }); - for (let y = 0; y < gh - 1; y++) { - const row = y * gw; - const nextRow = (y + 1) * gw; - for (let x = 0; x < gw - 1; x++) { - const tl = grid[row + x]; - const tr = grid[row + x + 1]; - const br = grid[nextRow + x + 1]; - const bl = grid[nextRow + x]; + const maxSteps = width * height; + let steps = 0; - const c = (tl << 3) | (tr << 2) | (br << 1) | bl; - if (c === 0 || c === 15) continue; - - const top = ptKey(2 * x + 1, 2 * y); - const right = ptKey(2 * x + 2, 2 * y + 1); - const bottom = ptKey(2 * x + 1, 2 * y + 2); - const left = ptKey(2 * x, 2 * y + 1); - - switch (c) { - case 1: addSegment(bottom, left); break; - case 2: addSegment(right, bottom); break; - case 3: addSegment(right, left); break; - case 4: addSegment(top, right); break; - case 5: addSegment(top, right); addSegment(bottom, left); break; - case 6: addSegment(top, bottom); break; - case 7: addSegment(top, left); break; - case 8: addSegment(left, top); break; - case 9: addSegment(bottom, top); break; - case 10: addSegment(left, top); addSegment(right, bottom); break; - case 11: addSegment(right, top); break; - case 12: addSegment(left, right); break; - case 13: addSegment(bottom, right); break; - case 14: addSegment(left, bottom); break; + while (steps++ < maxSteps) { + let found = false; + const nextDir = (enterDir + 5) % 8; + for (let i = 0; i < 8; i++) { + const d = (nextDir + i) % 8; + const nx = currX + dx[d]; + const ny = currY + dy[d]; + if ( + nx >= 0 && + nx < width && + ny >= 0 && + ny < height && + alpha[(ny * width + nx) * 4 + 3] > 64 + ) { + currX = nx; + currY = ny; + enterDir = d; + found = true; + break; } } + if (!found || (currX === startX && currY === startY)) break; + contour.push({ x: currX, y: currY }); } - const contours: { x: number; y: number }[][] = []; - - for (const start of nextSegment.keys()) { - const startEdges = nextSegment.get(start); - while (startEdges && startEdges.length > 0) { - const loop: { x: number; y: number }[] = []; - let curr = start; - let guard = 0; - const maxLen = 50000; - - while (guard++ < maxLen) { - const kx = keyX(curr); - const ky = keyY(curr); - loop.push({ - x: kx * 0.5 - 1, - y: ky * 0.5 - 1, - }); - - const curEdges = nextSegment.get(curr); - if (!curEdges || curEdges.length === 0) break; - const next = curEdges.pop()!; - if (next === start) { - if (loop.length >= 3) { - contours.push(loop); - } - break; - } - curr = next; - } - } - } - - return contours; + return contour; }; // Ramer-Douglas-Peucker polygon simplification @@ -249,8 +190,7 @@ const simplifyContour = ( return [p0, pend]; }; -// Computes the total outer union boundary and subtracts overlapping interior cutouts -// so both outer and inner stroke lines are properly retained. +// Computes the total outer union boundary of all overlapping circles in a stroke const strokeContourPath = ( spots: { x: number; y: number; r: number }[], box: { width: number; height: number } @@ -281,7 +221,7 @@ const strokeContourPath = ( let minY = Infinity; let maxY = -Infinity; const maxR = Math.max(...spots.map((s) => s.r * bw)); - const pad = Math.ceil(maxR + 8); + const pad = Math.ceil(maxR + 6); for (const s of spots) { const px = s.x * bw; @@ -306,16 +246,16 @@ const strokeContourPath = ( contourCtx.lineCap = 'round'; contourCtx.lineJoin = 'round'; - // Connect consecutive spots with accurate segment widths - for (let i = 0; i + 1 < spots.length; i++) { - const p1 = spots[i]; - const p2 = spots[i + 1]; - contourCtx.lineWidth = (p1.r + p2.r) * bw; - contourCtx.beginPath(); - contourCtx.moveTo(p1.x * bw - originX, p1.y * bh - originY); - contourCtx.lineTo(p2.x * bw - originX, p2.y * bh - originY); - contourCtx.stroke(); + // Connect centers with thick line + contourCtx.lineWidth = 2 * spots[0].r * bw; + contourCtx.beginPath(); + for (let i = 0; i < spots.length; i++) { + const x = spots[i].x * bw - originX; + const y = spots[i].y * bh - originY; + if (i === 0) contourCtx.moveTo(x, y); + else contourCtx.lineTo(x, y); } + contourCtx.stroke(); // Fill circle at each spot for (const s of spots) { @@ -328,28 +268,16 @@ const strokeContourPath = ( } const imgData = contourCtx.getImageData(0, 0, cw, ch); - const rawContours = traceAllContours(imgData.data, cw, ch); - if (rawContours.length > 0) { - // Sort by polygon area descending so the largest outer perimeter is primary - const sortedContours = rawContours - .map((c) => ({ pts: c, area: polygonArea(c) })) - .filter((c) => c.area > 8) - .sort((a, b) => b.area - a.area); - - const subpaths: string[] = []; - for (const { pts: raw } of sortedContours) { - if (raw.length < 3) continue; - const simplified = simplifyContour(raw, 1.2); - if (simplified.length < 3) continue; + const rawContour = traceOuterBoundary(imgData.data, cw, ch); + if (rawContour.length >= 3) { + const simplified = simplifyContour(rawContour, 1.2); + if (simplified.length >= 3) { let d = `M ${(simplified[0].x + originX).toFixed(1)} ${(simplified[0].y + originY).toFixed(1)}`; for (let i = 1; i < simplified.length; i++) { d += ` L ${(simplified[i].x + originX).toFixed(1)} ${(simplified[i].y + originY).toFixed(1)}`; } d += ' Z'; - subpaths.push(d); - } - if (subpaths.length > 0) { - return subpaths.join(' '); + return d; } } } @@ -359,15 +287,44 @@ const strokeContourPath = ( } } - // Fallback circle union - return spots - .map((s) => { - const cx = s.x * bw; - const cy = s.y * bh; - const r = s.r * bw; - return `M ${(cx - r).toFixed(1)} ${cy.toFixed(1)} A ${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(cx + r).toFixed(1)} ${cy.toFixed(1)} A ${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(cx - r).toFixed(1)} ${cy.toFixed(1)} Z`; - }) - .join(' '); + // Fallback hull + const pts = spots.map((s) => ({ x: s.x * bw, y: s.y * bh, r: s.r * bw })); + const r = pts[0].r; + const n = pts.length; + const leftPts: { x: number; y: number }[] = []; + const rightPts: { x: number; y: number }[] = []; + + for (let i = 0; i < n; i++) { + let dx = 0; + let dy = 0; + if (i === 0) { + dx = pts[1].x - pts[0].x; + dy = pts[1].y - pts[0].y; + } else if (i === n - 1) { + dx = pts[n - 1].x - pts[n - 2].x; + dy = pts[n - 1].y - pts[n - 2].y; + } else { + dx = pts[i + 1].x - pts[i - 1].x; + dy = pts[i + 1].y - pts[i - 1].y; + } + const len = Math.hypot(dx, dy) || 1; + const nx = (-dy / len) * r; + const ny = (dx / len) * r; + leftPts.push({ x: pts[i].x + nx, y: pts[i].y + ny }); + rightPts.push({ x: pts[i].x - nx, y: pts[i].y - ny }); + } + + let d = `M ${leftPts[0].x.toFixed(1)} ${leftPts[0].y.toFixed(1)}`; + for (let i = 1; i < n; i++) { + d += ` L ${leftPts[i].x.toFixed(1)} ${leftPts[i].y.toFixed(1)}`; + } + d += ` A ${r.toFixed(1)} ${r.toFixed(1)} 0 0 0 ${rightPts[n - 1].x.toFixed(1)} ${rightPts[n - 1].y.toFixed(1)}`; + for (let i = n - 2; i >= 0; i--) { + d += ` L ${rightPts[i].x.toFixed(1)} ${rightPts[i].y.toFixed(1)}`; + } + d += ` A ${r.toFixed(1)} ${r.toFixed(1)} 0 0 0 ${leftPts[0].x.toFixed(1)} ${leftPts[0].y.toFixed(1)} Z`; + + return d; }; // How far off the mark a press still counts as a press ON it: half the brush's @@ -2230,7 +2187,6 @@ export function ImageStage({ key={i} d={b.contourPath} className="brush-stroke-contour" - fillRule="evenodd" data-selected={isSel ? '1' : undefined} data-hover={isHover ? '1' : undefined} data-idle={on ? undefined : '1'}