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:
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user