feat(heal): compute true outer union boundary for brush strokes and expand zoom viewport with free panning
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
aec775faf6086b3a1e304e714b937178ddff1a5148fa050e4fa415f8b576799a recipescam-images.tar.gz
|
||||
be6e387b7d02f5fcce947d7411cebe01331f87d14207e009802c21624a3bebe7 recipescam-images.tar.gz
|
||||
|
||||
Reference in New Issue
Block a user