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.
This commit is contained in:
2026-10-16 06:06:20 +07:00
parent 5b7e065b8a
commit f044f7756f
2 changed files with 100 additions and 146 deletions
+1 -3
View File
@@ -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
+99 -143
View File
@@ -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<number, number[]>();
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'}