From 974c44f5fa918c7626bc760fe3fab09de86a9b68 Mon Sep 17 00:00:00 2001 From: harleensachdev Date: Thu, 13 Aug 2026 12:40:29 +0800 Subject: [PATCH 01/13] helpers: add world-space conversion + live-wire sub-voxel edge snap Adds canvasPointToWorld/worldToCanvasPoint so points can be stored in world space (mm) instead of camera-dependent canvas pixels, preventing drift on zoom/pan. Also refines the live-wire Dijkstra path with a sub-voxel gradient-peak snap: the raw grid-locked path was biased toward the inside of intensity transitions on convex boundary stretches; this walks along the local gradient and locates the true edge with a parabolic sub-voxel fit. --- PanTS-Demo/src/helpers/CornerstoneNifti2.tsx | 115 ++++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx b/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx index 0a3dab63..2c9e22e7 100644 --- a/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx +++ b/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx @@ -2417,6 +2417,43 @@ export function canvasPointToVoxel(pane: CinePane, canvasPos: Point2): [number, return null; } } +// Canvas-pixel positions only mean what they mean for the camera that was +// active the instant they were captured — zooming/panning afterward remaps +// every world location to a different canvas pixel, so anything stashed as +// a raw canvas coordinate (a lasso/scissors corner, a smart-fill seed dot) +// silently drifts off the anatomy it was placed on the moment the camera +// changes, both visually and — if it's later fed back into +// canvasPointToVoxel — in the actual voxels the tool acts on. World-space +// (mm) points don't have that problem: a world coordinate names the same +// physical location regardless of zoom/pan. Anything that needs to survive +// a camera change between "placed" and "drawn/committed" should be stored +// via canvasPointToWorld and turned back into a canvas pixel via +// worldToCanvasPoint at the moment it's actually drawn or committed. +export function canvasPointToWorld(pane: CinePane, canvasPos: Point2): Point3 | null { + const engine = getRenderingEngine(renderingEngineId); + if (!engine) return null; + const viewport = engine.getViewport(CINE_VIEWPORT_BY_PANE[pane]) as any; + if (!viewport) return null; + try { + return viewport.canvasToWorld(canvasPos) as Point3; + } catch { + return null; + } +} + +export function worldToCanvasPoint(pane: CinePane, world: Point3): [number, number] | null { + const engine = getRenderingEngine(renderingEngineId); + if (!engine) return null; + const viewport = engine.getViewport(CINE_VIEWPORT_BY_PANE[pane]) as any; + if (!viewport) return null; + try { + const [x, y] = viewport.worldToCanvas(world) as Point2; + return [x, y]; + } catch { + return null; + } +} + function _sliceAxisForPane(pane: CinePane): 0 | 1 | 2 { return pane === "sagittal" ? 0 : pane === "coronal" ? 1 : 2; } @@ -2663,9 +2700,83 @@ export function computeLiveWirePath( if (pathLocal[pathLocal.length - 1] !== seedLocal) return null; // unreachable pathLocal.reverse(); + // --- Sub-voxel edge snap --------------------------------------------- + // The raw Dijkstra path above is grid-locked (every point sits on an + // integer voxel) and, on any CONVEX stretch of boundary, is quietly + // biased toward the INSIDE of the intensity transition: a real CT edge + // is a ramp several voxels wide (partial-volume blur), not a single- + // pixel step, and going around the inside of that ramp is a shorter + // route than going around the outside. Since the search also minimizes + // path length (linkLen + the bending penalty), that small length + // advantage quietly wins the tie-break across a whole curved stretch — + // this is what shows up as the contour consistently sitting a few mm + // inside the true boundary. Fix: for every interior point (the two + // fastened endpoints are left exactly where the user clicked), walk a + // short distance along the LOCAL intensity gradient — i.e. + // perpendicular to the edge — and re-center the point on the actual + // gradient-magnitude peak, located with sub-voxel precision via a + // parabolic fit rather than whichever integer voxel the graph search + // happened to land on. Bilinear-sampled, so it isn't limited to the + // same coarse grid that caused the bias in the first place. + const sampleHUf = (a: number, b: number): number => { + const a0 = Math.floor(a), b0 = Math.floor(b); + const fa = a - a0, fb = b - b0; + const v00 = huAt(a0, b0), v10 = huAt(a0 + 1, b0); + const v01 = huAt(a0, b0 + 1), v11 = huAt(a0 + 1, b0 + 1); + return v00 * (1 - fa) * (1 - fb) + v10 * fa * (1 - fb) + v01 * (1 - fa) * fb + v11 * fa * fb; + }; + const H = 0.5; // sub-voxel differencing step, in voxels + const gradAtf = (a: number, b: number): [number, number] => [ + (sampleHUf(a + H, b) - sampleHUf(a - H, b)) / (2 * H), + (sampleHUf(a, b + H) - sampleHUf(a, b - H)) / (2 * H), + ]; + const gradMagAtf = (a: number, b: number): number => { + const [gxf, gyf] = gradAtf(a, b); + return Math.hypot(gxf, gyf); + }; + + const REFINE_RADIUS = 1.75; // voxels either side of the raw path point to search + const REFINE_STEP = 0.25; + const refinePoint = (a: number, b: number): [number, number] => { + const [gxf, gyf] = gradAtf(a, b); + const gmag = Math.hypot(gxf, gyf); + if (gmag < 1e-6) return [a, b]; // flat locally — nothing to snap to, leave it + const ux = gxf / gmag, uy = gyf / gmag; // unit vector along the gradient, i.e. perpendicular to the edge + + // Coarse search for the strongest gradient magnitude along that + // normal. Starts from (and only ever improves on) the raw point's own + // magnitude, so this can only pull toward a genuinely stronger nearby + // edge — never introduces a large jump toward an unrelated feature. + let bestT = 0, bestMag = gmag; + for (let t = -REFINE_RADIUS; t <= REFINE_RADIUS; t += REFINE_STEP) { + if (t === 0) continue; + const m = gradMagAtf(a + ux * t, b + uy * t); + if (m > bestMag) { bestMag = m; bestT = t; } + } + // Parabolic sub-step refinement around the winning sample so the final + // point isn't itself grid-locked to REFINE_STEP increments. + const mMinus = gradMagAtf(a + ux * (bestT - REFINE_STEP), b + uy * (bestT - REFINE_STEP)); + const mPlus = gradMagAtf(a + ux * (bestT + REFINE_STEP), b + uy * (bestT + REFINE_STEP)); + const denom = mMinus - 2 * bestMag + mPlus; + const delta = Math.abs(denom) > 1e-6 ? (0.5 * (mMinus - mPlus)) / denom : 0; + const tRefined = bestT + Math.max(-REFINE_STEP, Math.min(REFINE_STEP, delta * REFINE_STEP)); + + return [a + ux * tRefined, b + uy * tRefined]; + }; + const points: Array<[number, number]> = []; - for (const li of pathLocal) { - const a = winA0 + (li % winW), b = winB0 + Math.floor(li / winW); + for (let idx = 0; idx < pathLocal.length; idx++) { + const li = pathLocal[idx]; + let a = winA0 + (li % winW), b = winB0 + Math.floor(li / winW); + // Leave the two fastened endpoints exactly where the user clicked — + // only interior points get snapped to the refined edge location. + if (idx > 0 && idx < pathLocal.length - 1) { + [a, b] = refinePoint(a, b); + } + // sliceOf works fine with fractional a/b (it just slots them into the + // fixed-axis tuple) — passing the refined, non-rounded values straight + // through is what actually preserves the sub-voxel correction; feeding + // it Math.round(a)/Math.round(b) here would throw the refinement away. const [i, j, k] = sliceOf(a, b); try { const world = ctVolume.imageData.indexToWorld([i, j, k]); From 91a937ceb9ecbee3b38c0275227de96b6d1fa78e Mon Sep 17 00:00:00 2001 From: harleensachdev Date: Thu, 13 Aug 2026 12:41:38 +0800 Subject: [PATCH 02/13] fix(lasso/scissors): store polygon points in world space, not canvas pixels Prevents lasso/scissors corners and fill path from drifting off the anatomy when the user zooms or pans mid-draw. --- .../src/helpers/viewer/usePolygonDraw.ts | 145 ++++++++++++------ 1 file changed, 94 insertions(+), 51 deletions(-) diff --git a/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts b/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts index e7d66f20..4dd55c76 100644 --- a/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts +++ b/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts @@ -1,6 +1,11 @@ // helpers/viewer/usePolygonDraw.ts import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react"; -import type { CinePane } from "../CornerstoneNifti2"; +import { + canvasPointToWorld, + worldToCanvasPoint, + type CinePane, +} from "../CornerstoneNifti2"; +import type { Point3 } from "@cornerstonejs/core/types"; const CLOSE_CLICK_RADIUS_PX = 10; @@ -8,16 +13,19 @@ interface UsePolygonDrawArgs { enabled: boolean; /** Called with the DENSE fill path (every pixel along every leg, including * any live-wire detours) once the shape is closed — this is what actually - * gets rasterized/filled. */ + * gets rasterized/filled. Given as CURRENT-camera canvas points, freshly + * reprojected from the stored world-space path, so a zoom/pan mid-draw + * can't shift which voxels the commit lands on. */ onClose: (pane: CinePane, points: Array<[number, number]>) => void; /** Optional "live wire" hook (e.g. the magnetic edge-snap tool). Given the - * pane and the last fastening point + the current cursor point, returns a - * dense path from `from` to `to` (inclusive) that hugs nearby intensity - * edges — or null/undefined to fall back to a straight line between the - * two points. Used both for the live preview between clicks and to bake - * the actual leg in once the user clicks to drop the next fastening - * point, exactly like Photoshop's magnetic lasso: the cursor doesn't need - * to trace the boundary exactly, the path snaps to it between clicks. */ + * pane and the last fastening point + the current cursor point (both in + * CURRENT canvas-pixel space), returns a dense path from `from` to `to` + * (inclusive) that hugs nearby intensity edges — or null/undefined to + * fall back to a straight line between the two points. Used both for the + * live preview between clicks and to bake the actual leg in once the + * user clicks to drop the next fastening point, exactly like Photoshop's + * magnetic lasso: the cursor doesn't need to trace the boundary exactly, + * the path snaps to it between clicks. */ computeLivePath?: ( pane: CinePane, from: [number, number], @@ -25,16 +33,33 @@ interface UsePolygonDrawArgs { ) => Array<[number, number]> | null | undefined; } +// Reproject a world-space path back into the CURRENT camera's canvas-pixel +// space. Called on every render (cheap — just a matrix multiply per point) +// so the drawn overlay and the eventual commit always reflect whatever +// zoom/pan is active right now, not whatever was active when each point was +// originally clicked. +function toCanvas(pane: CinePane, world: Point3[]): Array<[number, number]> { + const out: Array<[number, number]> = []; + for (const w of world) { + const p = worldToCanvasPoint(pane, w); + if (p) out.push(p); + } + return out; +} + export function usePolygonDraw({ enabled, onClose, computeLivePath }: UsePolygonDrawArgs) { // Dense fill path — every pixel along every committed leg (corners plus, // when computeLivePath is active, whatever detour the live wire took to - // hug an edge between two corners). This is what gets filled/cut. - const [points, setPoints] = useState>([]); + // hug an edge between two corners) — stored in WORLD space so it stays + // anchored to the same anatomy across zoom/pan. Reprojected to canvas + // pixels on demand via `points` below. + const [pointsWorld, setPointsWorld] = useState([]); // Just the clicked "fastening points" — used for the corner dots and for - // screen-space close-click detection, independent of any live-wire detour. - const [corners, setCorners] = useState>([]); + // close-click detection, independent of any live-wire detour. Also + // world-space for the same reason. + const [cornersWorld, setCornersWorld] = useState([]); // How many dense points each leg (ending at corners[i+1]) contributed to - // `points`, so undo() can pop exactly one leg's worth back off. + // `pointsWorld`, so undo() can pop exactly one leg's worth back off. const legLengthsRef = useRef([]); const [livePreview, setLivePreview] = useState<[number, number] | null>(null); const [livePreviewPath, setLivePreviewPath] = useState | null>(null); @@ -44,8 +69,8 @@ export function usePolygonDraw({ enabled, onClose, computeLivePath }: UsePolygon const paneRef = useRef(null); const reset = useCallback(() => { - setPoints([]); - setCorners([]); + setPointsWorld([]); + setCornersWorld([]); legLengthsRef.current = []; setLivePreview(null); setLivePreviewPath(null); @@ -57,51 +82,69 @@ export function usePolygonDraw({ enabled, onClose, computeLivePath }: UsePolygon if (!enabled) reset(); }, [enabled, reset]); + // Canvas-space views of the world-space state, reprojected against + // whatever camera (zoom/pan) is active on THIS render. This is what + // drives the overlay, so it visually tracks the anatomy through any + // zoom/pan instead of staying pinned to old pixel coordinates. + const pane = paneRef.current; + const points = pane ? toCanvas(pane, pointsWorld) : []; + const corners = pane ? toCanvas(pane, cornersWorld) : []; + const close = useCallback(() => { const pane = paneRef.current; - if (!pane || corners.length < 3) return; - // Bake the closing leg (last corner back to the start) into the dense - // path too, so a magnetic/live-wire close hugs the boundary just like - // every other leg instead of snapping back with a straight line. - const last = corners[corners.length - 1]; - const first = corners[0]; + if (!pane || cornersWorld.length < 3) return; + // Reproject the stored world-space corners/path to CURRENT canvas + // pixels right before committing — so if the user zoomed/panned + // partway through drawing, the polygon handed to the commit (and the + // canvasPointToVoxel conversion it does internally) is consistent + // with itself, rather than a mix of old and new camera pixels. + const currentCorners = toCanvas(pane, cornersWorld); + const currentPoints = toCanvas(pane, pointsWorld); + const last = currentCorners[currentCorners.length - 1]; + const first = currentCorners[0]; const closingLeg = computeLivePath?.(pane, last, first); const closingPoints = closingLeg && closingLeg.length >= 2 ? closingLeg.slice(1) : [first]; - onClose(pane, [...points, ...closingPoints]); + onClose(pane, [...currentPoints, ...closingPoints]); reset(); - }, [points, corners, computeLivePath, onClose, reset]); + }, [pointsWorld, cornersWorld, computeLivePath, onClose, reset]); const handleClick = (pane: CinePane) => (e: MouseEvent) => { if (!enabled) return; const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rawPos: [number, number] = [e.clientX - rect.left, e.clientY - rect.top]; + const rawWorld = canvasPointToWorld(pane, rawPos); + if (!rawWorld) return; if (!paneRef.current) { paneRef.current = pane; - setPoints([rawPos]); - setCorners([rawPos]); + setPointsWorld([rawWorld]); + setCornersWorld([rawWorld]); legLengthsRef.current = []; return; } if (paneRef.current !== pane) return; // Close-click detection always uses the raw cursor position against the - // raw start corner — a live-wire detour shouldn't change where "click - // here to close" actually is on screen. - if (corners.length >= 3) { - const [fx, fy] = corners[0]; - if (Math.hypot(rawPos[0] - fx, rawPos[1] - fy) < CLOSE_CLICK_RADIUS_PX) { + // start corner reprojected to CURRENT canvas space — a live-wire detour + // (or an intervening zoom) shouldn't change where "click here to close" + // actually is on screen right now. + if (cornersWorld.length >= 3) { + const first = worldToCanvasPoint(pane, cornersWorld[0]); + if (first && Math.hypot(rawPos[0] - first[0], rawPos[1] - first[1]) < CLOSE_CLICK_RADIUS_PX) { close(); return; } } - const last = corners[corners.length - 1]; - const leg = computeLivePath?.(pane, last, rawPos); - const legPoints = leg && leg.length >= 2 ? leg.slice(1) : [rawPos]; - legLengthsRef.current = [...legLengthsRef.current, legPoints.length]; - setPoints((prev) => [...prev, ...legPoints]); - setCorners((prev) => [...prev, rawPos]); + const lastCanvas = worldToCanvasPoint(pane, cornersWorld[cornersWorld.length - 1]); + const leg = lastCanvas ? computeLivePath?.(pane, lastCanvas, rawPos) : undefined; + const legPointsCanvas = leg && leg.length >= 2 ? leg.slice(1) : [rawPos]; + const legPointsWorld = legPointsCanvas + .map((cp) => canvasPointToWorld(pane, cp)) + .filter((w): w is Point3 => !!w); + legLengthsRef.current = [...legLengthsRef.current, legPointsWorld.length]; + setPointsWorld((prev) => [...prev, ...legPointsWorld]); + setCornersWorld((prev) => [...prev, rawWorld]); }; const handleDoubleClick = (pane: CinePane) => (e: MouseEvent) => { @@ -111,27 +154,27 @@ export function usePolygonDraw({ enabled, onClose, computeLivePath }: UsePolygon }; const handleMouseMove = (pane: CinePane) => (e: MouseEvent) => { - if (!enabled || paneRef.current !== pane || !corners.length) return; + if (!enabled || paneRef.current !== pane || !cornersWorld.length) return; const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rawPos: [number, number] = [e.clientX - rect.left, e.clientY - rect.top]; setLivePreview(rawPos); - const last = corners[corners.length - 1]; - const preview = computeLivePath?.(pane, last, rawPos); - setLivePreviewPath(preview && preview.length >= 2 ? preview : [last, rawPos]); + const lastCanvas = worldToCanvasPoint(pane, cornersWorld[cornersWorld.length - 1]); + const preview = lastCanvas ? computeLivePath?.(pane, lastCanvas, rawPos) : undefined; + setLivePreviewPath(preview && preview.length >= 2 ? preview : (lastCanvas ? [lastCanvas, rawPos] : null)); - if (corners.length >= 3) { - const [fx, fy] = corners[0]; - setNearClose(Math.hypot(rawPos[0] - fx, rawPos[1] - fy) < CLOSE_CLICK_RADIUS_PX); + if (cornersWorld.length >= 3) { + const first = worldToCanvasPoint(pane, cornersWorld[0]); + setNearClose(!!first && Math.hypot(rawPos[0] - first[0], rawPos[1] - first[1]) < CLOSE_CLICK_RADIUS_PX); } }; const undo = () => { - if (corners.length <= 1) { reset(); return; } + if (cornersWorld.length <= 1) { reset(); return; } const lastLegLen = legLengthsRef.current[legLengthsRef.current.length - 1] ?? 0; legLengthsRef.current = legLengthsRef.current.slice(0, -1); - setPoints((prev) => prev.slice(0, prev.length - lastLegLen)); - setCorners((prev) => prev.slice(0, -1)); + setPointsWorld((prev) => prev.slice(0, prev.length - lastLegLen)); + setCornersWorld((prev) => prev.slice(0, -1)); }; useEffect(() => { @@ -139,19 +182,19 @@ export function usePolygonDraw({ enabled, onClose, computeLivePath }: UsePolygon const onKey = (e: KeyboardEvent) => { const t = e.target as HTMLElement | null; if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; - if (e.key === "Escape" && corners.length) { e.preventDefault(); reset(); } - else if (e.key === "Enter" && corners.length >= 3) { e.preventDefault(); close(); } + if (e.key === "Escape" && cornersWorld.length) { e.preventDefault(); reset(); } + else if (e.key === "Enter" && cornersWorld.length >= 3) { e.preventDefault(); close(); } // Ctrl/Cmd+Z removes the last placed point, one at a time — replaces // the old "Undo point" button in the flyout with the shortcut users // actually reach for. - else if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "z" && corners.length) { + else if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "z" && cornersWorld.length) { e.preventDefault(); undo(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [enabled, corners, reset, close]); + }, [enabled, cornersWorld, reset, close]); return { pane: paneRef.current, From f6b5485d5b02a312b5f99d16ed39f2dfcc991611 Mon Sep 17 00:00:00 2001 From: harleensachdev Date: Thu, 13 Aug 2026 12:41:49 +0800 Subject: [PATCH 03/13] fix(smart-fill): store scribble preview dots in world space Same drift issue as the lasso/scissors fix, applied to the foreground/background scribble markers. --- PanTS-Demo/src/helpers/viewer/useSmartFill.ts | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/PanTS-Demo/src/helpers/viewer/useSmartFill.ts b/PanTS-Demo/src/helpers/viewer/useSmartFill.ts index aa12f929..4d54039c 100644 --- a/PanTS-Demo/src/helpers/viewer/useSmartFill.ts +++ b/PanTS-Demo/src/helpers/viewer/useSmartFill.ts @@ -1,14 +1,25 @@ import { useRef, useState, type MouseEvent } from "react"; import { canvasPointToVoxel, + canvasPointToWorld, + worldToCanvasPoint, runDualScribbleFill, pushEditHistory, type CinePane, type SliceInfo, type MaskFilter, } from "../CornerstoneNifti2"; - -type ScribblePoint = { pos: [number, number]; slice: number }; +import type { Point3 } from "@cornerstonejs/core/types"; + +// World-space, not canvas-pixel — a canvas position only means what it means +// for the camera active the instant it was clicked, so a dot stored that way +// visually drifts off the marked anatomy the moment the user zooms/pans. +// Storing world coordinates and reprojecting to canvas pixels on every +// render keeps the preview pinned to the same spot on the slice regardless +// of zoom. (The actual fill algorithm below already works in voxel space via +// fgVoxelsRef/bgVoxelsRef, so it was never affected — only the preview dots +// were drifting.) +type ScribblePoint = { posWorld: Point3; slice: number }; type PanePreview = { fg: ScribblePoint[]; bg: ScribblePoint[] }; const EMPTY_PREVIEW: Record = { @@ -38,19 +49,19 @@ interface UseSmartFillArgs { export function useSmartFill({ enabled, sliceInfoRef, maskFilter, onLog }: UseSmartFillArgs) { const [markMode, setMarkMode] = useState<"fg" | "bg">("fg"); const [scope, setScope] = useState<"slice" | "volume">("slice"); - const [preview, setPreview] = useState>(EMPTY_PREVIEW); + const [previewWorld, setPreviewWorld] = useState>(EMPTY_PREVIEW); const scribbleActiveRef = useRef(false); const fgVoxelsRef = useRef<[number, number, number][]>([]); const bgVoxelsRef = useRef<[number, number, number][]>([]); const paneRef = useRef(null); - // Mirrors `preview` so stroke bookkeeping can read the latest value + // Mirrors `previewWorld` so stroke bookkeeping can read the latest value // synchronously (state updates are async/batched, refs aren't). - const previewRef = useRef(preview); + const previewRef = useRef(previewWorld); const updatePreview = (next: Record) => { previewRef.current = next; - setPreview(next); + setPreviewWorld(next); }; // Captures everything needed to undo/redo one whole click-and-drag @@ -76,6 +87,8 @@ export function useSmartFill({ enabled, sliceInfoRef, maskFilter, onLog }: UseSm const canvasPos: [number, number] = [e.clientX - rect.left, e.clientY - rect.top]; const voxel = canvasPointToVoxel(pane, canvasPos); if (!voxel) return; + const world = canvasPointToWorld(pane, canvasPos); + if (!world) return; paneRef.current = pane; (markMode === "fg" ? fgVoxelsRef : bgVoxelsRef).current.push(voxel); @@ -85,7 +98,7 @@ export function useSmartFill({ enabled, sliceInfoRef, maskFilter, onLog }: UseSm ...previewRef.current, [pane]: { ...previewRef.current[pane], - [markMode]: [...previewRef.current[pane][markMode], { pos: canvasPos, slice: sliceIdx }], + [markMode]: [...previewRef.current[pane][markMode], { posWorld: world, slice: sliceIdx }], }, }); }; @@ -147,6 +160,24 @@ export function useSmartFill({ enabled, sliceInfoRef, maskFilter, onLog }: UseSm }); }; + // Canvas-pixel view of the world-space preview dots, reprojected against + // whatever camera (zoom/pan) is active on THIS render — this is what the + // overlay should actually draw from, so the dots track the marked + // anatomy through zoom instead of staying pinned to old pixel positions. + const preview: Record; bg: Array<{ pos: [number, number]; slice: number }> }> = { + axial: { fg: [], bg: [] }, + sagittal: { fg: [], bg: [] }, + coronal: { fg: [], bg: [] }, + }; + (Object.keys(previewWorld) as CinePane[]).forEach((pane) => { + (["fg", "bg"] as const).forEach((mode) => { + for (const pt of previewWorld[pane][mode]) { + const pos = worldToCanvasPoint(pane, pt.posWorld); + if (pos) preview[pane][mode].push({ pos, slice: pt.slice }); + } + }); + }); + return { markMode, setMarkMode, From d08e61d56aeab936ff0b85588223eb0f0d815cad Mon Sep 17 00:00:00 2001 From: harleensachdev Date: Thu, 13 Aug 2026 12:42:13 +0800 Subject: [PATCH 04/13] fix(level-tracing): re-derive outline on camera change + close-loop hint Adds cameraVersion param so the cached preview outline refreshes immediately on zoom instead of waiting for the next mousemove. Also adds a fading 'click here to close the lasso' nudge shown the first time the cursor gets within closing range of the polygon's first point. --- PanTS-Demo/src/components/LiveWireOverlay.tsx | 26 ++- .../src/helpers/viewer/useLevelTracing.ts | 23 ++- PanTS-Demo/src/routes/VisualizationPage.tsx | 157 +++++++++++++++++- 3 files changed, 197 insertions(+), 9 deletions(-) diff --git a/PanTS-Demo/src/components/LiveWireOverlay.tsx b/PanTS-Demo/src/components/LiveWireOverlay.tsx index f9bfa666..69e4fac8 100644 --- a/PanTS-Demo/src/components/LiveWireOverlay.tsx +++ b/PanTS-Demo/src/components/LiveWireOverlay.tsx @@ -5,6 +5,11 @@ type Props = { anchorPointsCanvas: Array<[number, number]>; // dense — used for the fill path only cornerPointsCanvas: Array<[number, number]>; // one per click — used for the dots livePreviewPath: Array<[number, number]> | null; + // True once the cursor is within closing range of the first point. Used + // only to soften/highlight the closing anchor here — the actual + // "click here to close" nudge is a separate in + // VisualizationPage.tsx, positioned off the same corner. + nearClose?: boolean; }; @@ -15,7 +20,7 @@ function pathToD(points: Array<[number, number]>, close: boolean): string { } -function LiveWireOverlay({ anchorPointsCanvas, cornerPointsCanvas, livePreviewPath }: Props) { +function LiveWireOverlay({ anchorPointsCanvas, cornerPointsCanvas, livePreviewPath, nearClose }: Props) { return ( )} - {cornerPointsCanvas.map((p, i) => ( - - ))} + {cornerPointsCanvas.map((p, i) => { + const isClosingAnchor = i === 0; + return ( + + ); + })} ); } diff --git a/PanTS-Demo/src/helpers/viewer/useLevelTracing.ts b/PanTS-Demo/src/helpers/viewer/useLevelTracing.ts index aafbe2b9..7611baff 100644 --- a/PanTS-Demo/src/helpers/viewer/useLevelTracing.ts +++ b/PanTS-Demo/src/helpers/viewer/useLevelTracing.ts @@ -1,5 +1,5 @@ // helpers/viewer/useLevelTracing.ts -import { useRef, useState, type MouseEvent } from "react"; +import { useEffect, useRef, useState, type MouseEvent } from "react"; import { canvasPointToVoxel, computeLevelTraceMask, @@ -21,6 +21,15 @@ interface UseLevelTracingArgs { operation: LevelTraceOperation; activeSegmentIndex: number | null; maskFilter: MaskFilter; + /** Bump this (e.g. pass the toolbar's zoom slider value) whenever the + * pane's camera changes. The traced mask itself is voxel-space and + * camera-independent, but its cached preview OUTLINE is a canvas-pixel + * path computed the moment the mouse last moved — without this, zooming + * without also moving the mouse leaves that outline drawn at the old + * zoom level (visually detached from the anatomy) until the next + * mousemove happens to refresh it. Re-deriving the outline from the + * still-valid traced mask on every camera change keeps it pinned. */ + cameraVersion?: number; onLog?: (detail: string) => void; } @@ -28,7 +37,7 @@ interface UseLevelTracingArgs { * region under the cursor on the current slice and preview its outline; on * click, commit it into (or out of) the active segment per `operation`. */ export function useLevelTracing({ - enabled, toleranceHu, operation, activeSegmentIndex, maskFilter, onLog, + enabled, toleranceHu, operation, activeSegmentIndex, maskFilter, cameraVersion, onLog, }: UseLevelTracingArgs) { const [previewPane, setPreviewPane] = useState(null); const [previewPath, setPreviewPath] = useState | null>(null); @@ -82,6 +91,16 @@ export function useLevelTracing({ ); }; + // Re-derive the outline from the still-valid (voxel-space) traced mask + // whenever the camera changes, instead of leaving it drawn at whatever + // canvas pixels it happened to occupy at the last mousemove. + useEffect(() => { + const traced = tracedRef.current; + if (!traced) return; + setPreviewPath(levelTraceMaskToCanvasPath(traced.pane, traced.mask)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cameraVersion]); + return { handleClick, handleMouseMove, diff --git a/PanTS-Demo/src/routes/VisualizationPage.tsx b/PanTS-Demo/src/routes/VisualizationPage.tsx index 361fd9fd..10ea77e2 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.tsx +++ b/PanTS-Demo/src/routes/VisualizationPage.tsx @@ -410,6 +410,86 @@ function useToolbarFlyout() { return { open, pos, groupRef, btnRef, menuRef, toggle, close }; } +// One-time "click here to close" nudge shown next to the closing anchor +// (the highlighted/red first point) on the Lasso/Scissors live-wire +// overlay, the moment the cursor first gets close enough to actually close +// the loop. Fades away automatically after a few seconds rather than +// needing a dismiss click, since by the time it fades the person has +// almost always already seen the highlighted anchor itself. Resets the +// moment the loop is closed/cancelled (anchor goes away), so it can show +// again on the next shape. +const CLOSE_LOOP_HINT_VISIBLE_MS = 3000; +const CLOSE_LOOP_HINT_FADE_MS = 300; +function CloseLoopHint({ nearClose, anchor }: { nearClose: boolean; anchor: [number, number] | undefined }) { + const [visible, setVisible] = useState(false); + const [fading, setFading] = useState(false); + // Tracks whether this hint has already been shown for the CURRENT loop + // in progress, so it only ever fires once per shape rather than + // re-triggering every time the cursor wanders in and out of range. + const shownThisLoopRef = useRef(false); + const fadeTimerRef = useRef(null); + const hideTimerRef = useRef(null); + + const clearTimers = () => { + if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current); + if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current); + fadeTimerRef.current = null; + hideTimerRef.current = null; + }; + + // No anchor means there's no shape in progress (just closed, cancelled, + // or not started yet) — reset so the next shape can show the hint again. + useEffect(() => { + if (anchor) return; + shownThisLoopRef.current = false; + setVisible(false); + setFading(false); + clearTimers(); + }, [anchor]); + + useEffect(() => { + if (!nearClose || !anchor || shownThisLoopRef.current) return; + shownThisLoopRef.current = true; + setFading(false); + setVisible(true); + fadeTimerRef.current = window.setTimeout(() => setFading(true), CLOSE_LOOP_HINT_VISIBLE_MS); + hideTimerRef.current = window.setTimeout(() => setVisible(false), CLOSE_LOOP_HINT_VISIBLE_MS + CLOSE_LOOP_HINT_FADE_MS); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nearClose, anchor]); + + useEffect(() => () => clearTimers(), []); + + if (!visible || !anchor) return null; + return ( + + ); +} + function VisualizationPage() { // References and state const params = useParams(); @@ -736,6 +816,21 @@ function VisualizationPage() { const setActiveSegment = (id: number | null) => setActiveSegmentState(id); + // Shared by both "select a custom class" and "select an existing organ" + // (see onSelect/handleSelectCatalogOrgan below): moves both the 2D MPR + // crosshair and the 3D crosshair to the centroid of whatever class was + // just targeted, on axial/sagittal/coronal at once — same mechanism the + // sidebar's "jump to organ" already uses (handleJumpToOrgan below), just + // triggered from the popup's own row click instead. No-ops quietly if + // the class has no voxels yet (nothing painted into it) or centroid data + // isn't available for it. + const jumpCrosshairToSegmentCentroid = (label: number) => { + const centroid = getOrganCentroids()?.[label]; + if (!centroid) return; + moveCornerstoneCrosshairToMm(centroid); + setCrosshairMm(centroid); + }; + // Selecting an existing organ from the dropdown targets the brush at it // exactly like clicking a custom-segment row does. const handleSelectCatalogOrgan = (id: number | null) => { @@ -744,6 +839,7 @@ function VisualizationPage() { // (id === null) must clear activeSegment too, or a stale id lingers // and SegmentsPopup keeps showing a target as active. setActiveSegmentState(id); + if (id != null) jumpCrosshairToSegmentCentroid(id); }; const handleRenameSegment = (id: number, name: string): boolean => { const dup = checkBoxData.some((s) => s.id !== id && s.label.toLowerCase() === name.toLowerCase()); @@ -1121,6 +1217,11 @@ function VisualizationPage() { operation: levelTraceOperation, activeSegmentIndex: activeSegment, maskFilter, + // Lets the hook re-derive its cached preview outline (voxel-space, + // camera-independent) against the new camera the instant zoom changes, + // instead of leaving it drawn at old canvas pixels until the next + // mousemove happens to refresh it. + cameraVersion: zoomLevel, onLog: (detail) => sessionRef.current?.log("edit", detail, 1500), }); @@ -1246,6 +1347,23 @@ function VisualizationPage() { setActiveMeasurementTool(null); setActiveMaskEditTool(null); releasePrimaryMouseTools(); + } else if (editMode === "lasso" || activeToolbarTool === "levelTracing") { + // Scissors/lasso (editMode "lasso") and level tracing (its own hook, + // keyed off activeToolbarTool rather than editMode) all place their + // points via plain clicks on the pane, same as smart fill's + // scribbling — so the crosshair tool needs to be OFF here too, not + // just left to whatever `crosshairToolActive` (the user's saved + // navigation preference) happens to be. Previously this fell + // through to the plain `else` below, which re-enabled Crosshairs + // whenever `crosshairToolActive` was true (the default) — so the + // crosshair stayed live and interactive under the polygon/trace + // clicks even though the toolbar's own crosshair button visually + // showed itself as deselected (its active-state check already + // excludes any editMode) — nothing in the UI hinted navigation was + // still armed underneath. + setActiveMeasurementTool(null); + setActiveMaskEditTool(null); + toggleCrosshairTool(false); } else if (activeMeasureTool) { setActiveMaskEditTool(null); setActiveMeasurementTool(activeMeasureTool); @@ -1254,7 +1372,7 @@ function VisualizationPage() { setActiveMeasurementTool(null); toggleCrosshairTool(crosshairToolActive); } - }, [editMode, activeMeasureTool, crosshairToolActive]); + }, [editMode, activeToolbarTool, activeMeasureTool, crosshairToolActive]); @@ -2272,6 +2390,25 @@ function VisualizationPage() { } }; + const handleToggleAnnotationToolbar = () => { + const opening = !showAnnotationToolbar; + setShowAnnotationToolbar(opening); + if (!opening) { + // Closing (deselecting the Annotate button): drop whatever class + // was targeted — the isolation effect above reacts to + // activeCatalogOrganId/activeSegment both going null by putting + // every segmentation mask back to visible — and back out of + // whatever tool/edit mode was active, so the toolbar and popup + // (both driven by the same `open`/`showAnnotationToolbar` prop) + // close together instead of the target/tool state lingering + // invisibly after the UI has visually gone away. + setActiveCatalogOrganId(null); + setActiveSegmentState(null); + setEditMode(null); + setActiveToolbarTool(null); + } + }; + const handleToggleStats = () => { // The right-side slot is shared by stats / metadata / measurements / mask editing. setShowMetadata(false); @@ -2996,7 +3133,7 @@ const aiAvailableOrgans = useMemo(() => { + +
+ {SHORTCUT_GROUPS.map((group) => ( +
+

{group.label}

+ {group.rows.map((row) => ( +
+ {row.label} + + {row.keys.map((k, i) => ( + + {i > 0 && +} + {k} + + ))} + +
+ ))} +
+ ))} +
+
+ +
+ + , + document.body + ); +} export default function AnnotationToolbar({ open, hasSegments, hasActiveTarget, activeTool, onToolChange, diameterMm, onDiameterChange, onDiameterPreviewChange, scissorsOptions, onScissorsOptionsChange, renderFlyout, scissorsPointCount, onScissorsCancel, maskingArea, onMaskingAreaChange, hasAnySegments, scopeLocked, isRendering, isDeletingSegment, targetKey, showOnlyTargetMask, onShowOnlyTargetMaskChange, - popupRef, popupDragRef, popupMinRef, + popupRef, popupDragRef, popupMinRef, anchorRef, }: AnnotationToolbarProps) { const [hoveredTool, setHoveredTool] = useState(null); const [hoveredRect, setHoveredRect] = useState(null); const iconRefs = useRef>({}); + // Just the icon @@ -1121,6 +1352,70 @@ export default function AnnotationToolbar({ )} + {shortcutsIntroOpen && setShortcutsIntroOpen(false)} />} + + {open && guidedHintOpen && guidedHintRect && ( + <> + {/* Same dashed-spotlight treatment as the pick-class/first-target + hints above, but wrapping the Continue/Start over/Exit cluster + itself so it's obvious which controls the card is describing. */} + , document.body From 9a893a84b5d513205504d8984bb6c930d4875ec2 Mon Sep 17 00:00:00 2001 From: harleensachdev Date: Thu, 13 Aug 2026 12:48:08 +0800 Subject: [PATCH 09/13] chore: trigger mergeability recheck From f2ca937946c45a1a909dd5f80ea6fc647e762d07 Mon Sep 17 00:00:00 2001 From: harleensachdev Date: Thu, 13 Aug 2026 13:10:51 +0800 Subject: [PATCH 10/13] fix(toolbar): restore missing guided-hint effects and dismiss callback --- .../components/viewer/AnnotationToolbar.tsx | 130 ++++++++---------- 1 file changed, 58 insertions(+), 72 deletions(-) diff --git a/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx b/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx index 1a30286c..676a5b68 100644 --- a/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx +++ b/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx @@ -19,7 +19,7 @@ import "./AnnotationToolbar.css"; import MaskingSelect, { type MaskingArea } from "../segmentation/MaskingSelect"; import NumberSliderField from "../NumberSliderField"; import { FlyoutArrow, FlyoutPanel, MenuColumn, MenuRow, MenuDivider, useFlyout } from "./FlyoutPrimitives"; -import { PickErrorHint, type GuidedFlowControls } from "../segmentation/SliceAnchorPickerUI"; +import type { GuidedFlowControls } from "../segmentation/SliceAnchorPickerUI"; // sessionStorage keys: once the overview tour (or first-target hint) has // been seen, it won't auto-open again FOR THE REST OF THIS TAB SESSION. @@ -607,18 +607,12 @@ export default function AnnotationToolbar({ const toolFlyout = useFlyout(false, { scope: "top", - // Outside click behavior differs by tool: the "equip and use on the - // canvas" tools (paint/erase/scissors/level tracing — LIVE_COMMIT_TOOLS) - // stay equipped when you click away to do something else on the canvas - // — only their settings flyout closes, so the icon keeps its - // selected/white background and the tool is still active to use. The - // one-shot action tools (margin, smoothing, islands, etc.) fully - // deselect on an outside click, same as before. A guided-overlay tool - // routes through its own Exit so scribbles/anchors/picks get cleared. + // Outside click = full deselect. For a guided-overlay tool, route + // through its own Exit handler so scribbles/anchors/picks get + // cleared too; everything else just deselects directly. onOutsideClose: () => { - if (guidedControlsRef.current) { guidedControlsRef.current.onExit(); return; } - if (activeTool && LIVE_COMMIT_TOOLS.includes(activeTool)) return; // flyout already closes itself; stay equipped - onToolChange(null); + if (guidedControlsRef.current) guidedControlsRef.current.onExit(); + else onToolChange(null); }, }); @@ -844,29 +838,62 @@ export default function AnnotationToolbar({ // tool is active, instead of each tool floating its own controls over // the canvas. const [guidedControls, setGuidedControls] = useState(null); - // Brief Hopkins-blue warning shown near the cursor when Continue is - // pressed while blocked (e.g. no seed points marked yet) — only appears - // on an actual press, not just sitting there whenever the button happens - // to be disabled. Same PickErrorHint pill used for "no valid segment - // here" during Copy across slices, positioned off the click that - // triggered it, rather than a caption fixed in the ribbon. - const [continueWarning, setContinueWarning] = useState(null); - const [continueWarningPos, setContinueWarningPos] = useState<{ x: number; y: number } | null>(null); - const continueWarningTimerRef = useRef(null); - const flashContinueWarning = (message: string, pos: { x: number; y: number }) => { - if (continueWarningTimerRef.current) window.clearTimeout(continueWarningTimerRef.current); - setContinueWarning(message); - setContinueWarningPos(pos); - continueWarningTimerRef.current = window.setTimeout(() => setContinueWarning(null), 2200); - }; useEffect(() => { guidedControlsRef.current = guidedControls; - if (!guidedControls) setContinueWarning(null); // don't let a stale warning bleed into the next flow }, [guidedControls]); useEffect(() => { if (!activeTool) setGuidedControls(null); }, [activeTool]); - useEffect(() => () => { if (continueWarningTimerRef.current) window.clearTimeout(continueWarningTimerRef.current); }, []); + + // Fires once, on the null -> present transition (not on every re-render + // while a flow is already running), the first time this session that a + // given guided-flow family actually shows its Continue/Start over/Exit + // controls. Skipped while `busy` — those controls aren't on screen yet + // (see the `guidedControls.busy` branch in the render below). + useEffect(() => { + const wasPresent = prevGuidedControlsRef.current; + prevGuidedControlsRef.current = guidedControls; + if (wasPresent || !guidedControls || guidedControls.busy) return; + const group = guidedHintGroup(activeTool); + if (!group || !activeTool) return; + // Per-tool key (not per-group) — Grow from Seeds having been seen + // shouldn't suppress the explainer for Copy across slices, Fill + // between slices, or Islands, and vice versa between those three. + const seenKey = `${GUIDED_HINT_SEEN_KEY_PREFIX}${activeTool}`; + let alreadySeen = false; + try { + alreadySeen = typeof window !== "undefined" && window.sessionStorage.getItem(seenKey) === "1"; + } catch { /* sessionStorage unavailable — just show it */ } + if (alreadySeen) return; + setGuidedHintText(GUIDED_HINT_COPY[group]); + setGuidedHintOpen(true); + try { + if (typeof window !== "undefined") window.sessionStorage.setItem(seenKey, "1"); + } catch { /* not worth blocking on */ } + }, [guidedControls, activeTool]); + + // Once the flow's controls go away (tool exited/deselected) or flip into + // `busy` (buttons swap for the "Applying…" indicator), the hint no + // longer has anything to point at, so close it automatically. + useEffect(() => { + if (!guidedControls || guidedControls.busy) setGuidedHintOpen(false); + }, [guidedControls]); + + useLayoutEffect(() => { + if (!guidedHintOpen) return; + const measure = () => setGuidedHintRect(guidedControlsBoxRef.current ? guidedControlsBoxRef.current.getBoundingClientRect() : null); + measure(); + window.addEventListener("resize", measure); + window.addEventListener("scroll", measure, true); + const id = window.setInterval(measure, 200); // controls sit in a fixed ribbon, but keep parity with the other live-measured hints + return () => { + window.removeEventListener("resize", measure); + window.removeEventListener("scroll", measure, true); + window.clearInterval(id); + }; + }, [guidedHintOpen]); + + const dismissGuidedHint = useCallback(() => setGuidedHintOpen(false), []); // Keeps the "Applying…" dot mounted for a beat after `isRendering` goes // false so it can fade out via CSS instead of vanishing mid-pulse. @@ -958,7 +985,7 @@ export default function AnnotationToolbar({ aria-orientation="horizontal" >
-
+
{TOOL_DEFS.map(({ id, label, Icon, description }) => { // Only equip-and-use tools (paint/erase/scissors/level tracing) // get a settings arrow; other tools open settings on icon click. @@ -1083,47 +1110,6 @@ export default function AnnotationToolbar({ ) : ( <> - {guidedControls.onContinue && ( - // The primary "move to the next step" action — made visually - // louder (solid blue, pulsing) than Start over / Exit so it's - // the obvious next thing to press, and placed first since it's - // the one most people want most of the time. Deliberately not a - // native `disabled` button even when blocked — a disabled button - // can't be clicked at all, so pressing it couldn't show a - // warning. It stays clickable; pressing it while blocked flashes - // an orange "why" message instead of a permanently-visible caption. - - - {continueWarning && continueWarningPos && ( - - )} - - )} + )} -
- + {/* Small blue rectangle pinned near the cursor when Continue is + clicked while the guided flow still has nothing to continue + with (e.g. no seed point marked yet). Same visual language as + SliceAnchorPickerUI's PickErrorHint, kept local here since this + fires from the ribbon's Continue button, not from a canvas + click. */} + {continueBlockedHint && ( +
+ {continueBlockedHint.message} +
)} , From 489a0ddc232a076487c69dac78e48f0231d44346 Mon Sep 17 00:00:00 2001 From: harleensachdev Date: Thu, 13 Aug 2026 14:19:01 +0800 Subject: [PATCH 12/13] feat(toolbar): restore guided-flow Continue/Start over/Exit explainer popup alongside Continue button --- .../components/viewer/AnnotationToolbar.tsx | 148 +++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx b/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx index fc47ea94..9677fba2 100644 --- a/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx +++ b/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx @@ -28,6 +28,11 @@ import type { GuidedFlowControls } from "../segmentation/SliceAnchorPickerUI"; const OVERVIEW_WALKTHROUGH_SEEN_KEY = "mm_annotation_walkthrough_seen"; const FIRST_TARGET_HINT_SEEN_KEY = "mm_annotation_first_target_hint_seen"; const SHORTCUTS_INTRO_SEEN_KEY = "mm_annotation_shortcuts_intro_seen"; +// Guided-flow (Continue / Start over / Exit) explainer. Each guided tool +// (Grow from Seeds, Copy across slices, Fill between slices, Islands) gets +// its OWN "seen" flag — so seeing the explainer for one doesn't suppress it +// for the others — even though several of them share the same wording. +const GUIDED_HINT_SEEN_KEY_PREFIX = "mm_annotation_guided_hint_seen_"; // Grouped keyboard shortcuts shown once, the moment the annotation toolbar // itself is opened (see SHORTCUTS_INTRO_SEEN_KEY below). Kept as plain data @@ -166,6 +171,24 @@ const LIVE_COMMIT_TOOLS: Exclude[] = ["paint", "erase", " const MIN_DIAMETER_MM = 2; const MAX_DIAMETER_MM = 40; +// Which "explain Continue / Start over / Exit" message a guided tool falls +// under. Grow from Seeds gets its own copy; the slice-range tools (Copy/Fill +// across slices) and Islands' pick-based ops (Remove picked/Keep picked) +// all drive the exact same three controls, so they share one message keyed +// off a single "seen" flag rather than repeating the popup three times. +type GuidedHintGroup = "growSeeds" | "sliceOps"; +function guidedHintGroup(tool: PrimaryEditTool): GuidedHintGroup | null { + if (tool === "growFromSeeds") return "growSeeds"; + if (tool === "copyAcrossSlices" || tool === "fillBetweenSlices" || tool === "islands") return "sliceOps"; + return null; +} +const GUIDED_HINT_COPY: Record = { + growSeeds: + "Continue moves on once you've placed your seed scribbles. Start over clears every seed and lets you begin again. Exit leaves Grow from Seeds without changing anything.", + sliceOps: + "Continue (labeled Remove picked/Keep picked/etc. depending on the tool) applies your picks. Start over clears them and lets you pick again. Exit leaves the tool without changing anything.", +}; + // Ribbon height, matches --atb-ribbon-h in CSS. Exported so SegmentsPopup // can dock directly beneath the ribbon without duplicating the constant. export const ANNOTATION_DOCK_WIDTH = 60; @@ -511,6 +534,14 @@ export default function AnnotationToolbar({ // ribbon is clicked (once a target class is already active, since a // disabled icon click goes through pickClassHintOpen instead). const [shortcutsIntroOpen, setShortcutsIntroOpen] = useState(false); + // "Explain Continue/Start over/Exit" — shown the first time a guided flow + // (Grow from Seeds, Copy/Fill-across-slices, Islands' pick ops) actually + // surfaces those controls, once per flow family per session. + const [guidedHintOpen, setGuidedHintOpen] = useState(false); + const [guidedHintRect, setGuidedHintRect] = useState(null); + const [guidedHintText, setGuidedHintText] = useState(""); + const guidedControlsBoxRef = useRef(null); + const prevGuidedControlsRef = useRef(null); const [, setPanelRect] = useState(null); const [, setPanelDragRect] = useState(null); const [, setDockRect] = useState(null); @@ -828,6 +859,56 @@ export default function AnnotationToolbar({ if (!activeTool) setGuidedControls(null); }, [activeTool]); + // Fires once, on the null -> present transition (not on every re-render + // while a flow is already running), the first time this session that a + // given guided-flow family actually shows its Continue/Start over/Exit + // controls. Skipped while `busy` — those controls aren't on screen yet + // (see the `guidedControls.busy` branch in the render below). + useEffect(() => { + const wasPresent = prevGuidedControlsRef.current; + prevGuidedControlsRef.current = guidedControls; + if (wasPresent || !guidedControls || guidedControls.busy) return; + const group = guidedHintGroup(activeTool); + if (!group || !activeTool) return; + // Per-tool key (not per-group) — Grow from Seeds having been seen + // shouldn't suppress the explainer for Copy across slices, Fill + // between slices, or Islands, and vice versa between those three. + const seenKey = `${GUIDED_HINT_SEEN_KEY_PREFIX}${activeTool}`; + let alreadySeen = false; + try { + alreadySeen = typeof window !== "undefined" && window.sessionStorage.getItem(seenKey) === "1"; + } catch { /* sessionStorage unavailable — just show it */ } + if (alreadySeen) return; + setGuidedHintText(GUIDED_HINT_COPY[group]); + setGuidedHintOpen(true); + try { + if (typeof window !== "undefined") window.sessionStorage.setItem(seenKey, "1"); + } catch { /* not worth blocking on */ } + }, [guidedControls, activeTool]); + + // Once the flow's controls go away (tool exited/deselected) or flip into + // `busy` (buttons swap for the "Applying…" indicator), the hint no + // longer has anything to point at, so close it automatically. + useEffect(() => { + if (!guidedControls || guidedControls.busy) setGuidedHintOpen(false); + }, [guidedControls]); + + useLayoutEffect(() => { + if (!guidedHintOpen) return; + const measure = () => setGuidedHintRect(guidedControlsBoxRef.current ? guidedControlsBoxRef.current.getBoundingClientRect() : null); + measure(); + window.addEventListener("resize", measure); + window.addEventListener("scroll", measure, true); + const id = window.setInterval(measure, 200); // controls sit in a fixed ribbon, but keep parity with the other live-measured hints + return () => { + window.removeEventListener("resize", measure); + window.removeEventListener("scroll", measure, true); + window.clearInterval(id); + }; + }, [guidedHintOpen]); + + const dismissGuidedHint = useCallback(() => setGuidedHintOpen(false), []); + // Keeps the "Applying…" dot mounted for a beat after `isRendering` goes // false so it can fade out via CSS instead of vanishing mid-pulse. const [renderingDotMounted, setRenderingDotMounted] = useState(false); @@ -982,9 +1063,12 @@ export default function AnnotationToolbar({ (Grow-from-seeds, Copy/Fill-across-slices, Islands) — fixed in the ribbon, not floating over the canvas. No title/label text identifying which guided flow is running is shown here — just - the controls themselves. */} + the controls themselves (see guidedControlsBoxRef below, used + only to anchor the one-time Continue/Start over/Exit explainer + popup, not for a visible label). */} {guidedControls && (
setShortcutsIntroOpen(false)} />} + {open && guidedHintOpen && guidedHintRect && ( + <> + {/* Same dashed-spotlight treatment as the pick-class/first-target + hints above, but wrapping the Continue/Start over/Exit cluster + itself so it's obvious which controls the card is describing. */} + , + document.body ); } -// Single compact "this is the current target" indicator, shared by both the -// custom-class rows and the existing-organ rows so the two tabs read the -// same way. Icon-only (with a tooltip) so it costs as little row width as -// possible, leaving more room for the name itself. -function TargetBadge() { - return ( - - - - ); -} + // Gap kept clear between the docked panel's top edge and the reserved // annotation area above it (topbar + ribbon + flyout strip — see @@ -364,14 +367,13 @@ export default function SegmentsPopup({ root.style.setProperty("--atb-segpanel-w", open ? `${width}px` : "0px"); }, [open, width]); -const [tab, setTab] = useState("existing"); - - // `adding` drives the Collapse's `in` prop (expanded vs. animating - // closed); `addFormMounted` stays true for the extra beat it takes the - // close transition to actually finish, cleared only by Collapse's own - // `onExited` — so the form is never yanked out mid-animation. + const [tab, setTab] = useState("existing"); + // `adding` drives whether the Add-class FormFlyout is mounted at all — + // no separate "still animating closed" bookkeeping needed anymore since + // FormFlyout owns its own close animation/timer internally and only + // calls back once it's genuinely done. const [adding, setAdding] = useState(false); - const [addFormMounted, setAddFormMounted] = useState(false); + const [addAnchorEl, setAddAnchorEl] = useState(null); const [draftName, setDraftName] = useState(""); const [draftColor, setDraftColor] = useState(NEXT_COLOR_POOL[segments.length % NEXT_COLOR_POOL.length]); const [createError, setCreateError] = useState(""); @@ -379,18 +381,17 @@ const [tab, setTab] = useState("existing"); // Combined name+color editor, opened via the pen icon (replaces the old // double-click-to-rename-only flow — both fields are changed and - // confirmed together, in one place). - // `editingId` drives Collapse's `in` prop; `editRowMountedId` stays set - // (same pattern as addFormMounted above) until Collapse's `onExited` - // confirms the close transition has actually finished. + // confirmed together, in one place). Same "no separate mounted flag" + // simplification as `adding` above — the FormFlyout itself tracks its + // close animation. const [editingId, setEditingId] = useState(null); - const [editRowMountedId, setEditRowMountedId] = useState(null); + const [editAnchorEl, setEditAnchorEl] = useState(null); const [editNameDraft, setEditNameDraft] = useState(""); const [editColorDraft, setEditColorDraft] = useState("#ffffff"); const [renameError, setRenameError] = useState(null); const [editColorPopoverOpen, setEditColorPopoverOpen] = useState(false); // Anchors for the portaled ColorPickerPopover — one swatch button lives - // in the add-form, the other in the inline edit row, and only one of + // in the add-form flyout, the other in the edit flyout, and only one of // either is ever mounted at a time, but keeping separate refs avoids // them fighting over a single ref across renders. const addColorBtnRef = useRef(null); @@ -440,29 +441,28 @@ const [tab, setTab] = useState("existing"); const switchTab = (next: PopupTab) => { setTab(next); setAdding(false); - setAddFormMounted(false); setCreateError(""); setEditingId(null); - setEditRowMountedId(null); setConfirmDeleteId(null); }; - const startAdd = () => { + const startAdd = (e: React.MouseEvent) => { + setAddAnchorEl(e.currentTarget); setAdding(true); - setAddFormMounted(true); setDraftName(""); setCreateError(""); setDraftColor(NEXT_COLOR_POOL[segments.length % NEXT_COLOR_POOL.length]); }; - // Kicks off the add-form's collapse-out transition — shared by both - // Cancel and a successful Add so closing always reads the same way - // regardless of why it's closing. The form stays mounted (see - // `addFormMounted`) until Collapse's `onExited` fires below, once the - // animation has genuinely finished. + // Fully closes the add flyout immediately — used when something else + // (switching tabs, deleting) needs it gone right away, with no need for + // its own gradual close animation. The FormFlyout's own Cancel/Enter/ + // Apply paths instead call the `requestClose` it hands them, which + // plays the close animation first and calls this once it's done. const closeAddForm = () => { setAddColorPopoverOpen(false); setAdding(false); + setAddAnchorEl(null); }; const commitAdd = (): boolean => { @@ -490,20 +490,19 @@ const [tab, setTab] = useState("existing"); return true; }; - const startEdit = (id: number, currentName: string, currentColor: string) => { + const startEdit = (id: number, currentName: string, currentColor: string, anchorEl: HTMLElement) => { setEditingId(id); - setEditRowMountedId(id); + setEditAnchorEl(anchorEl); setEditNameDraft(currentName); setEditColorDraft(currentColor); setRenameError(null); }; - // Kicks off the edit row's collapse-out transition, shared by Save and - // Cancel so both close the same way. The row stays mounted until - // Collapse's `onExited` fires (see the render below), once the close - // transition has actually finished — no more setTimeout guessing. + // Immediately closes the edit flyout — see closeAddForm's note above for + // why this is separate from the FormFlyout's own animated requestClose. const closeEdit = (id: number) => { setEditColorPopoverOpen(false); setEditingId((cur) => (cur === id ? null : cur)); + setEditAnchorEl(null); }; const cancelEdit = () => { if (editingId == null) return; @@ -597,7 +596,7 @@ const [tab, setTab] = useState("existing"); onClick={() => handleSelectExisting(o.id)} > {toTitleCase(o.label)} - {activeCatalogOrganId === o.id && } + ))}
@@ -613,80 +612,11 @@ const [tab, setTab] = useState("existing"); const active = isCustomActive(s.id); const hex = colors[s.id] ?? "#ffffff"; const isEditing = editingId === s.id; - const isEditRowMounted = editRowMountedId === s.id; - - if (isEditRowMounted) { - const remaining = MAX_SEGMENT_NAME_LENGTH - editNameDraft.length; - return ( - setEditRowMountedId((cur) => (cur === s.id ? null : cur))} - className="segpop__row segpop__row--editing" - > -
e.stopPropagation()} - > - {/* Swatch button opens the gradual color popover instead of the - OS's own abrupt native picker — same swatch look as the static - row, just clickable while editing. */} -
-
- { setEditNameDraft(e.target.value); setRenameError(null); }} - onKeyDown={(e) => { - // Same fix as the add-form input above: close on a - // successful Enter-commit instead of leaving the row - // stuck open in edit mode. - if (e.key === "Enter") { if (commitEdit(s.id)) closeEdit(s.id); } - if (e.key === "Escape") cancelEdit(); - }} - /> - {renameError === s.id && Name in use} - {renameError !== s.id && remaining <= 10 && ( - {remaining} characters left - )} - {/* Same Apply/Cancel row arrangement as the "Add class" - form below — one standardized commit control across the - popup instead of this row's own bespoke Save/X buttons. */} -
- commitEdit(s.id)} onDone={() => closeEdit(s.id)} label="Save" applyingLabel="Saving…" successLabel="Saved" /> - -
-
-
- ); - } - const isDeleting = deletingIds.has(s.id); return (
{ if (!isDeleting) { onSelect(active ? null : s.id); } }} >
+ { setEditNameDraft(e.target.value); setRenameError(null); }} + onKeyDown={(e) => { + if (e.key === "Enter") { if (commitEdit(s.id)) requestClose(); } + if (e.key === "Escape") requestClose(); + }} + /> +
+ {renameError === s.id && Name in use} + {renameError !== s.id && remaining <= 10 && ( + {remaining} characters left + )} +
+ commitEdit(s.id)} onDone={requestClose} label="Save" applyingLabel="Saving…" successLabel="Saved" /> + +
+
+ )} + + ); + })()} + {confirmDeleteId != null && ( ("existing"); /> )} - {addFormMounted ? ( - setAddFormMounted(false)} - className="segpop__add-form" - > -
-
-
- +
+ {adding && ( + + {(requestClose) => ( +
+
+
+
+ { setDraftName(e.target.value); setCreateError(""); }} + onKeyDown={(e) => { + if (e.key === "Enter") { if (commitAdd()) requestClose(); } + if (e.key === "Escape") requestClose(); + }} /> - )} +
+ {createError && {createError}} +
+ + +
- { setDraftName(e.target.value); setCreateError(""); }} - onKeyDown={(e) => { - // Mirrors the ApplyButton path below: on a successful add, - // close the form the same way clicking "Add class" would - // (previously this just called commitAdd() and left the - // form sitting open with no visible next step). - if (e.key === "Enter") { if (commitAdd()) closeAddForm(); } - if (e.key === "Escape") closeAddForm(); - }} - /> -
- {createError && {createError}} -
- {/* Same ApplyButton used by every flyout's "do this now" - action — one consistent commit control across the panel. */} - - -
-
-
- ) : ( -
- -
+ )} + )} )}