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;
|
order: 0 !important;
|
||||||
min-height: 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"] {
|
.workspace.is-mobile .col[data-col="slider"] {
|
||||||
order: 1 !important;
|
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
|
// 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
|
// after a reload, an undo, or a copy of the recipe to another photo: a stroke
|
||||||
// is not stored anywhere.
|
// 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 = (
|
const strokeContourPath = (
|
||||||
spots: { x: number; y: number; r: number }[],
|
spots: { x: number; y: number; r: number }[],
|
||||||
box: { width: number; height: number }
|
box: { width: number; height: number }
|
||||||
@@ -97,6 +198,8 @@ const strokeContourPath = (
|
|||||||
if (!spots.length) return '';
|
if (!spots.length) return '';
|
||||||
const bw = box.width;
|
const bw = box.width;
|
||||||
const bh = box.height;
|
const bh = box.height;
|
||||||
|
|
||||||
|
// Single spot: exact circle
|
||||||
if (spots.length === 1) {
|
if (spots.length === 1) {
|
||||||
const s = spots[0];
|
const s = spots[0];
|
||||||
const cx = s.x * bw;
|
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`;
|
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 pts = spots.map((s) => ({ x: s.x * bw, y: s.y * bh, r: s.r * bw }));
|
||||||
const r = pts[0].r;
|
const r = pts[0].r;
|
||||||
const n = pts.length;
|
const n = pts.length;
|
||||||
|
|
||||||
const leftPts: { x: number; y: number }[] = [];
|
const leftPts: { x: number; y: number }[] = [];
|
||||||
const rightPts: { 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 };
|
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
|
// When zoomed (s > 1), allows panning across the full viewport in both axes
|
||||||
// it is wider (or taller) than the stage.
|
|
||||||
const clampPan = useCallback(
|
const clampPan = useCallback(
|
||||||
(s: number, x: number, y: number) => {
|
(s: number, x: number, y: number) => {
|
||||||
const b = baseRect();
|
const b = baseRect();
|
||||||
if (!b) return { x: 0, y: 0 };
|
if (!b) return { x: 0, y: 0 };
|
||||||
const mx = Math.max(0, (s * b.w - b.wrapW) / 2);
|
if (s <= 1) return { x: 0, y: 0 };
|
||||||
const my = Math.max(0, (s * b.h - b.wrapH) / 2);
|
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)) };
|
return { x: Math.max(-mx, Math.min(mx, x)), y: Math.max(-my, Math.min(my, y)) };
|
||||||
},
|
},
|
||||||
[baseRect]
|
[baseRect]
|
||||||
@@ -1181,15 +1363,15 @@ export function ImageStage({
|
|||||||
// down again, which is the whole of how a stroke is started.
|
// down again, which is the whole of how a stroke is started.
|
||||||
if (fingersRef.current.size < 2) pinchRef.current = null;
|
if (fingersRef.current.size < 2) pinchRef.current = null;
|
||||||
};
|
};
|
||||||
wrap.addEventListener('pointerdown', down);
|
wrap.addEventListener('pointerdown', down, { capture: true });
|
||||||
wrap.addEventListener('pointermove', move);
|
wrap.addEventListener('pointermove', move, { capture: true });
|
||||||
wrap.addEventListener('pointerup', up);
|
wrap.addEventListener('pointerup', up, { capture: true });
|
||||||
wrap.addEventListener('pointercancel', up);
|
wrap.addEventListener('pointercancel', up, { capture: true });
|
||||||
return () => {
|
return () => {
|
||||||
wrap.removeEventListener('pointerdown', down);
|
wrap.removeEventListener('pointerdown', down, { capture: true });
|
||||||
wrap.removeEventListener('pointermove', move);
|
wrap.removeEventListener('pointermove', move, { capture: true });
|
||||||
wrap.removeEventListener('pointerup', up);
|
wrap.removeEventListener('pointerup', up, { capture: true });
|
||||||
wrap.removeEventListener('pointercancel', up);
|
wrap.removeEventListener('pointercancel', up, { capture: true });
|
||||||
fingersRef.current.clear();
|
fingersRef.current.clear();
|
||||||
pinchRef.current = null;
|
pinchRef.current = null;
|
||||||
};
|
};
|
||||||
@@ -1672,6 +1854,7 @@ export function ImageStage({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="stage"
|
className="stage"
|
||||||
|
data-zoomed={view.s > 1 ? '1' : undefined}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
stop(e);
|
stop(e);
|
||||||
setOver(true);
|
setOver(true);
|
||||||
@@ -1719,6 +1902,7 @@ export function ImageStage({
|
|||||||
back as a new photo — the look re-applied once per drag. */}
|
back as a new photo — the look re-applied once per drag. */}
|
||||||
<img
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
|
data-zoomed={view.s > 1 ? '1' : undefined}
|
||||||
src={peek && originalUrl ? originalUrl : previewUrl}
|
src={peek && originalUrl ? originalUrl : previewUrl}
|
||||||
alt="preview"
|
alt="preview"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
aec775faf6086b3a1e304e714b937178ddff1a5148fa050e4fa415f8b576799a recipescam-images.tar.gz
|
be6e387b7d02f5fcce947d7411cebe01331f87d14207e009802c21624a3bebe7 recipescam-images.tar.gz
|
||||||
|
|||||||
Reference in New Issue
Block a user