feat(heal): compute true outer union boundary for brush strokes and expand zoom viewport with free panning

This commit is contained in:
2026-10-14 18:42:58 +07:00
parent c384d421d6
commit df44200890
3 changed files with 214 additions and 15 deletions
+15
View File
@@ -2860,6 +2860,21 @@ form[data-key='immich-modal'] {
order: 0 !important;
min-height: 0 !important;
}
.workspace.is-mobile .stage {
padding: 6px 8px !important;
gap: 8px !important;
}
.workspace.is-mobile .stage[data-zoomed="1"] {
padding: 2px 4px 6px !important;
}
.workspace.is-mobile .canvas-wrap {
width: 100% !important;
border-radius: var(--radius-sm);
}
.workspace.is-mobile .canvas-wrap img:not([data-zoomed="1"]) {
max-width: calc(100% - 16px);
max-height: calc(100% - 12px);
}
.workspace.is-mobile .col[data-col="slider"] {
order: 1 !important;
}
+198 -14
View File
@@ -89,7 +89,108 @@ const wmBoxOf = (mark: WmMark, fontPx: number) => {
// it is. All of it is read off the recipe, which is where the run comes from
// after a reload, an undo, or a copy of the recipe to another photo: a stroke
// is not stored anywhere.
// Traces a smooth, closed SVG contour around a stroke (capsule hull around spots at distance r)
// Cache offscreen canvas for computing true outer union contours of overlapping circles
let contourCanvas: HTMLCanvasElement | null = null;
let contourCtx: CanvasRenderingContext2D | null = null;
// 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 }[] => {
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[(rowOffset + x) * 4 + 3] > 64) {
startX = x;
startY = y;
break;
}
}
}
if (startX === -1) return [];
// 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 contour: { x: number; y: number }[] = [];
let currX = startX;
let currY = startY;
let enterDir = 4; // came from left
contour.push({ x: currX, y: currY });
const maxSteps = width * height;
let steps = 0;
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 });
}
return contour;
};
// Ramer-Douglas-Peucker polygon simplification
const simplifyContour = (
points: { x: number; y: number }[],
epsilon: number
): { x: number; y: number }[] => {
if (points.length <= 2) return points;
let dmax = 0;
let index = 0;
const end = points.length - 1;
const p0 = points[0];
const pend = points[end];
const mag = Math.hypot(pend.x - p0.x, pend.y - p0.y);
for (let i = 1; i < end; i++) {
const pt = points[i];
const d =
mag === 0
? Math.hypot(pt.x - p0.x, pt.y - p0.y)
: Math.abs(
(pend.y - p0.y) * pt.x -
(pend.x - p0.x) * pt.y +
pend.x * p0.y -
pend.y * p0.x
) / mag;
if (d > dmax) {
index = i;
dmax = d;
}
}
if (dmax > epsilon) {
const r1 = simplifyContour(points.slice(0, index + 1), epsilon);
const r2 = simplifyContour(points.slice(index), epsilon);
return r1.slice(0, -1).concat(r2);
}
return [p0, pend];
};
// 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 }
@@ -97,6 +198,8 @@ const strokeContourPath = (
if (!spots.length) return '';
const bw = box.width;
const bh = box.height;
// Single spot: exact circle
if (spots.length === 1) {
const s = spots[0];
const cx = s.x * bw;
@@ -105,10 +208,89 @@ const strokeContourPath = (
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`;
}
// Exact 2D union boundary tracing via offscreen canvas
if (typeof document !== 'undefined') {
try {
if (!contourCanvas) {
contourCanvas = document.createElement('canvas');
contourCtx = contourCanvas.getContext('2d', { willReadFrequently: true });
}
if (contourCtx && contourCanvas) {
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
const maxR = Math.max(...spots.map((s) => s.r * bw));
const pad = Math.ceil(maxR + 6);
for (const s of spots) {
const px = s.x * bw;
const py = s.y * bh;
if (px < minX) minX = px;
if (px > maxX) maxX = px;
if (py < minY) minY = py;
if (py > maxY) maxY = py;
}
const originX = Math.floor(minX - pad);
const originY = Math.floor(minY - pad);
const cw = Math.ceil(maxX - minX + 2 * pad);
const ch = Math.ceil(maxY - minY + 2 * pad);
if (cw > 0 && ch > 0 && cw <= 4000 && ch <= 4000) {
contourCanvas.width = cw;
contourCanvas.height = ch;
contourCtx.clearRect(0, 0, cw, ch);
contourCtx.fillStyle = '#fff';
contourCtx.strokeStyle = '#fff';
contourCtx.lineCap = 'round';
contourCtx.lineJoin = 'round';
// 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) {
const x = s.x * bw - originX;
const y = s.y * bh - originY;
const r = s.r * bw;
contourCtx.beginPath();
contourCtx.arc(x, y, r, 0, Math.PI * 2);
contourCtx.fill();
}
const imgData = contourCtx.getImageData(0, 0, cw, ch);
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';
return d;
}
}
}
}
} catch {
// Fallback below
}
}
// 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 }[] = [];
@@ -995,14 +1177,14 @@ export function ImageStage({
return { w: nw * k, h: nh * k, wrapW: w, wrapH: h };
}, []);
// The photo may never be dragged off its own frame: an axis only moves once
// it is wider (or taller) than the stage.
// When zoomed (s > 1), allows panning across the full viewport in both axes
const clampPan = useCallback(
(s: number, x: number, y: number) => {
const b = baseRect();
if (!b) return { x: 0, y: 0 };
const mx = Math.max(0, (s * b.w - b.wrapW) / 2);
const my = Math.max(0, (s * b.h - b.wrapH) / 2);
if (s <= 1) return { x: 0, y: 0 };
const mx = Math.max(Math.abs(s * b.w - b.wrapW) / 2, s * b.w * 0.4);
const my = Math.max(Math.abs(s * b.h - b.wrapH) / 2, s * b.h * 0.4);
return { x: Math.max(-mx, Math.min(mx, x)), y: Math.max(-my, Math.min(my, y)) };
},
[baseRect]
@@ -1181,15 +1363,15 @@ export function ImageStage({
// down again, which is the whole of how a stroke is started.
if (fingersRef.current.size < 2) pinchRef.current = null;
};
wrap.addEventListener('pointerdown', down);
wrap.addEventListener('pointermove', move);
wrap.addEventListener('pointerup', up);
wrap.addEventListener('pointercancel', up);
wrap.addEventListener('pointerdown', down, { capture: true });
wrap.addEventListener('pointermove', move, { capture: true });
wrap.addEventListener('pointerup', up, { capture: true });
wrap.addEventListener('pointercancel', up, { capture: true });
return () => {
wrap.removeEventListener('pointerdown', down);
wrap.removeEventListener('pointermove', move);
wrap.removeEventListener('pointerup', up);
wrap.removeEventListener('pointercancel', up);
wrap.removeEventListener('pointerdown', down, { capture: true });
wrap.removeEventListener('pointermove', move, { capture: true });
wrap.removeEventListener('pointerup', up, { capture: true });
wrap.removeEventListener('pointercancel', up, { capture: true });
fingersRef.current.clear();
pinchRef.current = null;
};
@@ -1672,6 +1854,7 @@ export function ImageStage({
return (
<div
className="stage"
data-zoomed={view.s > 1 ? '1' : undefined}
onDragOver={(e) => {
stop(e);
setOver(true);
@@ -1719,6 +1902,7 @@ export function ImageStage({
back as a new photo — the look re-applied once per drag. */}
<img
ref={imgRef}
data-zoomed={view.s > 1 ? '1' : undefined}
src={peek && originalUrl ? originalUrl : previewUrl}
alt="preview"
draggable={false}
+1 -1
View File
@@ -1 +1 @@
aec775faf6086b3a1e304e714b937178ddff1a5148fa050e4fa415f8b576799a recipescam-images.tar.gz
be6e387b7d02f5fcce947d7411cebe01331f87d14207e009802c21624a3bebe7 recipescam-images.tar.gz