From c87eb663f3a446fd9038199cb0ce402993b51611 Mon Sep 17 00:00:00 2001 From: harleensachdev Date: Sat, 15 Aug 2026 21:57:16 +0800 Subject: [PATCH 1/8] style: replace orange accent color with JHU blue across toolbar/flyouts --- .../src/components/viewer/AnnotationToolbar.css | 10 +++++----- .../src/components/viewer/FlyoutPrimitives.css | 16 ++++++++++------ .../src/components/viewer/FlyoutPrimitives.tsx | 2 +- PanTS-Demo/src/routes/VisualizationPage.css | 17 ++++++++++++----- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/PanTS-Demo/src/components/viewer/AnnotationToolbar.css b/PanTS-Demo/src/components/viewer/AnnotationToolbar.css index 52f88603..de48b81c 100644 --- a/PanTS-Demo/src/components/viewer/AnnotationToolbar.css +++ b/PanTS-Demo/src/components/viewer/AnnotationToolbar.css @@ -152,7 +152,7 @@ .atb-flyout__range:active::-webkit-slider-thumb { background: #0F172A; - border-color: #E76F51; + border-color: #68ACE5; } .atb-flyout__range::-moz-range-track { @@ -348,8 +348,8 @@ background: rgba(34, 34, 42, 0.62); backdrop-filter: blur(20px) saturate(150%); -webkit-backdrop-filter: blur(20px) saturate(150%); - border-left: 2px solid #E76F51; - border-top: 2px solid #E76F51; + border-left: 2px solid #68ACE5; + border-top: 2px solid #68ACE5; border-radius: 4px 0 0 0; transform: translateX(-50%) rotate(45deg); transition: left 0.2s ease; @@ -920,7 +920,7 @@ font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--accent, #E76F51); + color: var(--jhu-blue-accent, #68ACE5); margin: 0 0 8px; } @@ -979,7 +979,7 @@ padding: 10px 0; border-radius: 999px; background: var(--jhu-blue, #0F172A); - border: 1px solid var(--accent, #E76F51); + border: 1px solid var(--jhu-blue-accent, #68ACE5); color: #ffffff; font-size: 12.5px; font-weight: 800; diff --git a/PanTS-Demo/src/components/viewer/FlyoutPrimitives.css b/PanTS-Demo/src/components/viewer/FlyoutPrimitives.css index 4457e6db..741813f9 100644 --- a/PanTS-Demo/src/components/viewer/FlyoutPrimitives.css +++ b/PanTS-Demo/src/components/viewer/FlyoutPrimitives.css @@ -86,8 +86,8 @@ width: 12px; height: 12px; background: #202124; - border-left: 2px solid #E76F51; - border-top: 2px solid #E76F51; + border-left: 2px solid #3499ff; + border-top: 2px solid #3499ff; border-radius: 3px 0 0 0; z-index: 1; } @@ -284,13 +284,17 @@ instead of a differently-shaped control breaking the column's rhythm. */ .atb-menu-row--checkbox { cursor: pointer; + +} +.atb-menu-row_.check { + stroke: #3b82f6; } .atb-menu-row__checkbox-input { flex-shrink: 0; width: 14px; height: 14px; - accent-color: #E76F51; + accent-color: #3499ff; cursor: pointer; } @@ -338,7 +342,7 @@ .atb-menu-row__expand-chip.is-open { background: rgba(15, 23, 42, 0.35); border-color: rgba(104, 172, 229, 0.5); - color: #E76F51; + color: #3499ff; } .atb-menu-row:hover:not(:disabled) .atb-menu-row__expand-chip:not(.is-open) { @@ -367,7 +371,7 @@ justify-content: center; width: 16px; height: 16px; - color: var(--accent, #E76F51); + color: var(--accent, #3499ff); animation: atb-check-pop 0.15s ease-out; } @@ -587,7 +591,7 @@ .atb-action-btn__check { flex-shrink: 0; - color: #E76F51; + color: #3499ff; animation: atb-action-check-in 0.24s cubic-bezier(0.34, 1.56, 0.64, 1); } diff --git a/PanTS-Demo/src/components/viewer/FlyoutPrimitives.tsx b/PanTS-Demo/src/components/viewer/FlyoutPrimitives.tsx index 9c9d9ee9..000a358d 100644 --- a/PanTS-Demo/src/components/viewer/FlyoutPrimitives.tsx +++ b/PanTS-Demo/src/components/viewer/FlyoutPrimitives.tsx @@ -404,7 +404,7 @@ export function MenuRow({ <> {/* Leaf rows (no rowRef) show a check when selected; rows that * open a further grandchild show a chevron instead. */} - {!rowRef && open && } + {!rowRef && open && } {rowRef && } )} diff --git a/PanTS-Demo/src/routes/VisualizationPage.css b/PanTS-Demo/src/routes/VisualizationPage.css index 4c30fc59..c03aa889 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.css +++ b/PanTS-Demo/src/routes/VisualizationPage.css @@ -1441,7 +1441,8 @@ } .vp-stats__summary strong { - color: #ffb454; + color: #465bfc +; } .vp-stats__msg { @@ -1508,7 +1509,8 @@ /* Muted by default, highlighted when in the extreme tails (p95). */ .vp-stats__pct--flag { - color: #ffb454 !important; + color: #465bfc + !important; font-weight: 600; } @@ -1543,7 +1545,7 @@ } .vp-spark__marker--flag { - background: #ffb454; + background: #465bfc; } /* Explicit index-based stripe (not :nth-child) since expanded detail rows are interleaved @@ -1594,7 +1596,8 @@ .vp-stats__truncated-flag { margin-left: 5px; font-size: 10px; - color: #ffb454; + color: #465bfc +; cursor: help; } @@ -1795,4 +1798,8 @@ pointer-events: none; z-index: 35; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4); -} \ No newline at end of file +} + +/* Point/box-segment SUCCESS/ERROR now reuses the shared GuidedStepModal + (SliceAnchorPickerUI.tsx) instead of its own bottom pill — no CSS of + its own needed here anymore. */ \ No newline at end of file From 510818eeb092e167332f12ef8c9eb9c645dafe78 Mon Sep 17 00:00:00 2001 From: harleensachdev Date: Sat, 15 Aug 2026 21:59:00 +0800 Subject: [PATCH 2/8] fix: undo/redo picks the more recently touched edit stack undoMaskEdit/redoMaskEdit always preferred the smart-fill stack regardless of order, so an older fill-type undo could jump ahead of a newer brush stroke. Track _lastFillEditTime/_lastBrushEditTime and pick whichever stack was touched most recently. Also gives point/box segment its own sparse before/after undo entry on the same fill-history stack (touching only voxels that actually changed), and swaps the post-submit full representation rebuild for the same lightweight _notifySegmentationChanged() refresh brush/ smart-fill already use, avoiding an every-class-mask flash on every click/box prompt. Adds LIVEWIRE_OUTWARD_BIAS_VOXELS to nudge the live-wire/magnet snap point past the raw gradient-magnitude peak, correcting an inward boundary bias relative to the visually-perceived edge. --- PanTS-Demo/src/helpers/CornerstoneNifti2.tsx | 104 ++++++++++++++++++- 1 file changed, 100 insertions(+), 4 deletions(-) diff --git a/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx b/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx index 708a86ea..e5459588 100644 --- a/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx +++ b/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx @@ -840,7 +840,10 @@ export function setMaskBrushSize(diameterMm: number) { // always on top of ONE of the two, so this behaves as a single button. // --------------------------------------------------------------------------- export function undoMaskEdit() { - if (canUndoSmartFill()) { + // Prefer whichever stack was touched more recently, not always the fill + // stack — a brush stroke after a box-segment (or vice versa) must undo in + // the order it actually happened. + if (canUndoSmartFill() && _lastFillEditTime >= _lastBrushEditTime) { undoSmartFill(); return; } @@ -849,7 +852,7 @@ export function undoMaskEdit() { } export function redoMaskEdit() { - if (canRedoSmartFill()) { + if (canRedoSmartFill() && _lastFillEditTime >= _lastBrushEditTime) { redoSmartFill(); return; } @@ -1567,15 +1570,53 @@ export async function submitInteractiveSegmentPrompt( } let changed = 0; + // Sparse before/after capture for undo — only voxels this proposal + // actually touches AND actually changes (skips a no-op write where the + // voxel already held activeSegmentIndex), so undo/redo stay cheap even + // though `proposal.data` spans the whole volume. + const touchedIdx: number[] = []; + const priorValues: number[] = []; for (let idx = 0; idx < proposal.data.length; idx++) { if (proposal.data[idx]) { + if (segScalars[idx] !== activeSegmentIndex) { + touchedIdx.push(idx); + priorValues.push(segScalars[idx]); + } segScalars[idx] = activeSegmentIndex; changed++; } } if (changed > 0) { (segVolume as any)?.voxelManager?.setCompleteScalarDataArray?.(segScalars); - await _rebuildSegmentationRepresentations(); + // NOT _rebuildSegmentationRepresentations() — this only mutated voxels + // in the SAME already-cached segVolume object, it never swapped which + // volume is loaded (unlike upgradeSegmentationVolume, which genuinely + // does need the full remove+re-add). A full rebuild tears down and + // re-adds every segment's representation on every viewport, which is + // both the visible "every class mask flashes/reloads" symptom and + // real, avoidable cost on every single click/box prompt. This is the + // same lightweight refresh the brush/smart-fill/etc. direct-write paths + // already use — it doesn't touch representations or actors, so it also + // doesn't disturb camera position/zoom the way rebuilding did. + _notifySegmentationChanged(); + + // Own undo/redo entry, same shared stack as smart fill / scissors / + // lasso (pushEditHistory below) — a SEPARATE stack from brush strokes + // (Cornerstone's own HistoryMemo), so undoing a point/box segment never + // also reverts (or gets shadowed by) an unrelated brush stroke; see + // undoMaskEdit's recency check for how the two stacks interleave. + if (touchedIdx.length > 0) { + const applyAndRefresh = (values: number[]) => { + touchedIdx.forEach((idx, i) => { segScalars[idx] = values[i]; }); + (segVolume as any)?.voxelManager?.setCompleteScalarDataArray?.(segScalars); + _notifySegmentationChanged(); + }; + const redoValues = touchedIdx.map(() => activeSegmentIndex); + pushEditHistory({ + undo: () => applyAndRefresh(priorValues), + redo: () => applyAndRefresh(redoValues), + }); + } } return changed; @@ -2541,7 +2582,19 @@ export function runDualScribbleFill( _notifySegmentationChanged(); return { filledVoxels: touched.length, threshold }; } +// Guards the dispatch below so the module-level listener a few lines down +// (which stamps _lastBrushEditTime) can tell "this SEGMENTATION_DATA_MODIFIED +// came from OUR OWN edit path (fill/box/point/scissors/lasso/etc, all of +// which route through this function)" apart from "this came natively from +// Cornerstone's own BrushTool after a paint/erase stroke" — both dispatch +// the identical event, so without this flag the two are indistinguishable +// from the listener's side, which is exactly what made the old +// "smart-fill-stack always wins" undo ordering wrong (see _lastFillEditTime +// / _lastBrushEditTime below). +let _dispatchingOwnEdit = false; + function _notifySegmentationChanged() { + _dispatchingOwnEdit = true; try { // This is what BrushTool's own strategies call after painting — it invalidates the // labelmap's cached GPU texture so the 2D volume viewports actually repaint the new @@ -2556,10 +2609,22 @@ function _notifySegmentationChanged() { eventTarget.dispatchEvent( new CustomEvent(csToolsEnums.Events.SEGMENTATION_DATA_MODIFIED, { detail: { segmentationId } }) ); + _dispatchingOwnEdit = false; currentRenderingEngine?.renderViewports([...MPR_VIEWPORT_IDS]); currentRenderingEngine?.render(); } +// Fires once, unconditionally, for the lifetime of the module — separate +// from subscribeToSegmentationEdits below (which callers attach/detach per +// component). Its only job is recency-tracking for undoMaskEdit/redoMaskEdit: +// stamp _lastBrushEditTime whenever a genuine NATIVE brush/eraser stroke +// changes the labelmap (i.e. the event fired WITHOUT _dispatchingOwnEdit set, +// meaning it didn't come from _notifySegmentationChanged / our own edit +// paths). See _pushFillHistory below for the matching _lastFillEditTime. +eventTarget.addEventListener(csToolsEnums.Events.SEGMENTATION_DATA_MODIFIED, () => { + if (!_dispatchingOwnEdit) _lastBrushEditTime = Date.now(); +}); + // ============================================================================ // SECTION: Undo / Redo History // ============================================================================ @@ -2569,10 +2634,21 @@ type FillHistoryEntry = { undo: () => void; redo: () => void }; let _fillHistory: FillHistoryEntry[] = []; let _fillHistoryIndex = -1; +// Recency trackers so undoMaskEdit/redoMaskEdit can pick whichever of the +// two history mechanisms (this _fillHistory stack, used by smart fill, +// scissors, lasso, and point/box segmentation; or Cornerstone's own +// HistoryMemo, used natively by brush/eraser strokes) the user actually +// touched most recently — instead of always preferring one stack +// regardless of order, which let an older fill-type undo silently jump +// ahead of a newer brush stroke when the two were interleaved. +let _lastFillEditTime = 0; +let _lastBrushEditTime = 0; + function _pushFillHistory(entry: FillHistoryEntry) { _fillHistory = _fillHistory.slice(0, _fillHistoryIndex + 1); _fillHistory.push(entry); _fillHistoryIndex = _fillHistory.length - 1; + _lastFillEditTime = Date.now(); } // Exposed so hooks/components outside this module (e.g. useSmartFill's @@ -2689,6 +2765,26 @@ const LIVEWIRE_W_GRADIENT = 0.55; const LIVEWIRE_W_LAPLACIAN = 0.25; const LIVEWIRE_W_DIRECTION = 0.20; +// The refined snap point (see refinePoint below) sits at the objective +// gradient-magnitude peak of the CT intensity ramp — the mathematically +// "sharpest" point of the transition. That can read as a few mm inside the +// true anatomical boundary compared to where a human eye places the edge +// under typical windowing, since the visually-perceived edge and the raw +// gradient peak aren't always the same point. This nudges the final refined +// point a small extra distance further along the SAME outward normal +// refinePoint already found, landing it a bit past the gradient peak instead +// of exactly on it. +// +// Sign convention: positive values move along +[ux,uy], i.e. from lower-HU +// toward higher-HU across the edge — for a denser structure (bone, most solid +// organs) against a less-dense surround (fat/air), that's outward, away from +// the structure's interior. If a specific case needs the opposite (e.g. +// tracing something LESS dense than its surround), flip the sign here — this +// is a single scalar, not per-edge logic, so it can't be made +// direction-aware automatically without knowing which side is "inside" for +// an arbitrary class. +const LIVEWIRE_OUTWARD_BIAS_VOXELS = 0.9; + // Minimal binary min-heap keyed by a numeric priority — enough for Dijkstra // over a few thousand nodes without pulling in a dependency. class _MinHeap { @@ -2962,7 +3058,7 @@ export function computeLiveWirePath( 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]; + return [a + ux * (tRefined + LIVEWIRE_OUTWARD_BIAS_VOXELS), b + uy * (tRefined + LIVEWIRE_OUTWARD_BIAS_VOXELS)]; }; const points: Array<[number, number]> = []; From 0005fc8e4bb6955572f206c696731eb2eec64f07 Mon Sep 17 00:00:00 2001 From: harleensachdev Date: Sat, 15 Aug 2026 21:59:05 +0800 Subject: [PATCH 3/8] fix: scissors/lasso Ctrl+Z no longer also undoes the last committed edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usePolygonDraw's own Ctrl+Z listener (pop last placed point) and the global mask-edit undo listener are both on window, so stopPropagation alone didn't stop the sibling listener — one press was undoing a pending point AND a previous brush stroke. Use stopImmediatePropagation() instead. useKeyboardShortcuts now takes an onUndo callback (owned by VisualizationPage) instead of calling undoMaskEdit() directly, so a pending polygon point can be peeled off first before falling through to the committed-edit undo stack. --- .../src/helpers/viewer/useKeyboardShortcuts.ts | 18 +++++++++++++++--- .../src/helpers/viewer/usePolygonDraw.ts | 15 ++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/PanTS-Demo/src/helpers/viewer/useKeyboardShortcuts.ts b/PanTS-Demo/src/helpers/viewer/useKeyboardShortcuts.ts index 19984734..d4df9644 100644 --- a/PanTS-Demo/src/helpers/viewer/useKeyboardShortcuts.ts +++ b/PanTS-Demo/src/helpers/viewer/useKeyboardShortcuts.ts @@ -13,7 +13,6 @@ import { ROI_TOOL, setMaskBrushSize, setPaneSliceIndex, - undoMaskEdit, zoomToCursor, zoomToFit, type CinePane, @@ -61,6 +60,13 @@ interface UseKeyboardShortcutsArgs { sliceInfoRef: MutableRefObject>; editMode: MaskEditMode; setZoomLevel: Dispatch>; + /** Called for the plain undo shortcut (⌘Z/Ctrl+Z, no Shift). Owned by + * VisualizationPage so it can peel off a scissors/lasso in-progress + * point before falling through to the global mask-edit undo — see + * handleUndo there for why that ordering matters. Redo has no + * equivalent per-tool concept, so Shift+⌘Z still calls redoMaskEdit() + * directly below. */ + onUndo: () => void; } /** @@ -97,6 +103,7 @@ export function useKeyboardShortcuts({ sliceInfoRef, editMode, setZoomLevel, + onUndo, }: UseKeyboardShortcutsArgs) { // Last-seen mouse position (viewport-relative clientX/Y), updated on every // mousemove so +/- can zoom toward "wherever the cursor last was" even @@ -189,9 +196,13 @@ export function useKeyboardShortcuts({ const key = e.key.toLowerCase(); // ---- Undo / redo --------------------------------------------------- + // Plain undo goes through onUndo (not undoMaskEdit directly) so a + // pending scissors/lasso point gets peeled off first — see + // handleUndo in VisualizationPage for why. Redo has no per-tool + // equivalent, so it still calls redoMaskEdit() straight through. if ((e.metaKey || e.ctrlKey) && !e.altKey && key === "z") { if (e.shiftKey) redoMaskEdit(); - else undoMaskEdit(); + else onUndo(); e.preventDefault(); return; } @@ -297,5 +308,6 @@ export function useKeyboardShortcuts({ sliceInfoRef, editMode, setZoomLevel, + onUndo, ]); -} +} \ No newline at end of file diff --git a/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts b/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts index 4dd55c76..42ba9597 100644 --- a/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts +++ b/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts @@ -186,9 +186,22 @@ export function usePolygonDraw({ enabled, onClose, computeLivePath }: UsePolygon 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. + // actually reach for. stopImmediatePropagation() (not just + // preventDefault) is the important part: this listener is on + // `window`, same target as whatever global shortcut undoes the + // last committed brush/voxel edit — plain stopPropagation() does + // NOT stop a sibling listener registered on that same target, only + // propagation to OTHER elements, so without stopImmediatePropagation + // a single Ctrl+Z while mid-polygon fired BOTH: this popped the + // last placed point AND the global handler undid the last brush + // stroke, as if two Ctrl+Z presses had happened. Placing a + // scissors point isn't a committed edit yet (nothing touches the + // labelmap until the shape closes), so it needs to be its own, + // separate undo step — not bundled into the same history entry as + // an actual brush stroke. else if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "z" && cornersWorld.length) { e.preventDefault(); + e.stopImmediatePropagation(); undo(); } }; From 9a9f9676472f1cefa3ca819cd4b19e5f305c5edc Mon Sep 17 00:00:00 2001 From: harleensachdev Date: Sat, 15 Aug 2026 21:59:17 +0800 Subject: [PATCH 4/8] feat: point/box segment gets busy/success/error feedback + correct mouse handling - Introduces handleUndo() as the single entry point for both the Undo button and Ctrl+Z: peels off a pending scissors/lasso point before falling through to undoMaskEdit(). - useInteractivePromptTool now tracks status (idle/applying/success/ error) and calls onComplete() only on a successful, voxel-changing submit, so point/box segment (single-shot, unlike paint/erase) deselects itself after one use instead of staying armed. - Point/box segment now calls releasePrimaryMouseTools() (not just toggling the crosshair off), since box-segment's mousedown->drag-> mouseup was still bound to Cornerstone's default primary-drag tool (pan/window-level), so dragging a box also panned/adjusted the CT view underneath it. - 3D pane defaults to mesh mode for non-local (dataset) cases instead of always volume. - Removes the enhance.state === 'streaming' gate blocking 3D volume viewport setup, and drops the unused sessionDisplayLabel/ loadRecentUploads lookup in favor of showing the raw caseId. - Smart-fill preview point color updated from orange to JHU blue to match the accent color change. --- .../viewer/useInteractivePromptTool.ts | 51 +++++- PanTS-Demo/src/routes/VisualizationPage.tsx | 145 ++++++++++++------ 2 files changed, 141 insertions(+), 55 deletions(-) diff --git a/PanTS-Demo/src/helpers/viewer/useInteractivePromptTool.ts b/PanTS-Demo/src/helpers/viewer/useInteractivePromptTool.ts index 0540c4f0..ea9efb29 100644 --- a/PanTS-Demo/src/helpers/viewer/useInteractivePromptTool.ts +++ b/PanTS-Demo/src/helpers/viewer/useInteractivePromptTool.ts @@ -37,16 +37,31 @@ interface UseInteractivePromptToolArgs { * disable further clicks — a click mid-request would race the previous * one's voxel writes. */ onBusyChange?: (busy: boolean) => void; + /** Fired once a submit SUCCEEDS (voxels actually changed) — point/box + * segment is single-shot, not equip-and-use like paint/erase, so the + * caller should deselect the tool here (activeToolbarTool -> null) so + * its icon loses the active/white-background state after one use. + * NOT fired on "nothing changed" or on error — the user should be able + * to immediately retry in place without re-arming the tool. */ + onComplete?: () => void; } export function useInteractivePromptTool({ - enabled, mode, apiBase, caseId, activeSegmentIndex, res, tolerance, onLog, onBusyChange, + enabled, mode, apiBase, caseId, activeSegmentIndex, res, tolerance, onLog, onBusyChange, onComplete, }: UseInteractivePromptToolArgs) { const [dragStartCanvas, setDragStartCanvas] = useState<[number, number] | null>(null); const [dragStartWorld, setDragStartWorld] = useState(null); const [liveBoxCanvas, setLiveBoxCanvas] = useState<[[number, number], [number, number]] | null>(null); const paneRef = useRef(null); const busyRef = useRef(false); + // Drives the applying/success overlay (mirrors CopyAcrossSlicesFlyout's + // GuidedStepModal pattern) instead of the tool silently completing with + // only a session-log line — a click/box submit is a real server round + // trip (hundreds of ms to a few seconds), so it needs its own feedback, + // not just whatever "Interactive segment (N vox)" text happens to scroll + // past in the log panel. + const [status, setStatus] = useState<"idle" | "applying" | "success" | "error">("idle"); + const [statusMessage, setStatusMessage] = useState(null); const reset = useCallback(() => { setDragStartCanvas(null); @@ -66,6 +81,8 @@ export function useInteractivePromptTool({ } busyRef.current = true; onBusyChange?.(true); + setStatus("applying"); + setStatusMessage(null); try { const changed = await submitInteractiveSegmentPrompt( apiBase, @@ -74,18 +91,33 @@ export function useInteractivePromptTool({ { pointLps: pointWorld, boxLps: boxWorld, tolerance }, res, ); - onLog?.( - changed - ? `Interactive segment (${changed.toLocaleString()} vox)` - : "Interactive segment: nothing grew from that point — try a different spot." - ); + if (changed) { + const msg = `Interactive segment (${changed.toLocaleString()} vox)`; + onLog?.(msg); + setStatus("success"); + setStatusMessage("Operation completed successfully"); + onComplete?.(); + } else { + const msg = "Interactive segment: nothing grew from that point — try a different spot."; + onLog?.(msg); + setStatus("error"); + setStatusMessage(msg); + } } catch (e) { - onLog?.(e instanceof Error ? e.message : "Interactive segmentation failed."); + const msg = e instanceof Error ? e.message : "Interactive segmentation failed."; + onLog?.(msg); + setStatus("error"); + setStatusMessage(msg); } finally { busyRef.current = false; onBusyChange?.(false); } - }, [apiBase, caseId, activeSegmentIndex, res, tolerance, onLog, onBusyChange]); + }, [apiBase, caseId, activeSegmentIndex, res, tolerance, onLog, onBusyChange, onComplete]); + + const dismissStatus = useCallback(() => { + setStatus("idle"); + setStatusMessage(null); + }, []); const handleClick = (pane: CinePane) => (e: MouseEvent) => { if (!enabled || mode !== "point") return; @@ -147,6 +179,9 @@ export function useInteractivePromptTool({ return { pane, liveBox: liveBoxDisplay, + status, + statusMessage, + dismissStatus, handleClick, handleMouseDown, handleMouseMove, diff --git a/PanTS-Demo/src/routes/VisualizationPage.tsx b/PanTS-Demo/src/routes/VisualizationPage.tsx index c51a63b9..1ee3bc5d 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.tsx +++ b/PanTS-Demo/src/routes/VisualizationPage.tsx @@ -56,11 +56,12 @@ import MarginPanel from "../components/segmentation/MarginPanel"; import IslandsPanel from "../components/segmentation/IslandsPanel"; import LogicalOperatorsPanel from "../components/segmentation/LogicalOperatorsPanel"; import { setBrushMaskingScope } from "../helpers/CornerstoneNifti2"; -import { loadRecentUploads } from "../helpers/recentUploads"; + import SmoothingFlyout from "../components/segmentation/SmoothingFlyout"; import GrowFromSeedsFlyout from "../components/segmentation/GrowFromSeedFlyout"; import FillBetweenSlicesFlyout from "../components/segmentation/FillBetweenSlicesFlyout"; import CopyAcrossSlicesFlyout from "../components/segmentation/CopyAcrossSlicesFlyout"; +import { GuidedStepModal } from "../components/segmentation/SliceAnchorPickerUI"; import HollowFlyout from "../components/segmentation/HollowFlyout"; import LevelTracingFlyout from "../components/segmentation/LevelTracingFlyout"; import { useScissorsTool } from "../helpers/viewer/useScissorsTool"; @@ -510,17 +511,6 @@ function VisualizationPage() { const isLocal = isDicom || isLocalNifti; const [dicomError, setDicomError] = useState(null); - // Header display only — caseId itself stays the raw id everywhere it's used - // functionally (API calls, logging, etc). A session's raw id is a UUID, which - // means nothing to a person; look up the friendly name the Upload page already - // gives it ("LesionSegmenter · Aug 14") when this browser has it in - // localStorage, falling back to a short id fragment rather than the full UUID. - const sessionDisplayLabel = useMemo(() => { - if (!sessionId) return null; - const match = loadRecentUploads().find((u) => u.sessionId === sessionId); - return match?.label ?? `Session ${sessionId.slice(0, 8)}`; - }, [sessionId]); - // Where to load the volumes from. Per the maintainer's rule, dataset cases load // from the lab's LOCAL endpoints (served off disk on the JHU server — much faster @@ -1279,6 +1269,31 @@ function VisualizationPage() { // The active drawing tool for the pane handlers below — whichever one is // actually armed right now (they're mutually exclusive via `enabled`). const activeDrawTool = activeToolbarTool === "scissors" ? scissors : lasso; + + // Single entry point for both the toolbar's Undo button and the ⌘Z/Ctrl+Z + // shortcut. Scissors/lasso place polygon points one click at a time + // (usePolygonDraw's local `points` state) BEFORE anything is committed + // to the shared mask-edit undo stack — that commit only happens once the + // shape is closed. Previously Undo always called the global + // `undoMaskEdit()` directly, with no awareness of an in-progress draw: + // pressing it while a point was down undid the last COMMITTED mask edit + // (e.g. a brush stroke) while doing nothing to the pending point, but + // since the point then got silently cleared by the mask refresh that + // undo triggers, one press looked like it undid two things at once — + // the pending point AND the previous brush stroke. Now: if there's a + // pending, uncommitted point, undo removes just that one point first + // (activeDrawTool.undo(), from usePolygonDraw); only once there are no + // pending points left does it fall through to the normal undoMaskEdit() + // for the last committed edit. Redo has no equivalent concept for an + // in-progress draw, so it's untouched. + const handleUndo = useCallback(() => { + if (activeDrawTool.anchorsCanvas.length > 0) { + activeDrawTool.undo(); + return; + } + undoMaskEdit(); + }, [activeDrawTool]); + // Progressive resolution: after the fast low-res load, the full-res CT streams in // the background and hot-swaps in place (no reload). idle → streaming → done/failed. const [enhance, setEnhance] = useState<{ state: "idle" | "streaming" | "done" | "failed"; pct: number | null }>({ state: "idle", pct: null }); @@ -1297,6 +1312,11 @@ function VisualizationPage() { res: isHd || enhance.state === "done" ? "full" : "low", onLog: (detail) => sessionRef.current?.log("edit", detail, 2000), onBusyChange: setPromptToolBusy, + // Single-shot tool, not equip-and-use like paint/erase — deselect + // (icon loses its active/white-background state) once a click + // actually produced a mask, instead of staying armed for repeated + // clicks the way the brush does. + onComplete: () => setActiveToolbarTool(null), }); const boxSegment = useInteractivePromptTool({ enabled: activeToolbarTool === "boxSegment" && !promptToolBusy, @@ -1307,6 +1327,7 @@ function VisualizationPage() { res: isHd || enhance.state === "done" ? "full" : "low", onLog: (detail) => sessionRef.current?.log("edit", detail, 2000), onBusyChange: setPromptToolBusy, + onComplete: () => setActiveToolbarTool(null), }); const enhanceStartedRef = useRef(false); @@ -1351,7 +1372,7 @@ function VisualizationPage() { }, [showOnlyTargetMask, isolationTargetKey]); // 3D pane rendering mode: organ meshes (dataset cases) or shaded GPU volume // rendering of the CT itself (the only 3D option for local DICOM). - const [threeDMode, setThreeDMode] = useState<"mesh" | "volume">("volume"); + const [threeDMode, setThreeDMode] = useState<"mesh" | "volume">(isLocal ? "volume" : "mesh"); const [volumePreset, setVolumePreset] = useState(VOLUME_3D_PRESETS[0].name); // CT presets by default; swapped for the MR set when a local DICOM turns out to be MR. const [volume3DPresets, setVolume3DPresets] = useState(VOLUME_3D_PRESETS); @@ -1419,25 +1440,36 @@ function VisualizationPage() { if (editMode === "brush" || editMode === "eraser") { setActiveMeasurementTool(null); setActiveMaskEditTool(editMode === "brush" ? EDIT_BRUSH : EDIT_ERASER); - } else if (editMode === "smartfill") { - // Smart fill owns the mouse itself (scribbling) — no Cornerstone tool needed. + } else if (editMode === "smartfill" || activeToolbarTool === "pointSegment" || activeToolbarTool === "boxSegment") { + // Smart fill (scribbling) and point/box segment (click / click-drag + // prompts) all own the mouse themselves — no Cornerstone tool + // needed. This must be a full releasePrimaryMouseTools(), not just + // crosshair-off: box-segment specifically is a mousedown→drag→ + // mouseup gesture, and with the crosshair merely toggled off but + // no tool explicitly released, Cornerstone still had SOME tool + // bound to primary-button-drag (pan/window-level, whatever the + // viewport's underlying default is) — so dragging out a box + // simultaneously panned/adjusted the underlying CT view instead of + // just drawing the box overlay. Point segment is a plain click + // with no drag, so the same bug wasn't visible there, but it's + // released here too for consistency (and so a slight click-drift + // during the click can't be misread as a pan/W-L nudge either). 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. + // Scissors/lasso (editMode "lasso") and level tracing place their + // points via plain clicks on the pane — 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 underneath 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); @@ -1676,6 +1708,7 @@ function VisualizationPage() { sliceInfoRef, editMode, setZoomLevel, + onUndo: handleUndo, }); // Live-adjust the frame rate: if a clip is already running, restart it immediately at // the new speed rather than waiting for the next stop/start. @@ -1780,13 +1813,6 @@ function VisualizationPage() { useEffect(() => { if (loading || threeDMode !== "volume" || !renderingEngine) return; - // Don't contend with the HD CT stream swap-in (runEnhance, below) for - // GPU/main-thread time — building the shaded 3D volume texture while - // upgradeCtVolume is mid-stream is two heavy GPU uploads at once, which - // was freezing the page for several seconds. Wait for the stream to - // settle; for local files it never starts (state stays "idle"), so this - // never blocks them. - if (enhance.state === "streaming") return; const element = volume3DRef.current; if (!element) return; let disposed = false; @@ -1802,7 +1828,7 @@ function VisualizationPage() { // volumePreset intentionally omitted — preset changes are applied in place below, // without tearing the viewport down. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [threeDMode, loading, renderingEngine, enhance.state]); + }, [threeDMode, loading, renderingEngine]); useEffect(() => { if (threeDMode === "volume") applyVolume3DPreset(volumePreset); @@ -2827,9 +2853,7 @@ const aiAvailableOrgans = useMemo(() => { {/* Case / session identity */}
{sessionId ? "Session" : "Case"} - - {sessionId ? sessionDisplayLabel : caseId} - + {caseId}
@@ -3039,7 +3063,7 @@ const aiAvailableOrgans = useMemo(() => { dropdowns (same portal-flyout pattern as Measure/Cine originally used) so the bar reads as ~9 clusters instead of ~20 individual icons. */} @@ -3246,7 +3270,7 @@ const aiAvailableOrgans = useMemo(() => {
+
, document.body ); @@ -268,6 +300,16 @@ function FormFlyout({ anchorEl, onClose, children }: FormFlyoutProps) { const onDown = (e: MouseEvent) => { if (panelRef.current?.contains(e.target as Node)) return; if (anchorEl?.contains(e.target as Node)) return; + // The color swatch popover portals to document.body on its OWN, + // separate from this FormFlyout's portal — so a click on a preset + // swatch or the native color input isn't a descendant of either + // panelRef or anchorEl, and without this check got misread as + // "clicked outside the name/color editor," closing the whole + // editor instead of just the small color popover. The color + // popover already closes itself independently on its own outside + // click; this just stops THIS flyout from also reacting to a + // click that was actually still inside it, conceptually. + if ((e.target as Element | null)?.closest?.("[data-color-popover-portal]")) return; requestClose(); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") requestClose(); }; From cb64dd9dd0104ada1cccf7d471bc7f94c9b6abc6 Mon Sep 17 00:00:00 2001 From: harleensachdev Date: Sat, 15 Aug 2026 22:04:49 +0800 Subject: [PATCH 7/8] color changes --- .../components/segmentation/SegmentsPopup.css | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/PanTS-Demo/src/components/segmentation/SegmentsPopup.css b/PanTS-Demo/src/components/segmentation/SegmentsPopup.css index bf3c2f61..fc6a999b 100644 --- a/PanTS-Demo/src/components/segmentation/SegmentsPopup.css +++ b/PanTS-Demo/src/components/segmentation/SegmentsPopup.css @@ -278,7 +278,7 @@ } .segpop__color-popover-swatch.is-active { - outline: 2px solid #15007d; + outline: 2px solid #7cb7da; outline-offset: 1px; } @@ -341,13 +341,13 @@ padding: 5px 12px; font-size: 11px; font-weight: 600; - background: #15007d; + background: #7cb7da; color: #16181d; cursor: pointer; } .segpop__color-popover-done:hover { - background: #15007d; + background: #7cb7da; } /* Static color chip shown in the normal (non-editing) row — color is only @@ -374,7 +374,7 @@ width: 18px; height: 18px; border-radius: 50%; - color: #270da8; + color: #7cb7da; background: rgba(104, 172, 229, 0.16); } @@ -387,7 +387,7 @@ .segpop__target-badge--btn:hover { background: rgb(17, 0, 75); - color: #15007d; + color: #7cb7da; transform: scale(1.08); } @@ -427,7 +427,7 @@ .segpop__edit-btn:hover, .segpop__edit-btn.is-active { - color: #15007d; + color: #7cb7da; } /* Floating add/edit form (see FormFlyout in SegmentsPopup.tsx). Portaled to @@ -562,7 +562,7 @@ } .segpop__delete:hover:not(:disabled) { - color: #000072; + color: #7cb7da; } .segpop__err { @@ -700,7 +700,7 @@ width: 6px; height: 6px; border-radius: 50%; - background: #0275f9; + background: #7cb7da; } .segpop__tab.is-active .segpop__tab-dot { From 78e8dd574963ca4ba1309a4232cb39f40e9992bb Mon Sep 17 00:00:00 2001 From: harleensachdev Date: Sat, 15 Aug 2026 22:22:32 +0800 Subject: [PATCH 8/8] fix: remove blanket topbar click handler auto-closing annotation toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vp-topbar had a bubbling onClick that closed showAnnotationToolbar on any click inside the topbar (blank space, dividers, every button) except two explicitly excluded refs. This silently overrode all per-button close logic and also fired on clicks in dead space between button groups, not just the intended 'other tool' clicks. Removed the handler. Closing the annotation toolbar/SegmentsPopup now only happens via the explicit closeAnnotationToolbarIfOpen() calls on Download and HD, plus the Annotate button's own toggle — Crosshair, Measure, View, Cine/Play, Undo, Redo, Layout (MPR), Window (soft tissue), and Adjust are left untouched. --- PanTS-Demo/src/routes/VisualizationPage.tsx | 37 ++++++++++++--------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/PanTS-Demo/src/routes/VisualizationPage.tsx b/PanTS-Demo/src/routes/VisualizationPage.tsx index 1ee3bc5d..eb7a724e 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.tsx +++ b/PanTS-Demo/src/routes/VisualizationPage.tsx @@ -2556,6 +2556,25 @@ function VisualizationPage() { } }; + // Every OTHER main-toolbar icon (crosshair, measure, view, cine, layout, + // window preset, adjust, download, HD) needs to close the annotation + // toolbar/SegmentsPopup the same way pressing Annotate again does — + // previously only the Annotate button itself (and a couple of the + // right-side panel togglers like Stats/Metadata/Measurements) ran this + // teardown, so clicking e.g. Crosshair or Measure while annotating left + // the horizontal toolbar and the class popup visibly open even though + // navigation/measurement mode had taken over underneath them. Mirrors + // the closing branch of handleToggleAnnotationToolbar exactly, just + // gated on "was it open" instead of always toggling. + const closeAnnotationToolbarIfOpen = () => { + if (!showAnnotationToolbar) return; + setShowAnnotationToolbar(false); + setActiveCatalogOrganId(null); + setActiveSegmentState(null); + setEditMode(null); + setActiveToolbarTool(null); + }; + const handleToggleStats = () => { // The right-side slot is shared by stats / metadata / measurements / mask editing. setShowMetadata(false); @@ -2814,21 +2833,6 @@ const aiAvailableOrgans = useMemo(() => {
{ - // Clicking any OTHER control in the main toolbar while the - // annotation ribbon is open closes it — same as clicking the - // pencil again. The pencil button is excluded here since its - // own onClick already toggles the state; without the - // exclusion this bubbling handler would immediately flip it - // back off right after turning it on. - if ( - showAnnotationToolbar && - !annotatePencilRef.current?.contains(e.target as Node) && - !undoRedoGroupRef.current?.contains(e.target as Node) - ) { - setShowAnnotationToolbar(false); - } - }} > {/* Gear (hides the bar) + home, in-flow so there's no dead corner space */}