diff --git a/PanTS-Demo/src/components/ApplyButton.tsx b/PanTS-Demo/src/components/ApplyButton.tsx new file mode 100644 index 0000000..7f5f1b1 --- /dev/null +++ b/PanTS-Demo/src/components/ApplyButton.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; + +interface ApplyButtonProps { + onApply: () => void; + disabled?: boolean; + label?: string; + applyingLabel?: string; + className?: string; +} + +/** + * Standard "Apply" button for segment-effect panels (Margin, Islands, Smoothing, + * Logical operators, Fill between slices, Grow from seeds...). + * + * These operations run synchronously on the main thread and, depending on scope/ + * volume size, can take a visible moment — without any feedback that looks exactly + * like the click did nothing (or that the app froze). This wraps the click handler + * so the "Applying…" spinner state gets a chance to actually paint (via a double + * requestAnimationFrame) BEFORE the heavy synchronous work runs and blocks the + * thread, then clears once it returns. + */ +export default function ApplyButton({ + onApply, + disabled, + label = "Apply", + applyingLabel = "Applying…", + className = "", +}: ApplyButtonProps) { + const [applying, setApplying] = useState(false); + + const handleClick = () => { + if (disabled || applying) return; + setApplying(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + try { + onApply(); + } finally { + setApplying(false); + } + }); + }); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/MaskEditPanel/MaskEditPanel.css b/PanTS-Demo/src/components/MaskEditPanel/MaskEditPanel.css deleted file mode 100644 index d855b21..0000000 --- a/PanTS-Demo/src/components/MaskEditPanel/MaskEditPanel.css +++ /dev/null @@ -1,260 +0,0 @@ -/* Mask-editing panel — shares the docked right-side slot with the stats and - measurements panels (the page keeps them mutually exclusive). Sits in flow - beside the stage, not as an overlay. */ -.vp-edit { - flex: 0 0 300px; - width: 300px; - min-height: 0; - display: flex; - flex-direction: column; - background: rgba(14, 15, 18, 0.92); - backdrop-filter: blur(18px) saturate(140%); - -webkit-backdrop-filter: blur(18px) saturate(140%); - border-left: 1px solid var(--vp-border, rgba(255, 255, 255, 0.09)); - font-family: var(--vp-font, system-ui, sans-serif); - overflow: hidden; -} -.vp-edit__head { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; - border-bottom: 1px solid var(--vp-border, rgba(255, 255, 255, 0.09)); -} -.vp-edit__close { - background: transparent; - border: none; - color: var(--vp-text-dim, rgba(255, 255, 255, 0.5)); - font-size: 20px; - line-height: 1; - cursor: pointer; - padding: 0 4px; -} -.vp-edit__close:hover { color: var(--vp-text, rgba(255, 255, 255, 0.92)); } - -.vp-edit__body { - display: flex; - flex-direction: column; - gap: 14px; - padding: 14px 16px 16px; -} -.vp-edit__field { - display: flex; - flex-direction: column; - gap: 6px; - text-align: left; -} -.vp-edit__label { - font-family: var(--vp-mono, monospace); - font-size: 10px; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--vp-text-faint, rgba(255, 255, 255, 0.36)); - display: flex; - justify-content: space-between; - align-items: baseline; -} -.vp-edit__val { - color: var(--vp-text-dim, rgba(255, 255, 255, 0.5)); - letter-spacing: 0; - text-transform: none; - font-size: 11px; -} -.vp-edit__select { - width: 100%; - background: var(--vp-panel-strong, rgba(255, 255, 255, 0.07)); - border: 1px solid var(--vp-border, rgba(255, 255, 255, 0.09)); - border-radius: 8px; - color: var(--vp-text, rgba(255, 255, 255, 0.92)); - font-family: var(--vp-font, system-ui, sans-serif); - font-size: 13px; - padding: 7px 8px; - outline: none; -} -.vp-edit__select:focus { border-color: var(--vp-accent, #6ea8fe); } -.vp-edit__select option { background: #16181d; } - -.vp-edit__modes { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; -} -.vp-edit__mode { - display: flex; - align-items: center; - justify-content: center; - gap: 7px; - padding: 8px 10px; - border-radius: 9px; - border: 1px solid var(--vp-border, rgba(255, 255, 255, 0.09)); - background: var(--vp-panel-strong, rgba(255, 255, 255, 0.07)); - color: var(--vp-text-dim, rgba(255, 255, 255, 0.5)); - font-family: var(--vp-font, system-ui, sans-serif); - font-size: 13px; - font-weight: 600; - cursor: pointer; -} -.vp-edit__mode:hover { color: var(--vp-text, rgba(255, 255, 255, 0.92)); } -.vp-edit__mode.is-active { - background: #ffffff; - border-color: #ffffff; - color: #08090b; -} - -.vp-edit__history { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; -} -.vp-edit__btn { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 7px 10px; - border-radius: 8px; - border: 1px solid var(--vp-border, rgba(255, 255, 255, 0.09)); - background: transparent; - color: var(--vp-text-dim, rgba(255, 255, 255, 0.5)); - font-family: var(--vp-font, system-ui, sans-serif); - font-size: 12.5px; - cursor: pointer; -} -.vp-edit__btn:hover:not(:disabled) { color: var(--vp-text, rgba(255, 255, 255, 0.92)); } -.vp-edit__btn:disabled { opacity: 0.35; cursor: default; } - -.vp-edit__download { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 9px 12px; - border-radius: 9px; - border: 1px solid rgba(110, 168, 254, 0.5); - background: rgba(110, 168, 254, 0.14); - color: #a8c7ff; - font-family: var(--vp-font, system-ui, sans-serif); - font-size: 13px; - font-weight: 600; - cursor: pointer; -} -.vp-edit__download:hover:not(:disabled) { background: rgba(110, 168, 254, 0.26); } -.vp-edit__download:disabled { opacity: 0.4; cursor: default; } - -.vp-edit__save { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 9px 12px; - border-radius: 9px; - border: 1px solid rgba(74, 222, 128, 0.45); - background: rgba(74, 222, 128, 0.12); - color: #a7f3c8; - font-family: var(--vp-font, system-ui, sans-serif); - font-size: 13px; - font-weight: 600; - cursor: pointer; -} -.vp-edit__save:hover:not(:disabled) { background: rgba(74, 222, 128, 0.22); } -.vp-edit__save:disabled { opacity: 0.4; cursor: default; } -.vp-edit__save.is-error { - border-color: rgba(244, 63, 94, 0.5); - background: rgba(244, 63, 94, 0.12); - color: #fda4af; -} - -.vp-edit__hint { - font-size: 11.5px; - line-height: 1.45; - color: var(--vp-text-faint, rgba(255, 255, 255, 0.36)); - text-align: left; -} - -.vp-edit__new-class { - display: flex; - flex-direction: column; - gap: 10px; -} -.vp-edit__new-toggle { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 7px 10px; - border-radius: 8px; - border: 1px dashed var(--vp-border, rgba(255, 255, 255, 0.09)); - background: transparent; - color: var(--vp-text-dim, rgba(255, 255, 255, 0.5)); - font-family: var(--vp-font, system-ui, sans-serif); - font-size: 12.5px; - font-weight: 600; - cursor: pointer; -} -.vp-edit__new-toggle:hover, -.vp-edit__new-toggle.is-open { - color: var(--vp-text, rgba(255, 255, 255, 0.92)); - border-color: rgba(110, 168, 254, 0.45); -} -.vp-edit__new-form { - display: flex; - flex-direction: column; - gap: 10px; - padding: 10px; - border-radius: 9px; - border: 1px solid var(--vp-border, rgba(255, 255, 255, 0.09)); - background: rgba(255, 255, 255, 0.03); -} -.vp-edit__input { - width: 100%; - background: var(--vp-panel-strong, rgba(255, 255, 255, 0.07)); - border: 1px solid var(--vp-border, rgba(255, 255, 255, 0.09)); - border-radius: 8px; - color: var(--vp-text, rgba(255, 255, 255, 0.92)); - font-family: var(--vp-font, system-ui, sans-serif); - font-size: 13px; - padding: 7px 8px; - outline: none; -} -.vp-edit__input:focus { border-color: var(--vp-accent, #6ea8fe); } -.vp-edit__color-row { - display: flex; - align-items: center; - gap: 10px; -} -.vp-edit__color { - width: 44px; - height: 32px; - padding: 2px; - border: 1px solid var(--vp-border, rgba(255, 255, 255, 0.09)); - border-radius: 8px; - background: var(--vp-panel-strong, rgba(255, 255, 255, 0.07)); - cursor: pointer; -} -.vp-edit__color-swatch { - flex: 1; - height: 32px; - border-radius: 8px; - border: 1px solid var(--vp-border, rgba(255, 255, 255, 0.09)); -} -.vp-edit__create { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 8px 10px; - border-radius: 8px; - border: 1px solid rgba(110, 168, 254, 0.5); - background: rgba(110, 168, 254, 0.14); - color: #a8c7ff; - font-family: var(--vp-font, system-ui, sans-serif); - font-size: 12.5px; - font-weight: 600; - cursor: pointer; -} -.vp-edit__create:hover { background: rgba(110, 168, 254, 0.26); } -.vp-edit__error { - font-size: 11.5px; - color: #fda4af; - line-height: 1.4; -} diff --git a/PanTS-Demo/src/components/MaskEditPanel/MaskEditPanel.tsx b/PanTS-Demo/src/components/MaskEditPanel/MaskEditPanel.tsx deleted file mode 100644 index 3a07d74..0000000 --- a/PanTS-Demo/src/components/MaskEditPanel/MaskEditPanel.tsx +++ /dev/null @@ -1,722 +0,0 @@ -import { useEffect, useState } from "react"; -import JSZip from "jszip"; -import { - IconArrowBackUp, - IconArrowForwardUp, - IconBrush, - IconCloudUpload, - IconDownload, - IconEraser, - IconLasso, - IconPlus, - IconWand, -} from "@tabler/icons-react"; -import { - colorForNewClass, - copySegmentAcrossSlices, - dilateActiveSegment, - erodeActiveSegment, - getCustomSegmentLabelsForExport, - getMaskEditHistoryState, - getSegmentationExport, - getVolumeSliceIndexForPane, - interpolateSegmentBetweenSlices, - redoMaskEdit, - setActiveEditSegment, - setMaskBrushSize, - setPaneSliceIndex, - subscribeToSegmentationEdits, - undoMaskEdit, - type CinePane, -} from "../../helpers/CornerstoneNifti2"; -import { buildNiftiGzBlob } from "../../helpers/niftiWriter"; -import { downloadBlob } from "../../helpers/readingSession"; -import type { CheckBoxData } from "../../types"; -import "./MaskEditPanel.css"; - -export type MaskEditMode = "brush" | "eraser" | "smartfill" | "lasso" | null; - -// --------------------------------------------------------------------------- -// Props — grouped by the section of the panel they feed, in the same order -// the sections appear below. -// --------------------------------------------------------------------------- - -interface MaskEditPanelProps { - // Identity ------------------------------------------------------------ - organs: CheckBoxData[]; - caseId: string; - /** Dataset case id — shown on the (currently disabled) "save to server" button. */ - serverCaseId?: string; - - // Panel lifecycle ------------------------------------------------------- - mode: MaskEditMode; - onModeChange: (mode: MaskEditMode) => void; - onClose: () => void; - /** Reading-session hook — fires once per edit burst with a description. */ - onEdit?: (detail: string) => void; - /** Create a new label class (name + hex colour) and return it for the organ list. */ - onCreateClass?: (name: string, colorHex: string) => CheckBoxData | null; - - // Smart Fill (dual-scribble) -------------------------------------------- - smartFillMarkMode: "fg" | "bg"; - onSmartFillMarkModeChange: (m: "fg" | "bg") => void; - smartFillScope: "slice" | "volume"; - onSmartFillScopeChange: (s: "slice" | "volume") => void; - onApplySmartFill: () => void; - onClearSmartFillScribbles: () => void; - - // Lasso (plain polygon — no magnetic snapping) --------------------------- - lassoAnchorCount: number; - onLassoUndo: () => void; - onLassoClose: () => void; - onLassoCancel: () => void; - - // Morphology (erode / dilate only) --------------------------------------- - morphScope: "segment" | "island"; - onMorphScopeChange: (s: "segment" | "island") => void; - pickingMorphTarget: boolean; - onPickIsland: () => void; - morphSeedVoxel: [number, number, number] | null; - - // Slice tools (interpolation / copy) — need to know which pane is - // focused (as a sensible default) and how many slices it has. - focusedPane: CinePane; - totalSlices: number; -} - -const PANES: { value: CinePane; label: string }[] = [ - { value: "axial", label: "Axial" }, - { value: "sagittal", label: "Sagittal" }, - { value: "coronal", label: "Coronal" }, -]; - -function colorToHex([r, g, b]: readonly number[]): string { - const h = (n: number) => n.toString(16).padStart(2, "0"); - return `#${h(r)}${h(g)}${h(b)}`; -} - -/** - * Right-side panel for correcting segmentation masks. Everything happens - * client-side on the loaded labelmap volume; nothing here touches the server - * until "Download" is pressed ("Save to server" is temporarily disabled — - * see Section 7). - * - * Sections, top to bottom: - * 1. Target organ + new-class creation - * 2. Undo / redo (shared by every mode below) - * 3. Mode toolbar: Paint / Erase / Smart Fill / Lasso - * 4. Mode-specific controls - * 5. Morphology (erode / dilate) — applies to the active segment - * 6. Slice tools: shape interpolation, copy-across-slices - * 7. Export: download .nii.gz (save-to-server disabled for now) - */ -function MaskEditPanel({ - organs, - caseId, - serverCaseId, - mode, - onModeChange, - onClose, - onEdit, - onCreateClass, - smartFillMarkMode, - onSmartFillMarkModeChange, - smartFillScope, - onSmartFillScopeChange, - onApplySmartFill, - onClearSmartFillScribbles, - lassoAnchorCount, - onLassoUndo, - onLassoClose, - onLassoCancel, - morphScope, - onMorphScopeChange, - pickingMorphTarget, - onPickIsland, - morphSeedVoxel, - focusedPane, - totalSlices, -}: MaskEditPanelProps) { - // ---- Section 1: target organ + new class --------------------------------- - const [segment, setSegment] = useState(organs[0]?.id ?? 1); - const [showNewClass, setShowNewClass] = useState(false); - const [newClassName, setNewClassName] = useState(""); - const [newClassColor, setNewClassColor] = useState(() => - colorToHex(colorForNewClass((organs.length || 0) + 1)) - ); - const [createError, setCreateError] = useState(""); - - // Brush size lives here (not lifted) — only this panel's Paint/Erase UI - // and the Cornerstone brush tool need it. - const [brushMm, setBrushMm] = useState(10); - - // Seed the brush target/size once, on mount; change handlers keep them current. - useEffect(() => { - setActiveEditSegment(segment); - setMaskBrushSize(brushMm); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // If the selected segment disappears (e.g. organ list refreshed), fall back - // to the first available one rather than pointing at a dead id. - useEffect(() => { - if (organs.some((o) => o.id === segment)) return; - const fallback = organs[0]?.id ?? 1; - setSegment(fallback); - setActiveEditSegment(fallback); - }, [organs, segment]); - - const selectSegment = (id: number) => { - setSegment(id); - setActiveEditSegment(id); - }; - - const createClass = () => { - setCreateError(""); - const trimmed = newClassName.trim(); - if (!trimmed) { - setCreateError("Enter a name for the new class."); - return; - } - if (!onCreateClass) return; - const created = onCreateClass(trimmed, newClassColor); - if (!created) { - setCreateError("Could not create class — is a segmentation loaded?"); - return; - } - selectSegment(created.id); - setNewClassName(""); - setNewClassColor(colorToHex(colorForNewClass(created.id + 1))); - setShowNewClass(false); - onModeChange("brush"); - onEdit?.(`Created new class "${created.label}"`); - }; - - // ---- Section 2: undo/redo history ---------------------------------------- - const [edited, setEdited] = useState(false); - const [history, setHistory] = useState({ canUndo: false, canRedo: false }); - - useEffect(() => { - const organLabel = organs.find((o) => o.id === segment)?.label ?? `segment ${segment}`; - const unsubscribe = subscribeToSegmentationEdits(() => { - setEdited(true); - setHistory(getMaskEditHistoryState()); - onEdit?.(`Edited ${organLabel} mask`); - }); - return unsubscribe; - }, [segment, organs, onEdit]); - - const undo = () => { - undoMaskEdit(); - setHistory(getMaskEditHistoryState()); - }; - const redo = () => { - redoMaskEdit(); - setHistory(getMaskEditHistoryState()); - }; - - // ---- Section 6: slice tools (interpolation + copy-across-slices) -------- - const [interpPane, setInterpPane] = useState(focusedPane); - const [interpFirstSlice, setInterpFirstSlice] = useState(""); - const [interpLastSlice, setInterpLastSlice] = useState(""); - const [copyPane, setCopyPane] = useState(focusedPane); - const [copyFirstSlice, setCopyFirstSlice] = useState(""); - const [copyLastSlice, setCopyLastSlice] = useState(""); - - // Resolves a pane + typed 1-based slice number to the volume's real slice - // index, by actually navigating there. - const resolveSliceIndex = (pane: CinePane, typedSlice: number): number | null => { - setPaneSliceIndex(pane, typedSlice - 1); - return getVolumeSliceIndexForPane(pane); - }; - - const runInterpolation = () => { - const first = Number(interpFirstSlice); - const last = Number(interpLastSlice); - if (!Number.isFinite(first) || !Number.isFinite(last) || first === last) return; - - const a = resolveSliceIndex(interpPane, first); - const b = resolveSliceIndex(interpPane, last); - if (a === null || b === null) { - onEdit?.("Interpolation failed — could not resolve slice position."); - return; - } - - const result = interpolateSegmentBetweenSlices(interpPane, a, b, segment); - if (result?.changedVoxels) { - onEdit?.(`Interpolated ${result.slicesWritten} slices (${result.changedVoxels.toLocaleString()} vox)`); - setPaneSliceIndex(interpPane, last - 1); - } else { - onEdit?.( - "Interpolation did nothing — draw the segment fully on both slices and confirm the right organ is selected." - ); - } - }; - - const runCopyAcrossSlices = () => { - const first = Number(copyFirstSlice); - const last = Number(copyLastSlice); - if (!Number.isFinite(first) || !Number.isFinite(last)) return; - if (first === last) { - onEdit?.("Pick two different slices."); - return; - } - - const from = resolveSliceIndex(copyPane, first); - const to = resolveSliceIndex(copyPane, last); - if (from === null || to === null) { - onEdit?.("Copy failed — could not resolve slice position."); - return; - } - - const result = copySegmentAcrossSlices(copyPane, from, to, segment); - if (result?.changedVoxels) { - onEdit?.( - `Copied slice ${first} across ${result.slicesWritten} slices (${result.changedVoxels.toLocaleString()} vox)` - ); - setPaneSliceIndex(copyPane, last - 1); - } else { - onEdit?.("Copy failed — draw the segment fully on the first slice before copying."); - } - }; - - // ---- Section 7: export ---------------------------------------------------- - const [exporting, setExporting] = useState(false); - - const download = () => { - const labelmap = getSegmentationExport(); - if (!labelmap) return; - - setExporting(true); - // Gzipping a full labelmap blocks for a moment; rAF lets the button - // repaint to its busy state first. - requestAnimationFrame(() => { - void (async () => { - try { - const customLabels = getCustomSegmentLabelsForExport(); - const hasCustomClasses = Object.keys(customLabels).length > 0; - - if (!hasCustomClasses) { - downloadBlob(buildNiftiGzBlob(labelmap), `case${caseId}_edited_labels.nii.gz`); - } else { - // Custom classes exist — bundle mask + labels.json so the - // name/colour metadata survives the round trip. - const zip = new JSZip(); - zip.file("combined_labels_edited.nii.gz", buildNiftiGzBlob(labelmap)); - zip.file("labels.json", JSON.stringify(customLabels, null, 2)); - const blob = await zip.generateAsync({ type: "blob" }); - downloadBlob(blob, `case${caseId}_edited_labels.zip`); - } - } finally { - setExporting(false); - } - })(); - }); - }; - - // ---- Render ---------------------------------------------------------------- - return ( -
-
- Edit Masks - -
- -
- {/* ---- 1. Target organ + new class ---- */} - - - {onCreateClass && ( -
- - {showNewClass && ( -
- - - {createError &&
{createError}
} - -
- )} -
- )} - - {/* ---- 2. Undo / redo — shared across every mode below ---- */} -
- - -
- - {/* ---- 3. Mode toolbar ---- */} -
- - - - -
- - {/* ---- 4a. Brush / Eraser controls ---- */} - {(mode === "brush" || mode === "eraser" || mode === null) && ( - - )} - - {/* ---- 4b. Smart Fill controls ---- */} - {mode === "smartfill" && ( -
- Fill scope -
- - -
- -
- - -
- -
- Dot inside, switch to "Mark Outside", dot what to exclude, then Fill. - {smartFillScope === "slice" - ? " Restricted to the slice your scribbles are on." - : " Grows through the whole volume."} -
- -
- - -
-
- )} - - {/* ---- 4c. Lasso controls ---- */} - {mode === "lasso" && ( -
- - Lasso ({lassoAnchorCount} pt{lassoAnchorCount === 1 ? "" : "s"}) - -
- Click to drop points — straight edges between them. Close the loop - (or press Close) once you have at least 3 points, to fill it. -
-
- - -
- -
- )} - - {/* ---- 5. Morphology — applies to the active segment regardless of mode ---- */} -
- Morphology target -
- - -
- {morphScope === "island" && ( - - )} -
- -
- Morphology (active segment) -
- - -
-
- - {/* ---- 6. Slice tools: interpolation + copy-across-slices ---- */} -
- Shape interpolation (SDT) - -
- setInterpFirstSlice(e.target.value)} - /> - setInterpLastSlice(e.target.value)} - /> -
- -
- Draw the segment fully on two slices of the chosen pane, type their slice numbers, then interpolate. -
-
- -
- Copy across slices - -
- setCopyFirstSlice(e.target.value)} - /> - setCopyLastSlice(e.target.value)} - /> -
- -
- Draw the segment fully on the "first" slice number of the chosen pane, then run. -
-
- - {/* ---- 7. Export ---- */} - - {serverCaseId && ( - // Server save is temporarily disabled while the endpoint is - // reworked. Left wired up (minus the network call) so - // re-enabling later is a one-line flip, not a rebuild. - - )} -
- {mode - ? `${ - mode === "brush" - ? "Painting" - : mode === "eraser" - ? "Erasing" - : mode === "smartfill" - ? "Smart-filling" - : "Lassoing" - } on the 2D panes.` - : "Pick a mode above, then work on any 2D pane."} - {" "}Edits stay in your browser until downloaded. -
-
-
- ); -} - -export default MaskEditPanel; \ No newline at end of file diff --git a/PanTS-Demo/src/components/MeshViewer.tsx b/PanTS-Demo/src/components/MeshViewer.tsx index dcdfb07..21a72c6 100644 --- a/PanTS-Demo/src/components/MeshViewer.tsx +++ b/PanTS-Demo/src/components/MeshViewer.tsx @@ -4,10 +4,10 @@ import { Suspense, useEffect, useMemo, useState } from "react"; import { APP_CONSTANTS } from "../helpers/constants"; import { cornerstoneLpsMmToThree, type Vec3 } from "../helpers/utils"; import type { MeshManifest } from "../types"; -import { OrganMesh } from "./OrganMesh"; +import { OrganMesh } from "./viewer/OrganMesh"; import { SceneCrosshair3D } from "./SceneCrosshair3D"; import type { Color } from "@cornerstonejs/core/types"; -import { LiveSegmentMesh } from "./LiveSegmentMesh"; +import { LiveSegmentMesh } from "./viewer/LiveSegmentMesh"; import type { CheckBoxData } from "../types"; import { getEditedSegments, subscribeToSegmentationEdits } from "../helpers/CornerstoneNifti2"; @@ -70,7 +70,7 @@ export function SegmentationMeshViewer({ caseId, checkState, loading, opacity, c return (
- diff --git a/PanTS-Demo/src/components/NumberSliderField.tsx b/PanTS-Demo/src/components/NumberSliderField.tsx new file mode 100644 index 0000000..0e5b46e --- /dev/null +++ b/PanTS-Demo/src/components/NumberSliderField.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from "react"; + +interface NumberSliderFieldProps { + label?: string; + value: number; + onChange: (value: number) => void; + min: number; + max: number; + step: number; + unit?: string; + ariaLabel?: string; + /** Optional: fires true while the slider (not the text box) is actively + * being dragged/focused, false when released — for tools like the + * brush diameter that show a live preview only while adjusting. */ + onPreviewChange?: (active: boolean) => void; +} + +/** + * Slider + typable number box, shared by every panel that takes a numeric + * parameter (margin mm, smoothing kernel, level-tracing tolerance, brush + * diameter, ...). + * + * The number lives in exactly one place — the typable box — with its unit + * printed right after it. The label above is just a static caption; it does + * not repeat the value, so nothing is shown twice. + * + * The box owns its own draft string while focused, independent of the + * committed value, and only parses/clamps on blur or Enter. That's what + * makes decimals typable: clearing "3" to type "2.5" no longer round-trips + * through `Number("")` → 0 and snaps back to a lone "0" mid-keystroke. + */ +export default function NumberSliderField({ + label, value, onChange, min, max, step, unit, ariaLabel, onPreviewChange, +}: NumberSliderFieldProps) { + const [draft, setDraft] = useState(String(value)); + const [focused, setFocused] = useState(false); + + // Stay in sync with external changes (slider drags, resets) — but never + // stomp on what the user is actively typing. + useEffect(() => { + if (!focused) setDraft(String(value)); + }, [value, focused]); + + const clamp = (v: number) => Math.min(max, Math.max(min, v)); + + const commit = () => { + const parsed = parseFloat(draft); + if (Number.isNaN(parsed)) { + setDraft(String(value)); + return; + } + const clamped = clamp(parsed); + onChange(clamped); + setDraft(String(clamped)); + }; + + return ( +
+ {label && {label}} +
+ onChange(Number(e.target.value))} + onPointerDown={() => onPreviewChange?.(true)} + onPointerUp={() => onPreviewChange?.(false)} + onFocus={() => onPreviewChange?.(true)} + onBlur={() => onPreviewChange?.(false)} + className="atb-flyout__range" + aria-label={ariaLabel} + style={{ flex: 1 }} + /> +
+ setFocused(true)} + onChange={(e) => { + const v = e.target.value; + // Let the user freely type a number-in-progress: digits, one + // optional leading minus (only if the range allows negatives), + // one decimal point. Reject anything else instead of coercing it. + const pattern = min < 0 ? /^-?\d*\.?\d*$/ : /^\d*\.?\d*$/; + if (v === "" || pattern.test(v)) setDraft(v); + }} + onBlur={() => { setFocused(false); commit(); }} + onKeyDown={(e) => { + if (e.key === "Enter") { commit(); (e.target as HTMLInputElement).blur(); } + if (e.key === "Escape") { setDraft(String(value)); (e.target as HTMLInputElement).blur(); } + }} + aria-label={ariaLabel ? `${ariaLabel} exact value` : "Exact value"} + /> + {unit && {unit}} +
+
+
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/OperationPicker.tsx b/PanTS-Demo/src/components/OperationPicker.tsx new file mode 100644 index 0000000..7aa2083 --- /dev/null +++ b/PanTS-Demo/src/components/OperationPicker.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from "react"; + +export interface OperationOption { + value: T; + label: string; + tooltip?: string; + icon: ReactNode; +} + +interface OperationPickerProps { + title?: string; + name: string; + options: OperationOption[]; + value: T; + onChange: (value: T) => void; +} + +/** + * Standard operation picker shared by every panel that offers a small set + * of mutually-exclusive operations (scissors, margin, islands, ...): + * "Operation:" header on top, then each choice as a radio row with its icon + * beside its label. One component so every tool's picker looks and behaves + * the same instead of being reinvented per panel. + * + * Pass title="" (or omit and pass nothing truthy) to skip the header + * entirely — useful when the panel already has its own label above the + * picker (e.g. Grow From Seeds' "Scope:" label) and showing "Operation:" + * on top of that would be redundant. + */ +export default function OperationPicker({ + title = "Operation:", name, options, value, onChange, +}: OperationPickerProps) { + return ( +
+
+ {title && {title}} + {options.map((opt) => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/SliceJumpInput.tsx b/PanTS-Demo/src/components/SliceJumpInput.tsx index b6fbe54..d5c55bf 100644 --- a/PanTS-Demo/src/components/SliceJumpInput.tsx +++ b/PanTS-Demo/src/components/SliceJumpInput.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { forwardRef, useState } from "react"; import { setPaneSliceIndex, type CinePane, type SliceInfo } from "../helpers/CornerstoneNifti2"; type Props = { @@ -6,7 +6,7 @@ type Props = { info: SliceInfo; } -function SliceJumpInput({ pane, info }: Props) { +const SliceJumpInput = forwardRef(function SliceJumpInput({ pane, info }, ref) { const [isEditing, setIsEditing] = useState(false); const [inputValue, setInputValue] = useState(String(info.current + 1)); @@ -25,6 +25,7 @@ function SliceJumpInput({ pane, info }: Props) { return (
e.stopPropagation()} onClick={(e) => e.stopPropagation()} @@ -41,7 +42,7 @@ function SliceJumpInput({ pane, info }: Props) { onChange={(e) => setInputValue(e.target.value)} onBlur={commitEdit} onKeyDown={(e) => { - e.stopPropagation(); // stop the global keydown listener (window) from also eating this keystroke + e.stopPropagation(); if (e.key === "Enter") commitEdit(); if (e.key === "Escape") setIsEditing(false); }} @@ -64,6 +65,6 @@ function SliceJumpInput({ pane, info }: Props) { )}
); -} +}); -export default SliceJumpInput; \ No newline at end of file +export default SliceJumpInput; diff --git a/PanTS-Demo/src/components/segmentation/CopyAcrossSlicesFlyout.tsx b/PanTS-Demo/src/components/segmentation/CopyAcrossSlicesFlyout.tsx new file mode 100644 index 0000000..822fa6e --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/CopyAcrossSlicesFlyout.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import "./SegmentEffectPanel.css" +import { copySegmentAcrossSlices, setPaneSliceIndex } from "../../helpers/CornerstoneNifti2"; +import type { MaskFilter, CinePane } from "../../helpers/CornerstoneNifti2"; +import { useSliceAnchorPicker } from "../../helpers/viewer/useSliceAnchorPicker"; +import { StartButton, StepLabel, PickingBanner, StartOverButton, ArrowIcon } from "./SliceAnchorPickerUI"; + +interface Props { + pane: CinePane; + totalSlices: number; + segmentIndex: number; + maskFilter: MaskFilter; + onLog?: (detail: string) => void; +} + +export default function CopyAcrossSlicesFlyout({ segmentIndex, maskFilter, onLog }: Props) { + // Surfaced inline right under the picking banner whenever a click lands on + // a slice that doesn't satisfy the current step's requirement (e.g. the + // source slice has no drawn shape). It clears itself the moment a valid + // slice is picked — i.e. as soon as `first`/`last`/`phase` actually change. + const [pickError, setPickError] = useState(null); + + // The destination (last) slice is where the copy is going TO — it's + // expected to be empty, so the last click only needs to land on a valid + // slice in the same pane, not on an existing drawing. + const picker = useSliceAnchorPicker({ + segmentIndex, + lastRequiresSegment: false, + onError: (d) => { setPickError(d); onLog?.(d); }, + }); + const { phase, step, first, last } = picker; + + useEffect(() => { + setPickError(null); + }, [phase, step, first, last]); + + const run = () => { + if (!first || !last) return; + const result = copySegmentAcrossSlices(first.pane, first.sliceIndex, last.sliceIndex, segmentIndex, maskFilter); + if (result?.changedVoxels) { + onLog?.(`Copied across ${result.slicesWritten} slices (${result.changedVoxels.toLocaleString()} vox)`); + setPaneSliceIndex(last.pane, last.sliceIndex); + } else { + onLog?.("Copy failed — draw the shape fully on the first slice first."); + } + picker.reset(); + }; + + return ( +
+
Copy a shape from one slice to every slice up to another.
+ + {phase === "idle" ? ( + + ) : ( + <> + + + + {phase === "ready" && ( + + )} + + + + )} + + {phase === "picking" && ( + + )} + + {pickError &&
{pickError}
} +
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/FillBetweenSlicesFlyout.tsx b/PanTS-Demo/src/components/segmentation/FillBetweenSlicesFlyout.tsx new file mode 100644 index 0000000..ef25dd4 --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/FillBetweenSlicesFlyout.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from "react"; +import { interpolateSegmentBetweenSlices, setPaneSliceIndex } from "../../helpers/CornerstoneNifti2"; +import type { MaskFilter, CinePane } from "../../helpers/CornerstoneNifti2"; +import { useSliceAnchorPicker } from "../../helpers/viewer/useSliceAnchorPicker"; +import { StartButton, StepLabel, PickingBanner, StartOverButton, ArrowIcon } from "./SliceAnchorPickerUI"; + +interface Props { + pane: CinePane; + totalSlices: number; + segmentIndex: number; + maskFilter: MaskFilter; + onLog?: (detail: string) => void; +} + +export default function FillBetweenSlicesFlyout({ segmentIndex, maskFilter, onLog }: Props) { + // Surfaced inline right under the picking banner whenever a click lands on + // a slice that doesn't satisfy the current step's requirement (first or + // last slice missing the drawn shape). Clears itself as soon as a valid + // slice is picked — i.e. as soon as `first`/`last`/`phase` actually change. + const [pickError, setPickError] = useState(null); + + // Interpolation needs the shape drawn on BOTH end slices to build the + // in-between shapes from — unlike copy, the last click also requires it. + const picker = useSliceAnchorPicker({ + segmentIndex, + lastRequiresSegment: true, + onError: (d) => { setPickError(d); onLog?.(d); }, + }); + const { phase, step, first, last } = picker; + + useEffect(() => { + setPickError(null); + }, [phase, step, first, last]); + + const run = () => { + if (!first || !last) return; + const result = interpolateSegmentBetweenSlices(first.pane, first.sliceIndex, last.sliceIndex, segmentIndex, maskFilter); + if (result?.changedVoxels) { + onLog?.(`Interpolated ${result.slicesWritten} slices (${result.changedVoxels.toLocaleString()} vox)`); + setPaneSliceIndex(last.pane, last.sliceIndex); + } else { + onLog?.("Interpolation did nothing — draw the shape fully on both slices first."); + } + picker.reset(); + }; + + return ( +
+
Fill in the shape between two drawn slices.
+ + {phase === "idle" ? ( + + ) : ( + <> + + + + {phase === "ready" && ( + + )} + + + + )} + + {phase === "picking" && ( + + )} + + {pickError &&
{pickError}
} +
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/GrowFromSeedFlyout.tsx b/PanTS-Demo/src/components/segmentation/GrowFromSeedFlyout.tsx new file mode 100644 index 0000000..862f157 --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/GrowFromSeedFlyout.tsx @@ -0,0 +1,316 @@ +import { useEffect, useState } from "react"; +import type { useSmartFill } from "../../helpers/viewer/useSmartFill"; +import ApplyButton from "../ApplyButton"; +import OperationPicker, { type OperationOption } from "../OperationPicker"; + +type SmartFill = ReturnType; + +// If your useSmartFill hook can report whether any foreground/background +// scribbles actually exist, wire them up here (optional — see the two +// commented lines in the Pick<> below and where hasForegroundMarks / +// hasBackgroundMarks are read). Without them, "Continue" is a manual +// self-report step; with them, this component enforces it for real and +// shows an inline error if you try to move on with nothing marked. +type GrowFromSeedsFlyoutProps = Pick & { + hasForegroundMarks?: boolean; + hasBackgroundMarks?: boolean; +}; + +type Step = 1 | 2 | 3; + +// Two flat "plane" icons for scope: one slice highlighted vs. the whole +// stack highlighted — same visual language as the islands panel's blobs. +function ScopeIcon({ scope }: { scope: "slice" | "volume" }) { + if (scope === "slice") { + return ( + + + + + + ); + } + return ( + + + + + + ); +} + +const SCOPE_OPTIONS: OperationOption<"slice" | "volume">[] = [ + { value: "slice", label: "Current slice", tooltip: "Only fill on the slice you're currently viewing.", icon: }, + { value: "volume", label: "All slices", tooltip: "Fill across the whole volume.", icon: }, +]; + +// Step 1 icon: a dot landing inside a solid blob. +function MarkInsideIcon({ size = 34 }: { size?: number }) { + return ( + + + + + + + ); +} + +// Step 2 icon: a dot landing outside the blob, with a small "excluded" ring. +function MarkOutsideIcon({ size = 34 }: { size?: number }) { + return ( + + + + + + + ); +} + +// Step 3 icon: the region fully filled solid — the end state. +function FillReadyIcon({ size = 34 }: { size?: number }) { + return ( + + + + + ); +} + +// Progress rail — three dots connected by a line, current step highlighted, +// done steps checked. Built as a CSS grid (dot / line / dot / line / dot) +// rather than dots-over-an-absolutely-positioned-line, so the connector +// physically cannot render on top of a dot's number — it occupies its own +// grid track between them, never overlapping. Purely visual orientation, +// not clickable (you move forward/back only via the step buttons). +function StepRail({ step }: { step: Step }) { + const state = (n: Step) => (n < step ? "done" : n === step ? "current" : "upcoming"); + const labels = ["Mark inside", "Mark outside", "Fill"]; + + const dotStyle = (n: Step): React.CSSProperties => { + const s = state(n); + return { + width: 22, + height: 22, + borderRadius: "50%", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: 11, + fontWeight: 700, + justifySelf: "center", + background: s === "current" ? "#6ea8fe" : s === "done" ? "#22c55e" : "rgba(255,255,255,0.08)", + color: s === "upcoming" ? "rgba(255,255,255,0.4)" : "#0b1620", + border: s === "upcoming" ? "1px solid rgba(255,255,255,0.18)" : "none", + }; + }; + + const lineStyle = (done: boolean): React.CSSProperties => ({ + height: 2, + alignSelf: "center", + background: done ? "#22c55e" : "rgba(255,255,255,0.12)", + }); + + const labelStyle = (n: Step): React.CSSProperties => ({ + fontSize: 9.5, + textAlign: "center", + color: state(n) === "upcoming" ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.75)", + }); + + return ( +
+
{state(1) === "done" ? "✓" : 1}
+
1)} /> +
{state(2) === "done" ? "✓" : 2}
+
2)} /> +
{state(3) === "done" ? "✓" : 3}
+ {labels[0]} + + {labels[1]} + + {labels[2]} +
+ ); +} + +const stepCardStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 8, + textAlign: "center", + padding: "14px 12px", + borderRadius: 10, + background: "rgba(255,255,255,0.04)", + border: "1px solid rgba(255,255,255,0.08)", +}; + +const primaryBtnStyle: React.CSSProperties = { + width: "100%", + padding: "9px 10px", + borderRadius: 8, + border: "none", + background: "#6ea8fe", + color: "#0b1620", + fontWeight: 700, + fontSize: 12.5, + cursor: "pointer", +}; + +const secondaryBtnStyle: React.CSSProperties = { + width: "100%", + padding: "8px 10px", + borderRadius: 8, + border: "1px solid rgba(255,255,255,0.15)", + background: "transparent", + color: "rgba(255,255,255,0.75)", + fontSize: 12, + cursor: "pointer", +}; + +const errorStyle: React.CSSProperties = { + fontSize: 11.5, + color: "#fda4af", + background: "rgba(244,63,94,0.1)", + border: "1px solid rgba(244,63,94,0.3)", + borderRadius: 6, + padding: "6px 8px", +}; + +export default function GrowFromSeedsFlyout({ + setMarkMode, scope, setScope, apply, clearScribbles, + hasForegroundMarks, hasBackgroundMarks, +}: GrowFromSeedsFlyoutProps) { + const [step, setStep] = useState(1); + const [error, setError] = useState(null); + + // The step drives markMode — the user never toggles it directly. Step 1 = + // marking what to include, step 2 = marking what to exclude. + useEffect(() => { + if (step === 1) setMarkMode("fg"); + else if (step === 2) setMarkMode("bg"); + }, [step, setMarkMode]); + + const handleStartOver = () => { + clearScribbles(); + setStep(1); + setError(null); + }; + + // Switching scope mid-flow means everything marked so far was placed under + // the old scope (this slice vs. the whole volume) and no longer means what + // the user thinks it means — so it's discarded, not carried over. + const handleScopeChange = (next: typeof scope) => { + handleStartOver(); + setScope(next); + }; + + const handleContinueFromInside = () => { + if (hasForegroundMarks === false) { + setError("Click at least one point inside the region before continuing."); + return; + } + setError(null); + setStep(2); + }; + + const handleSkipOutside = () => { + if (hasBackgroundMarks === true) { + setError("You've marked points to exclude — click Continue instead, or Start over to clear them."); + return; + } + setError(null); + setStep(3); + }; + + const handleFill = async () => { + if (hasForegroundMarks === false) { + setError("Nothing was marked — click Start over and mark at least one point inside the region first."); + return; + } + setError(null); + await apply(); + // A committed fill consumes the marks that produced it — starting the + // next fill from a clean step 1 rather than leaving stale scribbles + // and a "step 3" state that no longer matches what's on screen. + handleStartOver(); + }; + + return ( +
+
+ Scope: + +
+ + + + {step === 1 && ( +
+ +
Click inside the region
+
+ Drop a few points anywhere inside what you want to fill. More points in different spots works better than one. +
+ {error &&
{error}
} + +
+ )} + + {step === 2 && ( +
+ +
Click anything to exclude
+
+ Optional. If a neighboring structure keeps bleeding in, dot it here to keep it out. Skip this if you don't need it. +
+ {error &&
{error}
} + + + +
+ )} + + {step === 3 && ( +
+ +
Ready to fill
+
+ This grows outward from your inside points, staying clear of anything marked outside, {scope === "slice" ? "on the current slice." : "across all slices."} +
+ {error &&
{error}
} +
+ +
+ +
+ )} + + +
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/HollowFlyout.tsx b/PanTS-Demo/src/components/segmentation/HollowFlyout.tsx new file mode 100644 index 0000000..2b80321 --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/HollowFlyout.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import "./SegmentEffectPanel.css" +import ApplyButton from "../ApplyButton"; +import OperationPicker, { type OperationOption } from "../OperationPicker"; +import NumberSliderField from "../NumberSliderField"; +import { applyHollow } from "../../helpers/CornerstoneNifti2"; +import type { HollowSurface, MaskFilter } from "../../helpers/CornerstoneNifti2"; + +interface Props { + segmentIndex: number; + maskFilter: MaskFilter; + onLog?: (detail: string) => void; +} + +// A shell drawn as a plain stroked ring (stroke-width = shell thickness) is +// enough to read at a glance; the dashed circle behind it is always the +// *original* segment boundary, so where the solid ring sits relative to the +// dashed line is the entire explanation — same "solid = result, dashed = +// original" language MarginOpIcon already uses. +function HollowOpIcon({ surface }: { surface: HollowSurface }) { + const originalR = 6; + const thickness = 2.6; + const ringCenterR = + surface === "inside" ? originalR - thickness / 2 : + surface === "outside" ? originalR + thickness / 2 : + originalR; // medial — centered on the original boundary + + return ( + + + + + ); +} + +const HOLLOW_OPTIONS: OperationOption[] = [ + { value: "inside", label: "Inside surface", tooltip: "The current segment becomes the outside of the shell — it's hollowed out from within.", icon: }, + { value: "medial", label: "Medial surface", tooltip: "The current boundary runs through the middle of the shell — it grows half-in, half-out.", icon: }, + { value: "outside", label: "Outside surface", tooltip: "The current segment becomes the inside of the shell — it grows outward into a shell around it.", icon: }, +]; + +const MIN_THICKNESS_MM = 0.1; +// Most shell use-cases (organ/vessel walls, printable casings) fall well +// under this; capping here keeps the slider precise at the sizes people +// actually use instead of spreading its range across values no one needs. +const MAX_THICKNESS_MM = 20; + +export default function HollowFlyout({ segmentIndex: _segmentIndex, maskFilter, onLog }: Props) { const [surface, setSurface] = useState("inside"); + const [thicknessMm, setThicknessMm] = useState(3); + const [applying, setApplying] = useState(false); + + + const run = () => { + setApplying(true); + try { + const r = applyHollow(surface, thicknessMm, 6, maskFilter); + if (r?.changedVoxels) { + onLog?.(`Hollowed (${surface} surface, ${thicknessMm}mm) — ${r.changedVoxels.toLocaleString()} vox`); + } else { + onLog?.("Hollow did nothing — segment may be too thin for this shell thickness."); + } + } finally { + setApplying(false); + } + }; + + return ( +
+ + + + + + +
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/IslandsPanel.tsx b/PanTS-Demo/src/components/segmentation/IslandsPanel.tsx new file mode 100644 index 0000000..82ebe09 --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/IslandsPanel.tsx @@ -0,0 +1,315 @@ +import { useEffect, useRef, useState } from "react"; +import ApplyButton from "../ApplyButton"; +import OperationPicker, { type OperationOption } from "../OperationPicker"; +import NumberSliderField from "../NumberSliderField"; +import type { IslandsOperation } from "../../helpers/CornerstoneNifti2"; + +interface IslandsPanelProps { + onApply: (operation: IslandsOperation, minimumSize: number) => void; + pickingSelectedIsland: boolean; + onPickSelectedIsland: () => void; + /** Forgets whatever voxel was previously picked. Called whenever the operation + * changes or the target segment/organ changes, since a pick made for one + * doesn't carry any guaranteed meaning for another. */ + onResetPick: () => void; + hasSelectedIsland: boolean; + /** A voxel was clicked, but it isn't part of the segment this operation runs on. */ + pickedInvalid: boolean; + /** Identifies the current target segment/organ — watched only to trigger a reset when it changes. */ + targetKey: number | null; +} + +const MIN_SIZE_VOXELS = 1; +const MAX_SIZE_VOXELS = 20000; + +// Three blobs of decreasing size stand in for "islands" in a segment. Each +// mode redraws them to show what happens: solid = kept as-is, faint dashed +// outline = removed, red X = explicitly deleted, ring = the picked island, +// distinct colors = split apart into separate segments. +function IslandsOpIcon({ op }: { op: IslandsOperation }) { + const BIG = { cx: 7.4, cy: 8, r: 4.2 }; + const MED = { cx: 15, cy: 6.8, r: 2.5 }; + const SMALL = { cx: 13.2, cy: 15, r: 1.7 }; + + const blob = (b: { cx: number; cy: number; r: number }, mode: "solid" | "dashed" | "x", color?: string) => { + if (mode === "x") { + return ( + + + + + + ); + } + if (mode === "dashed") { + return ; + } + return ; + }; + + let content: React.ReactNode; + switch (op) { + case "keepLargest": + content = <>{blob(BIG, "solid")}{blob(MED, "dashed")}{blob(SMALL, "dashed")}; + break; + case "keepSelected": + content = ( + <> + {blob(BIG, "x")} + {blob(MED, "solid")} + + {blob(SMALL, "x")} + + ); + break; + case "removeSmall": + content = <>{blob(BIG, "solid")}{blob(MED, "solid")}{blob(SMALL, "x")}; + break; + case "removeSelected": + content = <>{blob(BIG, "solid")}{blob(MED, "x")}{blob(SMALL, "solid")}; + break; + case "splitToSegments": + content = <>{blob(BIG, "solid", "#f43f5e")}{blob(MED, "solid", "#6ea8fe")}{blob(SMALL, "solid", "#eab308")}; + break; + } + + return ( + + {content} + + ); +} + +const ISLANDS_OPTIONS: OperationOption[] = [ + { + value: "keepLargest", + label: "Keep largest", + tooltip: "Keep only the largest island in this segment — every smaller island is removed.", + icon: , + }, + { + value: "keepSelected", + label: "Keep only picked", + tooltip: "Click one island to pick it — every other island in this segment is deleted, leaving just the one you picked.", + icon: , + }, + { + value: "removeSmall", + label: "Remove small", + tooltip: "Remove every island in this segment smaller than the minimum size below.", + icon: , + }, + { + value: "removeSelected", + label: "Remove picked", + tooltip: "Click one island to pick it, then remove just that island — everything else in the segment is left alone.", + icon: , + }, + { + value: "splitToSegments", + label: "Split to segments", + tooltip: "Turn every island in this segment into its own new segment, each with its own color.", + icon: , + }, +]; + +// Crosshair target used everywhere in the picker: a plain ring while idle, a +// pulsing red ring while armed and waiting for a click, a solid green ring +// with a checkmark once a valid pick lands, and a red ring with an X if the +// click landed outside the segment — so the icon alone tells you the state. +function PickIcon({ state }: { state: "idle" | "picking" | "picked" | "invalid" }) { + const color = state === "picking" || state === "invalid" ? "#f43f5e" : state === "picked" ? "#22c55e" : "currentColor"; + return ( + + ); +} + +// Small "click here" cursor glyph for the call-to-action buttons — reinforces +// that the next step happens in the viewport, not in this panel. +function ClickHintIcon() { + return ( + + ); +} + +export default function IslandsPanel({ + onApply, + pickingSelectedIsland, + onPickSelectedIsland, + onResetPick, + hasSelectedIsland, + pickedInvalid, + targetKey, +}: IslandsPanelProps) { + const [operation, setOperation] = useState("keepLargest"); + const [minimumSize, setMinimumSize] = useState(1000); + + const needsSelectedIsland = operation === "keepSelected" || operation === "removeSelected"; + + // A pick only ever makes sense for the exact operation + target segment it + // was made for — switching either one forces a fresh pick rather than + // silently carrying the old one over. + const prevOperationRef = useRef(operation); + useEffect(() => { + if (prevOperationRef.current !== operation) onResetPick(); + prevOperationRef.current = operation; + }, [operation, onResetPick]); + + const prevTargetRef = useRef(targetKey); + useEffect(() => { + if (prevTargetRef.current !== targetKey) onResetPick(); + prevTargetRef.current = targetKey; + }, [targetKey, onResetPick]); + + const pickState: "idle" | "picking" | "picked" | "invalid" = pickingSelectedIsland + ? "picking" + : pickedInvalid + ? "invalid" + : hasSelectedIsland + ? "picked" + : "idle"; + + const applyReady = needsSelectedIsland && pickState === "picked"; + + return ( +
+ + + {operation === "removeSmall" && ( + + )} + + {needsSelectedIsland && ( +
+ + + Pick your island + + + {pickState === "idle" && ( + + )} + + {pickState === "picking" && ( +
+ + Click directly on the island in any 2D view (axial, sagittal, or coronal). +
+ )} + + {pickState === "picked" && ( +
+ + + Island picked + + +
+ )} + + {pickState === "invalid" && ( +
+ + + Not part of this segment + + + That click landed outside the active segment — pick a point inside it instead. +
+ )} +
+ )} + +
+ {applyReady && ( + + Island selected — press Apply to run it. + + )} + { + onApply(operation, minimumSize); + // The pick only ever applies to one run — after Apply, whatever + // was picked no longer describes the (now-changed) segment, so + // forget it rather than leaving a stale "Island picked" state. + onResetPick(); + }} + /> +
+
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/LevelTracingFlyout.tsx b/PanTS-Demo/src/components/segmentation/LevelTracingFlyout.tsx new file mode 100644 index 0000000..f107817 --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/LevelTracingFlyout.tsx @@ -0,0 +1,73 @@ +import OperationPicker, { type OperationOption } from "../OperationPicker"; +import NumberSliderField from "../NumberSliderField"; +import type { LevelTraceOperation } from "../../helpers/CornerstoneNifti2"; + +interface Props { + operation: LevelTraceOperation; + onOperationChange: (op: LevelTraceOperation) => void; + toleranceHu: number; + onToleranceChange: (hu: number) => void; +} + +// Same visual language as the Scissors tool's op icons: a square stands in +// for "the slice", the circle for "the traced region". Solid fill = ends up +// part of the segment, dashed-only = ends up cleared. Inside vs outside just +// mirrors which side of the circle gets touched. +function LevelTraceOpIcon({ op }: { op: LevelTraceOperation }) { + const fill = op === "fillInside" || op === "fillOutside"; + const inside = op === "eraseInside" || op === "fillInside"; + return ( + + + + {!inside && fill && ( + + )} + + ); +} + +const LEVEL_TRACE_OPTIONS: OperationOption[] = [ + { value: "fillInside", label: "Fill inside", tooltip: "Fill the traced region into the active segment.", icon: }, + { value: "fillOutside", label: "Fill outside", tooltip: "Fill everything on this slice OUTSIDE the traced region into the active segment.", icon: }, + { value: "eraseInside", label: "Erase inside", tooltip: "Clear the active segment wherever it falls inside the traced region.", icon: }, + { value: "eraseOutside", label: "Erase outside", tooltip: "Clear the active segment everywhere on this slice OUTSIDE the traced region.", icon: }, +]; + +const MIN_SENSITIVITY_HU = 5; +// Past ~300 HU the trace is effectively grabbing half the intensity range in +// a typical CT — wide enough to cover "loosest useful setting" without the +// slider wasting resolution on values nobody wants. +const MAX_SENSITIVITY_HU = 300; + +export default function LevelTracingFlyout({ operation, onOperationChange, toleranceHu, onToleranceChange }: Props) { + return ( +
+ + Hover to preview the region of matching intensity, click to apply it. + + + + + + + Higher sensitivity traces a wider range of intensities around the cursor. +
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/LogicalOperatorsPanel.tsx b/PanTS-Demo/src/components/segmentation/LogicalOperatorsPanel.tsx new file mode 100644 index 0000000..f74fa31 --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/LogicalOperatorsPanel.tsx @@ -0,0 +1,336 @@ +import type { LogicalOperation } from "../../helpers/CornerstoneNifti2"; +import type { CheckBoxData } from "../../types"; +import ApplyButton from "../ApplyButton"; +import OperationPicker, { type OperationOption } from "../OperationPicker"; + +interface LogicalOperatorsPanelProps { + segments: CheckBoxData[]; + targetSegmentId: number; + onApply: (operation: LogicalOperation, sourceSegmentId: number | null, bypassMasking: boolean) => void; + // Selections live in the parent so they survive the panel unmounting when + // the flyout is minimized or switched away from and back. + operation: LogicalOperation; + onOperationChange: (op: LogicalOperation) => void; + sourceId: number | null; + onSourceIdChange: (id: number | null) => void; + bypassMasking: boolean; + onBypassMaskingChange: (v: boolean) => void; +} + +// A = this (target) segment, B = the other (source) segment. Solid white = +// stays filled, dashed = stays/ends up empty, panel-bg fill = punched out. +// Same visual language as the islands/margin icons: solid = kept, dashed = +// empty, cross = removed. +function LogicalOpIcon({ op }: { op: LogicalOperation }) { + const A = { cx: 7.5, cy: 10, r: 5.5 }; + const B = { cx: 12.5, cy: 10, r: 5.5 }; + + switch (op) { + case "copy": + // Source shape becomes the target shape: dashed source on the left, + // solid arrow, solid result on the right. + return ( + + + + + + + + + + + ); + case "add": + // Union: both circles filled — overlapping the same solid fill reads + // as one merged blob. + return ( + + + + + ); + case "invert": + // Everything filled except the segment's own shape — inside/outside + // swapped, via an evenodd cutout. + return ( + + + + ); + case "clear": + // Empty, dashed outline with a cross through it — same "removed" + // language used in the islands panel. + return ( + + + + + + ); + case "fill": + // Fully solid — the whole segment (within the masking area) filled. + return ( + + + + ); + } +} + +const OPERATIONS: { value: LogicalOperation; label: string; tooltip: string; needsSource: boolean }[] = [ + { value: "copy", label: "Copy", tooltip: "Replace this segment with another segment's shape.", needsSource: true }, + { value: "add", label: "Add", tooltip: "Merge another segment into this one.", needsSource: true }, + { value: "invert", label: "Invert", tooltip: "Swap this segment's filled and empty voxels.", needsSource: false }, + { value: "clear", label: "Clear", tooltip: "Empty this segment completely.", needsSource: false }, + { value: "fill", label: "Fill", tooltip: "Fill this segment completely (within the masking area).", needsSource: false }, +]; + +// Plain-English sentence for each operation, built out of pieces so the +// target segment (always known) and source segment (picked from the +// dropdown, or blank until then) can be styled/rendered differently from +// the surrounding words. TARGET/SOURCE are placeholder tokens swapped for +// the actual segment-name chip at render time. +type SentencePart = "TARGET" | "SOURCE" | string; + +const SENTENCE_TEMPLATES: Record, SentencePart[]> = { + copy: ["Give ", "TARGET", " the exact shape ", "SOURCE", " currently has, discarding ", "TARGET", "'s old shape. ", "SOURCE", "'s voxels become ", "TARGET", "."], + add: ["Merge ", "SOURCE", " into ", "TARGET", ". ", "SOURCE", "'s voxels become ", "TARGET", ", added on top of ", "TARGET", "'s existing shape."], + invert: ["Swap ", "TARGET", "'s filled and empty voxels."], + clear: ["Empty ", "TARGET", " completely."], +}; + +// Small mask glyph used inside the bypass-masking switch knob. Solid/opaque +// when masking is respected (the mask is "on"), shown with a diagonal slash +// when bypassed ("off"), so the icon alone communicates the current state. +function MaskIcon({ active }: { active: boolean }) { + const color = active ? "#0b3b2e" : "rgba(255,255,255,0.55)"; + return ( + + ); +} + +export default function LogicalOperatorsPanel({ + segments, + targetSegmentId, + onApply, + operation, + onOperationChange, + sourceId, + onSourceIdChange, + bypassMasking, + onBypassMaskingChange, +}: LogicalOperatorsPanelProps) { + const opDef = OPERATIONS.find((o) => o.value === operation) ?? OPERATIONS[0]; + const otherSegments = segments.filter((s) => s.id !== targetSegmentId); + const targetSegment = segments.find((s) => s.id === targetSegmentId); + const sourceSegment = sourceId != null ? otherSegments.find((s) => s.id === sourceId) ?? null : null; + + const options: OperationOption[] = OPERATIONS.map((o) => ({ + value: o.value, + label: o.label, + tooltip: o.tooltip, + icon: , + })); + + // A blank changing operation forgets whatever source was picked for the + // previous one — a source chosen for "Subtract" has no guaranteed meaning + // once you switch to "Intersect". + const handleOperationChange = (next: LogicalOperation) => { + onOperationChange(next); + onSourceIdChange(null); + }; + + const sentenceChip = (kind: "target" | "source" | "blank", text: string, key: string | number) => ( + + {text} + + ); + + // Fill is the odd one out: it has no source, and its masking clause + // depends on the bypass toggle rather than being fixed text. + const renderFillSentence = () => { + if (bypassMasking) { + return [ + Fill the entire volume with , + sentenceChip("target", targetSegment?.label ?? "this segment", "target"), + , overwriting any other segment in its way., + ]; + } + return [ + Fill the entire volume with , + sentenceChip("target", targetSegment?.label ?? "this segment", "target"), + , but only where voxels are empty or already , + sentenceChip("target", targetSegment?.label ?? "this segment", "target2"), + ., + ]; + }; + + const renderSentence = () => { + if (operation === "fill") return renderFillSentence(); + const template = SENTENCE_TEMPLATES[operation]; + return template.map((part, i) => { + if (part === "TARGET") { + return sentenceChip("target", targetSegment?.label ?? "this segment", i); + } + if (part === "SOURCE") { + return sourceSegment ? sentenceChip("source", sourceSegment.label, i) : sentenceChip("blank", "___", i); + } + return {part}; + }); + }; + + return ( +
+ + + {opDef.needsSource && ( +
+ With segment: + +
+ )} + + {/* Plain-English readout of what Apply will actually do. Fills in as + selections are made — starts with a blank until a source segment + is picked. Also spells out that SOURCE's voxels get absorbed into + TARGET (SOURCE loses that territory) — this is a single shared + label array, so copy/add don't leave the source untouched. */} +
+ What this does: +
+
{renderSentence()}
+ {opDef.needsSource && ( +
+ {sourceSegment ? sentenceChip("source", sourceSegment.label, "note-source") : sentenceChip("blank", "___", "note-source")}{" "} + will lose those voxels — they now belong to {sentenceChip("target", targetSegment?.label ?? "this segment", "note-target")}. +
+ )} +
+
+ + {/* Bypass-masking switch — a real sliding toggle (track + knob) rather + than a checkbox, so it's unambiguous that it's clickable and what + state it's currently in. */} +
+ + + {bypassMasking ? "Masking bypassed" : "Masking respected"} + + + {bypassMasking + ? "This operation ignores segment ownership and can overwrite any voxel, even ones another segment already owns." + : "This operation can only touch empty voxels and this segment's own — it won't overwrite another segment's voxels."} + + + +
+ + onApply(operation, opDef.needsSource ? sourceId : null, bypassMasking)} + /> +
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/MarginPanel.tsx b/PanTS-Demo/src/components/segmentation/MarginPanel.tsx new file mode 100644 index 0000000..167954c --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/MarginPanel.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import ApplyButton from "../ApplyButton"; +import OperationPicker, { type OperationOption } from "../OperationPicker"; +import NumberSliderField from "../NumberSliderField"; + +export type MarginOperation = "grow" | "shrink"; +export type EditableArea = "everywhere" | "insideAllSegments" | "insideVisibleSegments" | "insideSegment" | "outsideAllSegments" | "outsideVisibleSegments"; + +interface MarginPanelProps { + onApply: (operation: MarginOperation, marginMm: number) => void; + actualMm: [number, number, number] | null; + actualVoxels: [number, number, number] | null; +} + +const MAX_MARGIN_MM = 3; +const MIN_MARGIN_MM = 0; + +// Solid ring = the new boundary, faint dashed ring = the original boundary. +// Arrows point the direction the boundary actually moves, so grow/shrink +// read the same way the scissors inside/outside icons do. +function MarginOpIcon({ op }: { op: MarginOperation }) { + const grow = op === "grow"; + const outerR = grow ? 7.2 : 4.6; + const innerR = grow ? 4.6 : 7.2; + + const arrow = (angle: number) => { + const rad = (angle * Math.PI) / 180; + const from = grow ? innerR + 0.8 : outerR + 3.6; + const to = grow ? outerR + 3.4 : innerR + 0.8; + return { + x1: 10 + from * Math.cos(rad), + y1: 10 + from * Math.sin(rad), + x2: 10 + to * Math.cos(rad), + y2: 10 + to * Math.sin(rad), + }; + }; + + return ( + + + + + + + + + {[0, 90, 180, 270].map((angle) => { + const { x1, y1, x2, y2 } = arrow(angle); + return ( + + ); + })} + + ); +} + +const MARGIN_OPTIONS: OperationOption[] = [ + { value: "grow", label: "Grow", tooltip: "Expand the segment boundary outward by the margin.", icon: }, + { value: "shrink", label: "Shrink", tooltip: "Pull the segment boundary inward by the margin.", icon: }, +]; + +export default function MarginPanel({ onApply }: MarginPanelProps) { + const [operation, setOperation] = useState("grow"); + const [marginMm, setMarginMm] = useState(1.5); + + return ( +
+ + + + + onApply(operation, marginMm)} /> +
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/MaskingSelect.css b/PanTS-Demo/src/components/segmentation/MaskingSelect.css new file mode 100644 index 0000000..9371ee8 --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/MaskingSelect.css @@ -0,0 +1,71 @@ +.masking-select { + display: flex; + flex-direction: column; + gap: 5px; + padding-top: 10px; + margin-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.09); +} + +.masking-select__label { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.5); +} + +.masking-select__trigger { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 8px; + color: #fff; + font-size: 12.5px; + font-weight: 600; + padding: 7px 9px; + cursor: pointer; +} + +.masking-select__trigger:hover { + background: rgba(255, 255, 255, 0.12); +} + +.masking-select__menu { + z-index: 110; + display: flex; + flex-direction: column; + gap: 2px; + padding: 5px; + background: #16181d; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 10px; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5); +} + +.masking-select__item { + display: flex; + align-items: center; + gap: 9px; + background: transparent; + border: none; + border-radius: 7px; + color: rgba(255, 255, 255, 0.8); + font-size: 12.5px; + text-align: left; + padding: 7px 9px; + cursor: pointer; + white-space: nowrap; +} + +.masking-select__item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.masking-select__item.is-active { + background: #fff; + color: #08090b; +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/MaskingSelect.tsx b/PanTS-Demo/src/components/segmentation/MaskingSelect.tsx new file mode 100644 index 0000000..de4b74a --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/MaskingSelect.tsx @@ -0,0 +1,182 @@ +// MaskingSelect.tsx +import { useState } from "react"; +import { createPortal } from "react-dom"; +import { useRef, useEffect } from "react"; +import { + IconWorld, + IconStack2, + IconStack2Filled, + IconFocus2, + IconStackBack, + IconStackPop, + IconTarget, + IconChevronDown, +} from "@tabler/icons-react"; +import "./MaskingSelect.css"; + +export type MaskingArea = + | "everywhere" + | "insideAllSegments" + | "insideVisibleSegments" + | "insideSegment" + | "outsideAllSegments" + | "outsideVisibleSegments" + | "outsideSegment"; + +interface MaskingOption { + value: MaskingArea; + label: string; + Icon: typeof IconWorld; +} + +// Icons are chosen to read as a small family: filled/layered = "inside", outlined/ +// crossed = "outside", globe = no restriction, target = just the active segment. +const OPTIONS: MaskingOption[] = [ + { value: "everywhere", label: "Everywhere", Icon: IconWorld }, + { value: "insideAllSegments", label: "Inside all segments", Icon: IconStack2Filled }, + { value: "insideVisibleSegments", label: "Inside visible segments", Icon: IconStack2 }, + { value: "insideSegment", label: "Inside this segment", Icon: IconTarget }, + { value: "outsideAllSegments", label: "Outside all segments", Icon: IconStackBack }, + { value: "outsideVisibleSegments", label: "Outside visible segments", Icon: IconStackPop }, + { value: "outsideSegment", label: "Outside this segment", Icon: IconFocus2 }, +]; + +interface MaskingSelectProps { + value: MaskingArea; + onChange: (v: MaskingArea) => void; + hasActiveSegment: boolean; + hasAnySegments: boolean; // NEW — checkBoxData.length > 0 + /** True when the active target is a static catalog organ (baked into the local + * nifti's ground-truth labelmap). Those only ever exist in full, so scope has + * nothing to restrict — render a plain, non-interactive "Everywhere" readout + * instead of the dropdown. Custom classes keep the full picker. */ + locked?: boolean; +} + +/** Global "where is this tool allowed to act" control — shared by every tool in the + * annotation corner panel instead of each one having its own scope toggle. */ +export default function MaskingSelect({ value, onChange, hasActiveSegment, hasAnySegments, locked }: MaskingSelectProps) { + const [open, setOpen] = useState(false); + const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null); + const btnRef = useRef(null); + const menuRef = useRef(null); + + const active = OPTIONS.find((o) => o.value === value) ?? OPTIONS[0]; + const everywhere = OPTIONS[0]; + + const toggle = () => { + setOpen((prev) => { + const next = !prev; + if (next && btnRef.current) { + const r = btnRef.current.getBoundingClientRect(); + setPos({ top: r.bottom + 6, left: r.left, width: r.width }); + } + return next; + }); + }; + + useEffect(() => { + if (!open) return; + const onPointerDown = (e: MouseEvent) => { + const t = e.target as Node; + if (btnRef.current?.contains(t) || menuRef.current?.contains(t)) return; + setOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [open]); + + // NOTE: catalog organs used to force a non-interactive "Everywhere" readout + // here via `locked`. That's no longer necessary — checkBoxData already + // includes catalog organs at load, so insideSegment/insideAllSegments/etc. + // resolve for them exactly the same way they do for custom classes. The + // `locked` prop is kept for any future case that genuinely has no scope + // concept, but nothing currently sets it to true. + if (locked) { + return ( +
+ Applies to +
+ + {everywhere.label} +
+
+ ); + } + + return ( +
+ Applies to + + {open && pos && + createPortal( +
+ {OPTIONS.map((o) => { + const needsActiveSegment = o.value === "insideSegment" || o.value === "outsideSegment"; + const needsAnySegments = + o.value === "insideAllSegments" || + o.value === "outsideAllSegments" || + o.value === "insideVisibleSegments" || + o.value === "outsideVisibleSegments"; + const disabled = + (needsActiveSegment && !hasActiveSegment) || + (needsAnySegments && !hasAnySegments); + return ( + + ); + })} +
, + document.body + )} +
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/SegmentEffectPanel.css b/PanTS-Demo/src/components/segmentation/SegmentEffectPanel.css new file mode 100644 index 0000000..2bc5afe --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/SegmentEffectPanel.css @@ -0,0 +1,298 @@ +/* SegEffect.css */ +.seg-effect { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 0; + border-top: 1px solid var(--vp-border); +} + +.seg-effect__title { + font-weight: 700; + font-size: 13px; + color: var(--vp-text); +} + +.seg-effect__desc { + font-size: 11px; + color: var(--vp-text-faint); + background: rgba(255, 255, 255, 0.04); + border-radius: 6px; + padding: 6px 8px; +} + +.seg-effect__field { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.seg-effect__label { + font-size: 11.5px; + color: var(--vp-text-dim); + min-width: 90px; +} + +.seg-effect__radio, +.seg-effect__checkbox { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--vp-text); + cursor: pointer; +} + +.seg-effect__grid-2col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 12px; +} + +.seg-effect__number { + width: 70px; + background: var(--vp-panel-strong); + border: 1px solid var(--vp-border); + border-radius: 6px; + color: var(--vp-text); + padding: 4px 6px; +} + +.seg-effect__unit { + font-size: 11px; + color: var(--vp-text-faint); +} + +.seg-effect__select { + flex: 1; + background: var(--vp-panel-strong); + border: 1px solid var(--vp-border); + border-radius: 6px; + color: var(--vp-text); + padding: 5px 7px; +} + +.seg-effect__apply { + background: #fff; + color: #08090b; + border: none; + border-radius: 8px; + padding: 8px 12px; + font-weight: 700; + font-size: 12.5px; + cursor: pointer; +} + +.seg-effect__apply:disabled { + opacity: 0.4; + cursor: default; +} + +.seg-effect__apply--inline { + padding: 6px 10px; +} + +.seg-effect__pick { + background: var(--vp-panel-strong); + border: 1px solid var(--vp-border); + border-radius: 8px; + color: var(--vp-text); + padding: 7px 10px; + font-size: 12px; + cursor: pointer; +} + +.seg-effect__section-toggle { + background: transparent; + border: none; + color: var(--vp-text-dim); + font-size: 12px; + text-align: left; + cursor: pointer; + padding: 4px 0; +} + +.seg-effect__masking { + display: flex; + flex-direction: column; + gap: 8px; + padding-left: 8px; + border-left: 2px solid var(--vp-border); +} + +.seg-effect__row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.seg-effect__dropdown-wrap { + position: relative; +} + +.seg-effect__dropdown-trigger { + background: var(--vp-panel-strong); + border: 1px solid var(--vp-border); + border-radius: 6px; + color: var(--vp-text); + padding: 5px 10px; + font-size: 12px; + cursor: pointer; +} + +.seg-effect__dropdown-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 20; + background: #16181d; + border: 1px solid var(--vp-border); + border-radius: 8px; + padding: 4px; + min-width: 140px; +} + +.seg-effect__dropdown-item { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: none; + color: var(--vp-text); + padding: 6px 8px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; +} + +.seg-effect__dropdown-item:hover { + background: var(--vp-hover); +} + +.seg-effect__dropdown-item.is-active { + background: #fff; + color: #08090b; +} + +.seg-effect__seg-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.seg-effect__seg-row { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 6px; + border-radius: 6px; + cursor: pointer; +} + +.seg-effect__seg-row:hover { + background: var(--vp-hover); +} + +.seg-effect__seg-swatch { + width: 14px; + height: 14px; + border-radius: 3px; +} + +.seg-effect__seg-name { + flex: 1; + font-size: 12.5px; + color: var(--vp-text); +} + +.seg-effect__apply-spinner-wrap { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.seg-effect__apply-spinner { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid rgba(8, 9, 11, 0.25); + border-top-color: #08090b; + animation: seg-effect-spin 0.7s linear infinite; +} + +@keyframes seg-effect-render-pulse { + + 0%, + 100% { + transform: scale(0.7); + opacity: 0.5; + } + + 50% { + transform: scale(1); + opacity: 1; + } +} + +.seg-effect__hint { + font-size: 11px; + color: var(--vp-text-faint); +} + +.seg-effect__error { + display: flex; + align-items: center; + gap: 6px; + font-size: 11.5px; + line-height: 1.4; + color: #fda4af; + background: rgba(244, 63, 94, 0.1); + border: 1px solid rgba(244, 63, 94, 0.3); + border-radius: 7px; + padding: 6px 8px; + animation: seg-effect-error-in 0.12s ease-out; +} + +@keyframes seg-effect-error-in { + from { + opacity: 0; + transform: translateY(-2px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.seg-effect__number { + width: 56px; + background: var(--vp-panel-strong); + border: 1px solid var(--vp-border); + border-radius: 6px; + color: var(--vp-text); + padding: 5px 6px; + font-size: 12.5px; + font-family: "Space Grotesk", system-ui, sans-serif; + box-sizing: border-box; + line-height: 1.2; +} + +.seg-effect__number::-webkit-inner-spin-button, +.seg-effect__number::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.seg-effect__number:focus { + outline: none; + border-color: #6ea8fe; + background: rgba(110, 168, 254, 0.08); +} + +.seg-effect__number--compact { + width: 50px; + flex-shrink: 0; +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/SegmentsPopup.css b/PanTS-Demo/src/components/segmentation/SegmentsPopup.css new file mode 100644 index 0000000..85513d5 --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/SegmentsPopup.css @@ -0,0 +1,510 @@ +/* SegmentsPopup.css */ +.segpop { + position: fixed; + z-index: 45; + width: 320px; + max-height: 60vh; + display: flex; + flex-direction: column-reverse; + /* header at bottom, body grows upward above it */ + border-radius: 14px; + background: rgba(14, 15, 18, 0.94); + backdrop-filter: blur(18px) saturate(150%); + -webkit-backdrop-filter: blur(18px) saturate(150%); + border: 1px solid rgba(255, 255, 255, 0.09); + box-shadow: 0 18px 44px -14px rgba(0, 0, 0, 0.75); + font-family: "Space Grotesk", system-ui, sans-serif; + overflow: hidden; +} + +.segpop__resize-handle { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 8px; + cursor: ew-resize; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + touch-action: none; +} + +.segpop__resize-handle:hover .segpop__resize-grip, +.segpop__resize-handle:active .segpop__resize-grip { + background: rgba(110, 168, 254, 0.55); +} + +.segpop__resize-grip { + width: 3px; + height: 32px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.18); + transition: background 0.12s; +} + +.segpop--min { + max-height: 46px; +} + +.segpop__head--min { + height: 100%; + padding: 0 14px; + border-bottom: none; +} + +.segpop__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-top: 1px solid rgba(255, 255, 255, 0.09); + flex-shrink: 0; +} + +.segpop__title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + font-weight: 700; + color: #fff; +} + +.segpop__min-btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; +} + +.segpop__body { + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 3px; +} + +.segpop__empty { + font-size: 11.5px; + color: rgba(255, 255, 255, 0.4); + padding: 10px 6px; +} + +.segpop__row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 8px; + cursor: pointer; + position: relative; +} + +.segpop__row:hover { + background: rgba(255, 255, 255, 0.06); +} + +.segpop__row.is-active { + background: rgba(110, 168, 254, 0.14); +} + +.segpop__vis { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + display: flex; +} + +/* The single color swatch shown while editing. Browsers pad native + type=color inputs and render an inner swatch inside that padding, which + can look like two nested squares — strip the padding/appearance so this + is one flush chip, matching the static .segpop__swatch it replaces. */ +.segpop__color { + width: 22px; + height: 22px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 5px; + background: transparent; + cursor: pointer; + flex-shrink: 0; + -webkit-appearance: none; + appearance: none; + overflow: hidden; +} + +.segpop__color::-webkit-color-swatch-wrapper { + padding: 0; +} + +.segpop__color::-webkit-color-swatch { + border: none; + border-radius: 4px; +} + +.segpop__color::-moz-color-swatch { + border: none; + border-radius: 4px; +} + +/* Static color chip shown in the normal (non-editing) row — color is only + changed together with the name via the pen-icon editor, so this isn't an + and doesn't open a picker on click. */ +.segpop__swatch { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.2); + flex-shrink: 0; +} + +/* Shared active/targeting indicator — icon only, used identically in the + Existing-organ and Custom tabs so both read the same way and cost the + row as little width as possible, leaving more room for the name. */ +.segpop__target-badge { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 18px; + height: 18px; + border-radius: 50%; + color: #6ea8fe; + background: rgba(110, 168, 254, 0.16); +} + +.segpop__row.is-deleting { + opacity: 0.55; + cursor: default; +} + +.segpop__spin { + animation: segpop-spin 0.8s linear infinite; +} + +@keyframes segpop-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.segpop__edit-btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + display: flex; + flex-shrink: 0; +} + +.segpop__edit-btn:hover { + color: #6ea8fe; +} + +.segpop__row--editing { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + position: relative; + flex-wrap: wrap; +} + +.segpop__edit-confirm { + background: #22c55e; + border: none; + color: #08090b; + border-radius: 6px; + height: 22px; + padding: 0 8px; + display: flex; + align-items: center; + gap: 4px; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + font-size: 11px; + font-weight: 700; +} + +.segpop__edit-cancel { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + display: flex; + flex-shrink: 0; +} + +.segpop__name { + flex: 1; + min-width: 0; + /* required for flex children to actually shrink/ellipsize + instead of forcing the row wider than the panel */ + font-size: 12.5px; + color: #fff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.segpop__name-input { + flex: 1; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 5px; + color: #fff; + padding: 3px 6px; + font-size: 12.5px; +} + +.segpop__name-input.is-error { + border-color: #f43f5e; +} + +.segpop__delete { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.35); + cursor: pointer; + display: flex; +} + +.segpop__delete:hover { + color: #f43f5e; +} + +.segpop__err { + position: absolute; + right: 8px; + bottom: -2px; + font-size: 9.5px; + color: #fda4af; +} + +.segpop__err--block { + position: static; + display: block; + margin-top: 4px; +} + +.segpop__new { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px; + border-radius: 8px; + border: 1px dashed rgba(255, 255, 255, 0.18); + background: transparent; + color: rgba(255, 255, 255, 0.7); + font-size: 12px; + cursor: pointer; + margin-top: 4px; +} + +.segpop__new:hover { + color: #fff; + border-color: rgba(110, 168, 254, 0.45); +} + +.segpop__add-form { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 8px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + margin-top: 4px; +} + +.segpop__add-confirm { + background: #fff; + color: #08090b; + border: none; + border-radius: 6px; + padding: 5px 10px; + font-size: 11.5px; + font-weight: 700; + cursor: pointer; +} + +.segpop__add-cancel { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + display: flex; +} + +.segpop__catalog { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 6px 8px; + margin-bottom: 4px; + border-bottom: 1px solid rgba(255, 255, 255, 0.09); +} + +.segpop__catalog-label { + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(255, 255, 255, 0.5); +} + +.segpop__catalog-select { + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 6px; + color: #fff; + padding: 6px 8px; + font-size: 12.5px; +} + +/* Tab toggle between "existing organ" and "custom class" modes */ +.segpop__tabs { + display: flex; + gap: 4px; + padding: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.09); +} + + +.segpop__tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 7px 8px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.09); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.6); + font-size: 11.5px; + font-weight: 600; + cursor: pointer; + position: relative; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} + +.segpop__tab:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.85); +} + +.segpop__tab.is-active { + background: #fff; + color: #08090b; + border-color: #fff; +} + +.segpop__tab-dot { + position: absolute; + top: 5px; + right: 6px; + width: 6px; + height: 6px; + border-radius: 50%; + background: #22d3ee; +} + +.segpop__tab.is-active .segpop__tab-dot { + background: #08090b; +} + +.segpop__hint { + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + padding: 8px 8px 4px; + line-height: 1.4; +} + +.segpop__catalog-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 320px; + overflow-y: auto; +} + +.segpop__catalog-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.85); + font-size: 12.5px; + text-align: left; + cursor: pointer; +} + +.segpop__catalog-row:hover { + background: rgba(255, 255, 255, 0.06); +} + +.segpop__catalog-row.is-active { + background: rgba(110, 168, 254, 0.16); + color: #fff; +} + +.segpop__catalog-row-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.segpop__char-count { + color: rgba(255, 255, 255, 0.4); + font-size: 9.5px; +} + +a.segpop { + position: fixed; + z-index: 45; + width: 240px; + max-height: 60vh; + display: flex; + flex-direction: column-reverse; + /* header at bottom, body grows upward above it */ + border-radius: 14px; + background: rgba(14, 15, 18, 0.94); + backdrop-filter: blur(18px) saturate(150%); + -webkit-backdrop-filter: blur(18px) saturate(150%); + border: 1px solid rgba(255, 255, 255, 0.09); + box-shadow: 0 18px 44px -14px rgba(0, 0, 0, 0.75); + font-family: "Space Grotesk", system-ui, sans-serif; + overflow: hidden; +} + +.segpop--min { + height: 46px; + max-height: 46px; +} + + + +.segpop__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.09); + flex-shrink: 0; +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/SegmentsPopup.tsx b/PanTS-Demo/src/components/segmentation/SegmentsPopup.tsx new file mode 100644 index 0000000..28621ea --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/SegmentsPopup.tsx @@ -0,0 +1,537 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { + IconEye, IconEyeOff, IconTrash, IconPlus, IconX, IconStack2, IconSparkles, + IconMinus, IconChevronsUp, IconGripVertical, IconPencil, + IconLoader2, IconTargetArrow, IconDeviceFloppy, +} from "@tabler/icons-react"; +import type { CheckBoxData } from "../../types"; +import "./SegmentsPopup.css"; +import { useDraggablePanel } from "../../helpers/viewer/useDraggablePanel"; +import { ANNOTATION_DOCK_WIDTH } from "../viewer/AnnotationToolbar"; +import ToolWalkthrough, { WalkthroughLauncherButton } from "../walkthrough/ToolWalkthrough"; +import { buildCustomClassSteps, buildExistingOrganSteps } from "../walkthrough/WalkthroughContent"; + +interface SegmentsPopupProps { + /** Mirrors AnnotationToolbar's own `open` prop: the component stays + * mounted (and its drag/resize state alive) at all times — closing just + * renders null after the hooks have run — so a dragged position isn't + * lost every time the panel is toggled off and back on. */ + open: boolean; + segments: CheckBoxData[]; + colors: Record; + visibility: Record; + activeSegmentId: number | null; + onSelect: (id: number) => void; + onRename: (id: number, name: string) => boolean; + onColorChange: (id: number, hex: string) => void; + onToggleVisibility: (id: number) => void; + onDelete: (id: number) => void; + onCreate: (name: string, colorHex: string) => CheckBoxData | null; + organCatalog: { id: number; label: string }[]; + activeCatalogOrganId: number | null; + onSelectCatalogOrgan: (id: number | null) => void; + + /** Refs the parent (VisualizationPage) attaches this component's outer + * panel and drag-header to, so AnnotationToolbar's Overview walkthrough + * can spotlight this popup even though it lives outside that component. + * Both are safe to pass through even while minimized — only one of the + * two header elements is ever mounted at a time, and this same ref + * object is attached to whichever one is currently rendered. Same idea + * for minButtonRef: it's attached to whichever minimize/expand button is + * currently rendered (expanded header's "Minimize" or the minimized + * bar's "Expand"), so the Overview walkthrough can spotlight it too. */ + containerRef?: React.RefObject; + dragHandleRef?: React.RefObject; + minButtonRef?: React.RefObject; +} + +const NEXT_COLOR_POOL = ["#f43f5e", "#eab308", "#22c55e", "#6ea8fe", "#a855f7", "#22d3ee", "#f97316", "#ec4899"]; + +// Applies to both the "add segment" and "rename" name fields. +const MAX_SEGMENT_NAME_LENGTH = 40; + +type PopupTab = "existing" | "custom"; + +// 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 popup's right edge and the dock's left edge so +// they never touch even at the default position — see ANNOTATION_DOCK_WIDTH. +// DOCK_GAP is the breathing room against the dock itself; EXTRA_LEFT_OFFSET +// pushes the default position further left still, since the dock's icon +// tooltips/flyouts open leftward and would otherwise overlap the popup. +const DOCK_GAP = 16; +const EXTRA_LEFT_OFFSET = 48; +const DOCK_CLEARANCE = ANNOTATION_DOCK_WIDTH + DOCK_GAP + EXTRA_LEFT_OFFSET; +const POPUP_WIDTH = 320; +const POPUP_MIN_WIDTH = 240; +const POPUP_MAX_WIDTH = 560; + +/** + * Segments popup — draggable, anchored at its bottom edge so it always grows + * upward (dragging it near the bottom of the screen never hides it). Renders + * expanded by default, positioned just left of the vertical tool dock. + * Minimizable to a small centered horizontal bar. + */ +export default function SegmentsPopup({ + open, segments, colors, visibility, activeSegmentId, + onSelect, onRename, onColorChange, onToggleVisibility, onDelete, onCreate, + organCatalog, activeCatalogOrganId, onSelectCatalogOrgan, + containerRef, dragHandleRef, minButtonRef, +}: SegmentsPopupProps) { + const panel = useDraggablePanel({ + initial: { + // Same viewport metric (clientWidth, not innerWidth) the dock itself + // uses for its flush-right default — mixing the two here previously + // meant the two "flush to the right edge" numbers could disagree by + // however wide the scrollbar happened to be. + x: typeof document !== "undefined" ? document.documentElement.clientWidth - DOCK_CLEARANCE - POPUP_WIDTH : 24, + y: typeof window !== "undefined" ? window.innerHeight - 20 : 24, + }, + expandedSize: { width: POPUP_WIDTH, height: 360 }, + minimizedSize: { width: POPUP_WIDTH, height: 46 }, + anchorBottom: true, + }); + const minimized = panel.minimized; + const setMinimized = panel.setMinimized; + + // Horizontal resize, independent of useDraggablePanel's own drag-to-move + // handling. The handle sits on the LEFT edge (the popup is anchored near + // the right-hand tool dock) so growing it extends leftward, away from the + // dock, while the right edge — panel.pos.x + width — stays put. + const [width, setWidth] = useState(POPUP_WIDTH); + const resizeStateRef = useRef<{ startX: number; startWidth: number } | null>(null); + + useEffect(() => { + const onMove = (e: PointerEvent) => { + const st = resizeStateRef.current; + if (!st) return; + const delta = st.startX - e.clientX; // dragging left = positive delta = wider + const next = Math.min(POPUP_MAX_WIDTH, Math.max(POPUP_MIN_WIDTH, st.startWidth + delta)); + setWidth(next); + }; + const onUp = () => { resizeStateRef.current = null; }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + return () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + }, []); + + const startResize = (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + resizeStateRef.current = { startX: e.clientX, startWidth: width }; + }; + + const [tab, setTab] = useState(activeCatalogOrganId != null ? "existing" : "custom"); + + const [adding, setAdding] = useState(false); + const [draftName, setDraftName] = useState(""); + const [draftColor, setDraftColor] = useState(NEXT_COLOR_POOL[segments.length % NEXT_COLOR_POOL.length]); + const [createError, setCreateError] = useState(""); + + // 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). + const [editingId, setEditingId] = useState(null); + const [editNameDraft, setEditNameDraft] = useState(""); + const [editColorDraft, setEditColorDraft] = useState("#ffffff"); + const [renameError, setRenameError] = useState(null); + + // Deletion can take a moment on the backend — track in-flight deletes + // locally so the row can show a spinner instead of looking unresponsive. + // Cleared automatically once the segment actually disappears from props. + const [deletingIds, setDeletingIds] = useState>(new Set()); + useEffect(() => { + setDeletingIds((prev) => { + if (prev.size === 0) return prev; + const stillPresent = new Set(segments.map((s) => s.id)); + const next = new Set([...prev].filter((id) => stillPresent.has(id))); + return next.size === prev.size ? prev : next; + }); + }, [segments]); + + const handleDelete = (id: number) => { + setDeletingIds((prev) => new Set(prev).add(id)); + onDelete(id); + }; + + const switchTab = (next: PopupTab) => { + setTab(next); + setAdding(false); + setCreateError(""); + setEditingId(null); + }; + + const startAdd = () => { + setAdding(true); + setDraftName(""); + setCreateError(""); + setDraftColor(NEXT_COLOR_POOL[segments.length % NEXT_COLOR_POOL.length]); + }; + + const commitAdd = () => { + const trimmed = draftName.trim(); + if (!trimmed) { setCreateError("Enter a name."); return; } + const lower = trimmed.toLowerCase(); + const dupCustom = segments.some((s) => s.label.toLowerCase() === lower); + const dupCatalog = organCatalog.some((o) => o.label.toLowerCase() === lower); + if (dupCustom || dupCatalog) { + setCreateError( + dupCatalog + ? "That name matches an existing organ — pick it from the Existing tab instead." + : "That name is already used." + ); + return; + } + const created = onCreate(trimmed, draftColor); + if (!created) { setCreateError("Could not create segment."); return; } + setAdding(false); + setDraftName(""); + setCreateError(""); + }; + + const startEdit = (id: number, currentName: string, currentColor: string) => { + setEditingId(id); + setEditNameDraft(currentName); + setEditColorDraft(currentColor); + setRenameError(null); + }; + const cancelEdit = () => { + setEditingId(null); + setRenameError(null); + }; + const commitEdit = (id: number) => { + const trimmed = editNameDraft.trim(); + if (!trimmed) { cancelEdit(); return; } + const lower = trimmed.toLowerCase(); + const dupCustom = segments.some((s) => s.id !== id && s.label.toLowerCase() === lower); + const dupCatalog = organCatalog.some((o) => o.label.toLowerCase() === lower); + if (dupCustom || dupCatalog) { setRenameError(id); return; } + const renamed = onRename(id, trimmed); + if (!renamed) { setRenameError(id); return; } + onColorChange(id, editColorDraft); + setEditingId(null); + setRenameError(null); + }; + + const handleSelectExisting = (id: number) => { + onSelectCatalogOrgan(id === activeCatalogOrganId ? null : id); + }; + + const isCustomActive = (id: number) => activeSegmentId === id && activeCatalogOrganId == null; + + // --- Walkthroughs --------------------------------------------------------- + // Two replayable walkthroughs live in this popup, one per tab — Custom + // class (creating/managing a custom class) and Existing organ (picking an + // already-segmented catalog organ). Each has its own launcher button in + // the header (identical WalkthroughLauncherButton used everywhere else) + // and is built from the matching buildXSteps content. Both share the same + // measure-on-interval effect below since they can point at overlapping UI + // (the tab bar, the body list) and only one is ever open at a time. + const [customWalkthroughOpen, setCustomWalkthroughOpen] = useState(false); + const [existingWalkthroughOpen, setExistingWalkthroughOpen] = useState(false); + const addClassRef = useRef(null); + const tableRef = useRef(null); + const tabsRef = useRef(null); + const catalogListRef = useRef(null); + const [addClassRect, setAddClassRect] = useState(null); + const [tableRect, setTableRect] = useState(null); + const [tabsRect, setTabsRect] = useState(null); + const [catalogListRect, setCatalogListRect] = useState(null); + + const anyWalkthroughOpen = customWalkthroughOpen || existingWalkthroughOpen; + + useLayoutEffect(() => { + if (!anyWalkthroughOpen) return; + const measure = () => { + setAddClassRect(addClassRef.current ? addClassRef.current.getBoundingClientRect() : null); + setTableRect(tableRef.current ? tableRef.current.getBoundingClientRect() : null); + setTabsRect(tabsRef.current ? tabsRef.current.getBoundingClientRect() : null); + setCatalogListRect(catalogListRef.current ? catalogListRef.current.getBoundingClientRect() : (tableRef.current ? tableRef.current.getBoundingClientRect() : null)); + }; + measure(); + window.addEventListener("resize", measure); + const id = window.setInterval(measure, 200); + return () => { + window.removeEventListener("resize", measure); + window.clearInterval(id); + }; + }, [anyWalkthroughOpen, panel.pos.x, panel.pos.y, minimized, tab]); + + // All hooks above have run unconditionally on every render — bailing out + // here (rather than gating the component's mount/unmount from the parent) + // is what keeps drag position, resize width, editing state, etc. alive + // across the popup being shown and hidden. + if (!open) return null; + + return ( +
+ {minimized ? ( + // Minimized: single row, everything centered — no column-reverse + // ordering games needed since there's only one row. +
+ + + {tab === "existing" ? "Existing organ" : `Custom classes${segments.length > 0 ? ` (${segments.length})` : ""}`} + + +
+ ) : ( + <> + {/* Resize handle: drag left to widen, right to narrow. Absolutely + positioned so it doesn't interfere with the column-reverse flex + ordering of the header/tabs/body below it. */} +
+ +
+ + {/* DOM order: body, tabs, header — column-reverse renders the LAST + child at the TOP, so visual order top-to-bottom is header, tabs, body. + The wrapper's `bottom` style is what stays fixed while dragging; + everything above grows upward from it. */} +
+ {tab === "existing" ? ( + <> +
Pick an organ already segmented in this scan to edit it directly.
+ {organCatalog.length === 0 ? ( +
No organs detected in this case.
+ ) : ( +
+ {organCatalog.map((o) => ( + + ))} +
+ )} + + ) : ( + <> + {segments.length === 0 && !adding && ( +
No custom segments yet — add one below.
+ )} + + {segments.map((s) => { + const active = isCustomActive(s.id); + const hex = colors[s.id] ?? "#ffffff"; + const isEditing = editingId === s.id; + + if (isEditing) { + const remaining = MAX_SEGMENT_NAME_LENGTH - editNameDraft.length; + return ( +
e.stopPropagation()}> + {/* Single swatch: the native color input IS the swatch — clicking it + opens the OS color picker and updates this same square, so there's + only ever one color chip visible while editing. */} + setEditColorDraft(e.target.value)} + aria-label="Segment color" + title="Click to change color" + /> + { setEditNameDraft(e.target.value); setRenameError(null); }} + onKeyDown={(e) => { + if (e.key === "Enter") commitEdit(s.id); + if (e.key === "Escape") cancelEdit(); + }} + /> + + + {renameError === s.id && Name in use} + {renameError !== s.id && remaining <= 10 && ( + {remaining} characters left + )} +
+ ); + } + + const isDeleting = deletingIds.has(s.id); + return ( +
{ if (!isDeleting) { onSelect(s.id); onSelectCatalogOrgan(null); } }} + > + +
+ ); + })} + + {adding ? ( +
+ setDraftColor(e.target.value)} /> + { setDraftName(e.target.value); setCreateError(""); }} + onKeyDown={(e) => { + if (e.key === "Enter") commitAdd(); + if (e.key === "Escape") setAdding(false); + }} + /> + + + {createError && {createError}} +
+ ) : ( +
+ +
+ )} + + )} +
+ +
e.stopPropagation()}> + + +
+ +
+ + + {tab === "existing" ? "Existing organ" : `Custom classes${segments.length > 0 ? ` (${segments.length})` : ""}`} + + + {tab === "custom" && ( + { e.stopPropagation(); setCustomWalkthroughOpen(true); }} + /> + )} + {tab === "existing" && ( + { e.stopPropagation(); setExistingWalkthroughOpen(true); }} + /> + )} + + +
+ + )} + + setCustomWalkthroughOpen(false)} + steps={buildCustomClassSteps({ addClassRect, tableRect })} + /> + + setExistingWalkthroughOpen(false)} + steps={buildExistingOrganSteps({ listRect: catalogListRect, tabsRect })} + /> +
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/SegmentsTable.tsx b/PanTS-Demo/src/components/segmentation/SegmentsTable.tsx new file mode 100644 index 0000000..f8ed642 --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/SegmentsTable.tsx @@ -0,0 +1,100 @@ +import { useState } from "react"; +import { IconEye, IconEyeOff, IconTrash } from "@tabler/icons-react"; +import type { CheckBoxData } from "../../types"; + +interface SegmentsTableProps { + segments: CheckBoxData[]; + colors: Record; // hex + visibility: Record; + activeSegmentId: number; + onSelect: (id: number) => void; + onRename: (id: number, name: string) => boolean; // returns false if rejected (dup) + onColorChange: (id: number, hex: string) => void; + onToggleVisibility: (id: number) => void; + onDelete: (id: number) => void; +} + +export default function SegmentsTable({ + segments, colors, visibility, activeSegmentId, + onSelect, onRename, onColorChange, onToggleVisibility, onDelete, +}: SegmentsTableProps) { + const [editingId, setEditingId] = useState(null); + const [draft, setDraft] = useState(""); + const [error, setError] = useState(null); + + const startEdit = (id: number, current: string) => { + setEditingId(id); + setDraft(current); + setError(null); + }; + + const commitEdit = (id: number) => { + const trimmed = draft.trim(); + if (!trimmed) { setEditingId(null); return; } + const dup = segments.some((s) => s.id !== id && s.label.toLowerCase() === trimmed.toLowerCase()); + if (dup) { setError(id); return; } + const ok = onRename(id, trimmed); + if (ok) { setEditingId(null); setError(null); } + else setError(id); + }; + + return ( +
+
+ Name +
+ {segments.map((s) => ( +
onSelect(s.id)} + > + + e.stopPropagation()} + onChange={(e) => onColorChange(s.id, e.target.value)} + /> + {editingId === s.id ? ( + e.stopPropagation()} + onChange={(e) => { setDraft(e.target.value); setError(null); }} + onBlur={() => commitEdit(s.id)} + onKeyDown={(e) => { + if (e.key === "Enter") commitEdit(s.id); + if (e.key === "Escape") { setEditingId(null); setError(null); } + }} + /> + ) : ( + { e.stopPropagation(); startEdit(s.id, s.label); }} + title="Double-click to rename" + > + {s.label} + + )} + + {error === s.id && Name already used} +
+ ))} +
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/SliceAnchorPickerUI.tsx b/PanTS-Demo/src/components/segmentation/SliceAnchorPickerUI.tsx new file mode 100644 index 0000000..ac573e7 --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/SliceAnchorPickerUI.tsx @@ -0,0 +1,194 @@ +// Small shared visual pieces for the guided click-to-pick slice flow. +// Flow: press the single green "Start" button → step 1's label highlights +// blue (plain text, not a button) → click on the canvas → it's replaced by +// a done chip and step 2's label highlights → click again → ready to apply. +// "Start over" clears everything back to the initial Start button. +import type { SliceAnchor } from "../../helpers/viewer/useSliceAnchorPicker"; + +const PANE_LABEL: Record = { axial: "Axial", sagittal: "Sagittal", coronal: "Coronal" }; + +// "Axial - Slice 42" — consistent everywhere an anchor is shown. +export function formatAnchor(anchor: SliceAnchor): string { + return `${PANE_LABEL[anchor.pane] ?? anchor.pane} - Slice ${anchor.sliceIndex + 1}`; +} + +export function TargetIcon({ size = 16 }: { size?: number }) { + return ( + + ); +} + +export function PlayIcon({ size = 14 }: { size?: number }) { + return ( + + ); +} + +export function CheckIcon({ size = 14 }: { size?: number }) { + return ( + + ); +} + +export function RefreshIcon({ size = 13 }: { size?: number }) { + return ( + + ); +} + +export function ArrowIcon({ size = 16 }: { size?: number }) { + return ( + + ); +} + +// The single entry point into the guided flow. Green, unmissable. +export function StartButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +// One row of the 2-step wizard. Before it's picked: plain text (NOT a +// button — nothing to click here, the instruction is "click on the image"), +// highlighted blue only while it's the step currently needed. After it's +// picked: a small done chip with the resolved pane/slice. +export function StepLabel({ stepNumber, anchor, highlighted }: { stepNumber: 1 | 2; anchor: SliceAnchor | null; highlighted: boolean }) { + if (anchor) { + return ( +
+ + + + + {formatAnchor(anchor)} + +
+ ); + } + return ( +
+ {highlighted && ( + + + + )} + {stepNumber === 1 ? "Click first slice" : "Click last slice"} +
+ ); +} + +// Fixed banner shown while a pick is armed, so the instruction travels with +// the user's eyes instead of sitting back in the flyout panel they've left. +export function PickingBanner({ label, onCancel }: { label: string; onCancel: () => void }) { + return ( +
+ + + + {label} + +
+ ); +} + +// Single, unambiguous way to clear both picked anchors and go all the way +// back to the initial Start button. +export function StartOverButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/segmentation/SmoothingFlyout.tsx b/PanTS-Demo/src/components/segmentation/SmoothingFlyout.tsx new file mode 100644 index 0000000..2d30a1e --- /dev/null +++ b/PanTS-Demo/src/components/segmentation/SmoothingFlyout.tsx @@ -0,0 +1,34 @@ +import { useState } from "react"; +import ApplyButton from "../ApplyButton"; +import NumberSliderField from "../NumberSliderField"; +import type { SmoothingMethod } from "../../helpers/CornerstoneNifti2"; + +interface Props { + onApply: (method: SmoothingMethod, kernelMm: number) => void; +} + +const MAX_KERNEL_MM = 3; +const MIN_KERNEL_MM = 0.5; + +export default function SmoothingFlyout({ onApply }: Props) { + const [kernelMm, setKernelMm] = useState(3); + + return ( +
+
Make segment boundaries smoother.
+ + + + onApply("median", kernelMm)} /> +
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/viewer/AnnotationToolbar.css b/PanTS-Demo/src/components/viewer/AnnotationToolbar.css new file mode 100644 index 0000000..532b074 --- /dev/null +++ b/PanTS-Demo/src/components/viewer/AnnotationToolbar.css @@ -0,0 +1,413 @@ +/* AnnotationToolbar.css */ +.atb { + position: fixed; + left: 50%; + bottom: 22px; + transform: translateX(-50%); + z-index: 50; + display: flex; + align-items: center; + gap: 4px; + padding: 6px; + border-radius: 18px; + background: rgba(14, 15, 18, 0.88); + backdrop-filter: blur(20px) saturate(150%); + -webkit-backdrop-filter: blur(20px) saturate(150%); + border: 1px solid rgba(255, 255, 255, 0.09); + box-shadow: 0 18px 44px -14px rgba(0, 0, 0, 0.75); + font-family: "Space Grotesk", system-ui, sans-serif; +} + + +.atb__btn { + position: relative; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.85); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.atb__btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +.atb__btn.is-active { + background: #ffffff; + color: #08090b; +} + + + +.atb__tooltip { + position: absolute; + bottom: calc(100% + 10px); + left: 50%; + transform: translateX(-50%) translateY(4px); + display: flex; + flex-direction: column; + gap: 2px; + min-width: 160px; + max-width: 220px; + padding: 8px 10px; + border-radius: 9px; + background: #16181d; + border: 1px solid rgba(255, 255, 255, 0.09); + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); + opacity: 0; + pointer-events: none; + transition: opacity 0.12s, transform 0.12s; + z-index: 60; +} + +.atb__tooltip strong { + font-size: 12px; + color: #fff; +} + +.atb__tooltip span:last-child { + font-size: 11px; + line-height: 1.4; + color: rgba(255, 255, 255, 0.6); +} + +.atb__btn:hover .atb__tooltip { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + + + +.atb-flyout__section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.atb-flyout__label { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.5); + display: flex; + justify-content: space-between; +} + +.atb-flyout__val { + color: rgba(255, 255, 255, 0.85); + text-transform: none; + letter-spacing: 0; +} + +.atb-flyout__range { + width: 220px; +} + +.atb-flyout__grid { + display: grid; + grid-template-columns: repeat(3, minmax(110px, 1fr)); + gap: 14px; + min-width: 380px; +} + +.atb-flyout__col { + display: flex; + flex-direction: column; + gap: 6px; +} + +.atb-flyout__radio { + display: flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + color: rgba(255, 255, 255, 0.85); + cursor: pointer; +} + +.atb-flyout__checkbox { + display: flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + color: rgba(255, 255, 255, 0.85); + cursor: pointer; +} + +.atb-flyout__span-all { + grid-column: 1 / -1; + margin-top: 4px; +} + +.atb-flyout__mm-input { + width: 90px; + margin-top: 4px; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 6px; + color: #fff; + padding: 4px 6px; + font-size: 12px; +} + +/* Vertical dock — plain row of icon buttons, no caret. Height-capped and + internally scrollable so it never overflows the viewport. */ +.atb--vertical { + /* position/top/left/right/bottom now set inline via dockPanel.pos — remove them here */ + flex-direction: column; + gap: 4px; + padding: 8px 6px; + border-radius: 18px; + max-height: calc(100vh - 48px); + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; +} + +.atb--vertical::-webkit-scrollbar { + display: none; +} + +.atb--vertical .atb__btn { + width: 55px; + height: 50px; + border-radius: 5px; +} + +.atb__tooltip--left { + bottom: auto; + top: 50%; + left: auto; + right: calc(100% + 12px); + transform: translateY(-50%) translateX(4px); +} + +.atb--vertical .atb__btn:hover .atb__tooltip--left { + opacity: 1; + transform: translateY(-50%) translateX(0); +} + +.atb--disabled .atb__btn { + opacity: 0.35; + cursor: not-allowed; +} + +/* Top-left corner panel — opens when a tool is selected, replaces the old + caret-triggered flyout entirely. Minimizable via its own header button. */ +.atb-corner-panel { + position: fixed; + top: 18px; + left: 18px; + z-index: 80; + width: 300px; + max-height: calc(100vh - 36px); + display: flex; + flex-direction: column; + border-radius: 14px; + background: #16181d; + border: 1px solid rgba(255, 255, 255, 0.09); + box-shadow: 0 18px 44px -14px rgba(0, 0, 0, 0.75); + font-family: "Space Grotesk", system-ui, sans-serif; + color: #fff; + overflow: hidden; + --vp-text: #ffffff; + --vp-text-dim: rgba(255, 255, 255, 0.7); + --vp-text-faint: rgba(255, 255, 255, 0.5); + --vp-border: rgba(255, 255, 255, 0.09); + --vp-panel-strong: rgba(255, 255, 255, 0.07); + --vp-hover: rgba(255, 255, 255, 0.08); +} + +.atb-corner-panel .seg-effect { + padding: 0; + border-top: none; + gap: 10px; +} + +.atb-corner-panel.is-minimized { + max-height: 46px; +} + +.atb-corner-panel__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.09); + flex-shrink: 0; +} + +.atb-corner-panel__title { + font-size: 12.5px; + font-weight: 700; + color: #fff; +} + +.atb-corner-panel__actions { + display: flex; + align-items: center; + gap: 4px; +} + +.atb-corner-panel__icon-btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + display: flex; + padding: 2px; + border-radius: 4px; +} + +.atb-corner-panel__icon-btn:hover { + color: #fff; + background: rgba(255, 255, 255, 0.08); +} + +.atb-corner-panel__body { + overflow-y: auto; + padding: 12px 14px; +} + + +.atb-flyout__grid--scissors { + min-width: 200px; + grid-template-columns: 1fr; +} + +.atb-flyout__radio svg.atb-scissors-icon { + flex-shrink: 0; +} + +.atb-flyout__hint { + font-size: 11px; + line-height: 1.4; + color: rgba(255, 255, 255, 0.5); + display: flex; + align-items: center; + gap: 5px; + flex-wrap: wrap; +} + +.atb-flyout__hint-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: #f43f5e; + box-shadow: 0 0 0 2px rgba(244, 63, 94, 0.35); +} + +.vp-livewire-close-pulse { + animation: vp-livewire-pulse 0.9s ease-in-out infinite; + transform-origin: center; +} + +@keyframes vp-livewire-pulse { + + 0%, + 100% { + opacity: 0.55; + r: 12; + } + + 50% { + opacity: 0.15; + r: 16; + } +} + +.atb-flyout__draw-actions { + display: flex; + gap: 6px; + margin-top: 2px; +} + +.atb-flyout__draw-btn { + flex: 1; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 7px; + color: rgba(255, 255, 255, 0.85); + font-size: 11.5px; + font-weight: 600; + padding: 7px 8px; + cursor: pointer; +} + +.atb-flyout__draw-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.12); +} + +.atb-flyout__draw-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.atb-flyout__draw-btn--cancel:hover:not(:disabled) { + background: rgba(244, 63, 94, 0.18); + border-color: rgba(244, 63, 94, 0.4); + color: #fda4af; +} + + + +.atb-flyout__target-btn:hover { + background: rgba(255, 255, 255, 0.09); + color: rgba(255, 255, 255, 0.85); +} + +.atb-flyout__target-btn.is-active { + background: #fff; + border-color: #fff; + color: #08090b; +} + +.atb-flyout__target-toggle-wrap { + display: flex; + flex-direction: column; + gap: 4px; +} + +.atb-flyout__target-btn { + gap: 6px; + font-size: 11px; + font-weight: 600; + color: rgba(255, 255, 255, 0.75); +} + +.atb-flyout__target-btn.is-active { + color: #08090b; +} + +.atb-flyout__magnet-toggle { + padding: 6px 8px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.09); +} + +.atb-flyout__magnet-toggle:has(input:checked) { + background: rgba(110, 168, 254, 0.14); + border-color: rgba(110, 168, 254, 0.4); +} + +.atb__min-btn { + margin-top: 4px; + border-top: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 12px 12px 12px 12px; + padding-top: 8px; + color: #6ea8fe; +} + +.atb__min-btn:hover { + color: #9dc2ff; + background: rgba(110, 168, 254, 0.1); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx b/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx new file mode 100644 index 0000000..f5ba6a3 --- /dev/null +++ b/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx @@ -0,0 +1,712 @@ +import { useState, useRef, useCallback, useEffect, useLayoutEffect } from "react"; +import { createPortal } from "react-dom"; +import { + IconBrush, + IconEraser, + IconScissors, + IconRipple, + IconArrowsDiagonal, + IconDroplet, + IconMathFunction, + IconWand, + IconStack2, + IconCopy, + IconWaveSine, + IconGripVertical, + IconMinus, + IconChevronsUp, + IconCircleDashed, +} from "@tabler/icons-react"; +import "./AnnotationToolbar.css"; +import { useDraggablePanel } from "../../helpers/viewer/useDraggablePanel"; +import MaskingSelect, { type MaskingArea } from "../segmentation/MaskingSelect"; +import NumberSliderField from "../NumberSliderField"; +import ToolWalkthrough, { WalkthroughLauncherButton } from "../walkthrough/ToolWalkthrough"; +import { buildToolSteps, buildOverviewSteps, type OverviewRects } from "../walkthrough/WalkthroughContent"; + +// Shared across the whole app: once the overview tour has been shown (either +// automatically on first open, or manually via replay-then-dismiss), it +// won't auto-pop again on future visits — the "Show walkthrough" launcher in +// the dock header remains the way to bring it back up any time. +const OVERVIEW_WALKTHROUGH_SEEN_KEY = "mm_annotation_walkthrough_seen"; + +export type PrimaryEditTool = + | "paint" | "erase" | "scissors" | "levelTracing" + | "margin" | "smoothing" | "islands" | "logicalOperators" + | "growFromSeeds" | "fillBetweenSlices" | "copyAcrossSlices" | "hollow" + | null; +export type ScissorsOperation = "eraseInside" | "eraseOutside" | "fillInside" | "fillOutside"; +export type ScissorsSliceCut = "unlimited" | "positive" | "negative" | "symmetric"; + +export interface ScissorsOptions { + operation: ScissorsOperation; + /** Magnetic edge snap — when on, each placed point (and the live preview + * point) snaps to the nearest strong intensity edge within a small radius, + * like Photoshop's magnetic lasso. Makes it much easier to trace + * high-contrast organ/bone boundaries without hand-placing every point. */ + magnetEnabled?: boolean; +} + +interface AnnotationToolbarProps { + open: boolean; + hasSegments: boolean; + hasActiveTarget: boolean; + activeTool: PrimaryEditTool; + onToolChange: (tool: PrimaryEditTool) => void; + diameterMm: number; + onDiameterChange: (mm: number) => void; + onDiameterPreviewChange?: (active: boolean) => void; + scissorsOptions: ScissorsOptions; + onScissorsOptionsChange: (opts: ScissorsOptions) => void; + scissorsPointCount: number; + onScissorsCancel: () => void; + maskingArea: MaskingArea; + onMaskingAreaChange: (v: MaskingArea) => void; + hasAnySegments: boolean; + scopeLocked?: boolean; + /** True while a paint/erase/scissors edit is actively being committed — + * drives a small pulsing dot in the panel header so lag never reads as + * "nothing happened". */ + isRendering?: boolean; + + renderFlyout: (tool: Exclude) => React.ReactNode; + + popupRef?: React.RefObject; + popupDragRef?: React.RefObject; + popupMinRef?: React.RefObject; + sliceJumpRef?: React.RefObject; + overviewExtraRects?: OverviewRects; + + +} + +const TOOL_DEFS: Array<{ id: Exclude; label: string; Icon: any; description: string }> = [ + { id: "paint", label: "Brush", Icon: IconBrush, description: "Paint with a round brush." }, + { id: "erase", label: "Erase", Icon: IconEraser, description: "Erase with a round brush." }, + { id: "scissors", label: "Scissors", Icon: IconScissors, description: "Cut through the entire segment from the current viewpoint." }, + { id: "levelTracing", label: "Level Tracing", Icon: IconRipple, description: "Trace the boundary of similar intensity around the cursor." }, + { id: "margin", label: "Margin", Icon: IconArrowsDiagonal, description: "Grow or shrink selected segment by specified margin size." }, + { id: "smoothing", label: "Smoothing", Icon: IconWaveSine, description: "Make segment boundaries smoother." }, + { id: "islands", label: "Islands", Icon: IconDroplet, description: "Edit islands (connected components) in a segment." }, + { id: "logicalOperators", label: "Logical operators", Icon: IconMathFunction, description: "Apply logical operators or combine segments." }, + { id: "growFromSeeds", label: "Grow from seeds", Icon: IconWand, description: "Grow segments from user-placed seed scribbles." }, + { id: "fillBetweenSlices", label: "Fill between slices", Icon: IconStack2, description: "Interpolate segment shape between two annotated slices." }, + { id: "copyAcrossSlices", label: "Copy across slices", Icon: IconCopy, description: "Copy the segment's shape from one slice across a range." }, + { id: "hollow", label: "Hollow", Icon: IconCircleDashed, description: "Make the segment hollow by replacing it with a uniform-thickness shell." }, +]; + +const SCISSORS_OPERATIONS: { value: ScissorsOperation; label: string }[] = [ + { value: "eraseInside", label: "Erase inside" }, + { value: "eraseOutside", label: "Erase outside" }, + { value: "fillInside", label: "Fill inside" }, + { value: "fillOutside", label: "Fill outside" }, +]; + +// Tools that don't have an ApplyButton — they commit directly on pointer +// interaction, so the rendering dot is the only feedback available. +const LIVE_COMMIT_TOOLS: Exclude[] = ["paint", "erase", "scissors", "levelTracing"]; + +const MIN_DIAMETER_MM = 2; +const MAX_DIAMETER_MM = 40; + +// Dock width matches its CSS (.atb--vertical: 6px*2 padding + 55px buttons = +// 67px, rounded to 68). Exported so SegmentsPopup can size its own default +// resting position off the same number instead of duplicating a magic +// constant that could silently drift out of sync. +export const ANNOTATION_DOCK_WIDTH = 68; + +function ScissorsOpIcon({ op }: { op: ScissorsOperation }) { + const fill = op === "fillInside" || op === "fillOutside"; + const inside = op === "eraseInside" || op === "fillInside"; + return ( + + + + {!inside && fill && ( + + )} + + ); +} + +// Magnet icon: a horseshoe magnet with two little "attraction" arcs pulling +// toward the poles — reused as the checkbox glyph and standalone next to the +// "Magnetic edge snap" label so the toggle reads at a glance even before +// hovering for the tooltip. +function MagnetIcon({ active }: { active?: boolean }) { + const stroke = active ? "#08090b" : "#fff"; + return ( + + + + + + + ); +} + +// Drop-in replacement for `DiameterFlyout` in AnnotationToolbar.tsx. +// Same props/behavior (live preview while dragging), now using the shared +// NumberSliderField so the pen/eraser diameter box behaves like every other +// typable field: one number shown once, unit beside it, decimals typable. +// +// Add this import near the top of AnnotationToolbar.tsx: +// import NumberSliderField from "./NumberSliderField"; +// then replace the existing `function DiameterFlyout(...) { ... }` with this. + +function DiameterFlyout({ + title, diameterMm, onDiameterChange, onPreviewChange, fieldRef, +}: { + title: string; + diameterMm: number; + onDiameterChange: (mm: number) => void; + onPreviewChange?: (active: boolean) => void; + /** Wraps the slider field so the walkthrough can spotlight its live rect. */ + fieldRef?: React.RefObject; +}) { + return ( +
+
+ +
+
+ ); +} + +function ScissorsFlyout({ options, onChange, pointCount, onCancel }: { + options: ScissorsOptions; + onChange: (opts: ScissorsOptions) => void; + pointCount: number; + onCancel: () => void; +}) { + const set = (key: K, value: ScissorsOptions[K]) => + onChange({ ...options, [key]: value }); + + return ( +
+
+
+ Operation: + {SCISSORS_OPERATIONS.map((op) => ( + + ))} +
+ +
+ +
+
+ Draw a shape, then click back on the red start point to close it. Ctrl+Z undoes the last point. + {options.magnetEnabled && " The edge hugs nearby boundaries between clicks, like Photoshop's magnetic lasso."} +
+
+
+ ); +} + +// Portal-rendered tooltip — rendered to document.body and positioned via +// getBoundingClientRect of the hovered icon, so it's never clipped by the +// dock's own overflow:hidden/auto rules. +function IconTooltip({ + label, description, anchorRect, +}: { + label: string; + description: string; + anchorRect: DOMRect | null; +}) { + if (!anchorRect) return null; + return createPortal( +
+
{label}
+
{description}
+
, + document.body + ); +} + +// Small pulsing dot — the only feedback for live-commit tools (paint/erase/ +// scissors) since they have no ApplyButton. Lives in the panel header next +// to the tool name so it's visible without adding any body text. +function RenderingIndicator() { + return ( + + + ); +} +export default function AnnotationToolbar({ + open, hasSegments, hasActiveTarget, activeTool, onToolChange, + diameterMm, onDiameterChange, onDiameterPreviewChange, scissorsOptions, onScissorsOptionsChange, + renderFlyout, scissorsPointCount, onScissorsCancel, maskingArea, + onMaskingAreaChange, hasAnySegments, scopeLocked, isRendering, + popupRef, popupDragRef, popupMinRef, sliceJumpRef, overviewExtraRects, +}: AnnotationToolbarProps) { + const [hoveredTool, setHoveredTool] = useState(null); + const [hoveredRect, setHoveredRect] = useState(null); + const [dockMinimized, setDockMinimized] = useState(false); + const iconRefs = useRef>({}); + + // --- Walkthroughs -------------------------------------------------------- + // Every tool gets an identical "Show walkthrough" launcher in its panel + // header (toolWalkthroughOpen). There's also one Overview walkthrough, + // launched from the dock, that tours the dock + corner panel dragging/ + // minimizing (the segments-popup portion is filled in by the caller via + // `overviewExtraRects`, since the popup lives outside this component). + // Reopening either after dismissal — the launcher button never goes away + // — is the entire "replay" mechanism; no separate control is needed. + const [toolWalkthroughOpen, setToolWalkthroughOpen] = useState(false); + const [overviewWalkthroughOpen, setOverviewWalkthroughOpen] = useState(false); + const [fieldRect, setFieldRect] = useState(null); + const [maskingRect, setMaskingRect] = useState(null); + const [panelRect, setPanelRect] = useState(null); + const [panelDragRect, setPanelDragRect] = useState(null); + const [dockRect, setDockRect] = useState(null); + const [dockDragRect, setDockDragRect] = useState(null); + const [dockMinRect, setDockMinRect] = useState(null); + const [panelMinRect, setPanelMinRect] = useState(null); + // Measured from the refs the parent (VisualizationPage) hands in — the + // popup and the slice-jump overlay both live outside this component, so + // their DOM nodes are only reachable via these external refs. + const [popupRectMeasured, setPopupRectMeasured] = useState(null); + const [popupDragRectMeasured, setPopupDragRectMeasured] = useState(null); + const [popupMinRectMeasured, setPopupMinRectMeasured] = useState(null); + const [sliceJumpRect, setSliceJumpRect] = useState(null); + + const fieldRef = useRef(null); + const maskingFieldRef = useRef(null); + const panelBodyRef = useRef(null); + const panelHeadRef = useRef(null); + const panelMinRef = useRef(null); + const dockElRef = useRef(null); + const dockDragRef = useRef(null); + + // Close the tool walkthrough if the person switches tools (or closes the + // panel) while it's up — its targets no longer exist under a new tool. + useEffect(() => { + setToolWalkthroughOpen(false); + }, [activeTool]); + const showTooltip = useCallback((id: string) => { + const el = iconRefs.current[id]; + setHoveredRect(el ? el.getBoundingClientRect() : null); + setHoveredTool(id); + }, []); + const hideTooltip = useCallback((id: string) => { + setHoveredTool((cur) => (cur === id ? null : cur)); + setHoveredRect((cur) => (hoveredTool === id ? null : cur)); + }, [hoveredTool]); + + const panel = useDraggablePanel({ + initial: { x: 24, y: 24 }, + expandedSize: { width: 300, height: 420 }, + minimizedSize: { width: 300, height: 46 }, + marginX: -30, + }); + const dockPanel = useDraggablePanel({ + // Flush against the real right edge on first load. Use clientWidth (not + // innerWidth) since innerWidth includes the scrollbar's own width, which + // otherwise leaves a visible gap between the dock and the page content. + // y is intentionally small so the dock sits near the top of the viewport + // by default, clear of the segments popup that anchors near the bottom. + initial: { x: typeof document !== "undefined" ? document.documentElement.clientWidth - ANNOTATION_DOCK_WIDTH : 24, y: 80 }, + // TOOL_DEFS.length icons + minimize button, each ~56px tall (52px + 4px gap), plus padding + expandedSize: { width: ANNOTATION_DOCK_WIDTH, height: TOOL_DEFS.length * 56 + 60 }, + minimizedSize: { width: ANNOTATION_DOCK_WIDTH, height: 116 }, // 1 icon + minimize button + padding + marginX: 0, + // Only left/right dragging is meaningful for a vertical dock; keep it + // pinned flush to whichever screen edge it's dragged toward, and never + // let vertical dragging place it where its full icon list would be + // clipped off the top or bottom of the viewport. + lockToScreenEdges: true, + }); + + // Recompute every spotlight target whenever either walkthrough is open. + // Runs on every render while open (cheap: a handful of + // getBoundingClientRect calls) so it stays correct across panel drags, + // minimize/expand, and window resizes — none of which have one single + // event to hook reliably given both panels are freely draggable. + const anyWalkthroughOpen = toolWalkthroughOpen || overviewWalkthroughOpen; + useLayoutEffect(() => { + if (!anyWalkthroughOpen) return; + const measure = () => { + setFieldRect(fieldRef.current ? fieldRef.current.getBoundingClientRect() : null); + setMaskingRect(maskingFieldRef.current ? maskingFieldRef.current.getBoundingClientRect() : null); + setPanelRect(panelBodyRef.current ? panelBodyRef.current.getBoundingClientRect() : null); + setPanelDragRect(panelHeadRef.current ? panelHeadRef.current.getBoundingClientRect() : null); + setDockRect(dockElRef.current ? dockElRef.current.getBoundingClientRect() : null); + setDockDragRect(dockDragRef.current ? dockDragRef.current.getBoundingClientRect() : null); + // The dock's minimize button doesn't have its own dedicated ref — + // it's already tracked in iconRefs (keyed "__min") for the tooltip + // system, so reuse that instead of threading through a second ref. + const dockMinEl = iconRefs.current["__min"]; + setDockMinRect(dockMinEl ? dockMinEl.getBoundingClientRect() : null); + setPanelMinRect(panelMinRef.current ? panelMinRef.current.getBoundingClientRect() : null); + setPopupRectMeasured(popupRef?.current ? popupRef.current.getBoundingClientRect() : null); + setPopupDragRectMeasured(popupDragRef?.current ? popupDragRef.current.getBoundingClientRect() : null); + setPopupMinRectMeasured(popupMinRef?.current ? popupMinRef.current.getBoundingClientRect() : null); + setSliceJumpRect(sliceJumpRef?.current ? sliceJumpRef.current.getBoundingClientRect() : null); + }; + measure(); + window.addEventListener("resize", measure); + window.addEventListener("scroll", measure, true); + const id = window.setInterval(measure, 200); // catches drag moves without their own event hook + return () => { + window.removeEventListener("resize", measure); + window.removeEventListener("scroll", measure, true); + window.clearInterval(id); + }; + }, [anyWalkthroughOpen, panel.pos.x, panel.pos.y, panel.minimized, dockPanel.pos.x, dockPanel.pos.y, dockMinimized, activeTool, popupRef, popupDragRef, popupMinRef, sliceJumpRef]); + + // First-run auto-open: the moment this toolbar is opened (Annotate + // pressed) with no target picked yet, show the overview tour once per + // browser. Reopening the dock later (or already having a target) never + // re-triggers it — the header's "Show walkthrough" launcher is the only + // way to bring it back after that. + useEffect(() => { + if (!open) return; + if (hasActiveTarget) return; + let alreadySeen = false; + try { + alreadySeen = typeof window !== "undefined" && window.localStorage.getItem(OVERVIEW_WALKTHROUGH_SEEN_KEY) === "1"; + } catch { /* localStorage unavailable — just show it */ } + if (!alreadySeen) setOverviewWalkthroughOpen(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const dismissOverviewWalkthrough = useCallback(() => { + setOverviewWalkthroughOpen(false); + try { + if (typeof window !== "undefined") window.localStorage.setItem(OVERVIEW_WALKTHROUGH_SEEN_KEY, "1"); + } catch { /* localStorage unavailable — not worth blocking on */ } + }, []); + + const enabled = hasSegments && hasActiveTarget; + + const selectTool = (tool: Exclude) => { + if (!enabled) return; + onToolChange(activeTool === tool ? null : tool); + }; + + if (!open) return null; + + const activeDef = activeTool ? TOOL_DEFS.find((t) => t.id === activeTool) : null; + const showRenderingDot = !!isRendering && !!activeTool && LIVE_COMMIT_TOOLS.includes(activeTool); + + // Minimized dock shows exactly one icon: the active tool if one is + // selected, otherwise Brush (first entry) as the default resting state. + const minimizedDef = activeDef ?? TOOL_DEFS[0]; + + return ( + <> +
+
+ +
+
+ setOverviewWalkthroughOpen(true)} + /> +
+ {dockMinimized ? ( +
{ iconRefs.current[minimizedDef.id] = el; }} + style={{ position: "relative" }} + onMouseEnter={() => showTooltip(minimizedDef.id)} + onMouseLeave={() => hideTooltip(minimizedDef.id)} + > + + {hoveredTool === minimizedDef.id && ( + + )} +
+ ) : ( + TOOL_DEFS.map(({ id, label, Icon, description }) => ( +
{ iconRefs.current[id] = el; }} + style={{ position: "relative" }} + onMouseEnter={() => showTooltip(id)} + onMouseLeave={() => hideTooltip(id)} + > + + {hoveredTool === id && ( + + )} +
+ )) + )} + + {/* Minimize toggle — always its own row, never scrolls off with the + icon list, so it's reachable regardless of dock state. */} + + {hoveredTool === "__min" && ( + + )} +
+ + {activeTool && enabled && activeDef && + createPortal( +
+
+ + + + {activeDef.label} + + {showRenderingDot && } + + + {/* Identical launcher in every tool's panel — this is the one + and only "Show walkthrough" affordance for whichever tool + is currently active, and doubles as the replay control. */} + { e.stopPropagation(); setToolWalkthroughOpen(true); }} + /> + + +
+ {!panel.minimized && ( +
+
+ {(activeTool === "paint" || activeTool === "erase") && ( + + )} + {activeTool === "scissors" && ( + + )} + {!["paint", "erase", "scissors"].includes(activeTool) && renderFlyout(activeTool)} +
+ +
+ +
+
+ )} +
, + document.body + )} + + {activeTool && ( + setToolWalkthroughOpen(false)} + steps={buildToolSteps(activeTool, { + panelRect, + fieldRect, + maskingRect, + sliceJumpRect, + })} + /> + )} + + + + ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/LiveSegmentMesh.tsx b/PanTS-Demo/src/components/viewer/LiveSegmentMesh.tsx similarity index 96% rename from PanTS-Demo/src/components/LiveSegmentMesh.tsx rename to PanTS-Demo/src/components/viewer/LiveSegmentMesh.tsx index eaf2274..92f0b0a 100644 --- a/PanTS-Demo/src/components/LiveSegmentMesh.tsx +++ b/PanTS-Demo/src/components/viewer/LiveSegmentMesh.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; import * as THREE from "three"; import type { Color } from "@cornerstonejs/core/types"; -import { extractSegmentSurface, subscribeToSegmentationEdits } from "../helpers/CornerstoneNifti2"; -import { debounce } from "../helpers/debounce"; +import { extractSegmentSurface, subscribeToSegmentationEdits } from "../../helpers/CornerstoneNifti2"; +import { debounce } from "../../helpers/debounce" import { rgbToHex } from "./OrganMesh"; type LiveSegmentMeshProps = { diff --git a/PanTS-Demo/src/components/viewer/LiveWireOverlay.tsx b/PanTS-Demo/src/components/viewer/LiveWireOverlay.tsx new file mode 100644 index 0000000..5d8c068 --- /dev/null +++ b/PanTS-Demo/src/components/viewer/LiveWireOverlay.tsx @@ -0,0 +1,78 @@ +import type { CinePane } from "../../helpers/CornerstoneNifti2"; + +type Props = { + pane: CinePane; + 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 when the cursor is currently within closing range of the start point — + * grows/highlights that point so it's obvious where to click to close the shape. */ + nearClose?: boolean; +}; + + +function pathToD(points: Array<[number, number]>, close: boolean): string { + if (!points.length) return ""; + const d = `M ${points[0][0]} ${points[0][1]} ` + points.slice(1).map((p) => `L ${p[0]} ${p[1]}`).join(" "); + return close ? `${d} Z` : d; +} + + +function LiveWireOverlay({ anchorPointsCanvas, cornerPointsCanvas, livePreviewPath, nearClose }: Props) { + return ( + + {anchorPointsCanvas.length > 1 && ( + + )} + {livePreviewPath && livePreviewPath.length > 1 && ( + + )} + {cornerPointsCanvas.length > 2 && ( + + )} + {/* Pulsing halo behind the start point once the cursor is close enough to close — + makes "click here to finish" obvious without any text. */} + {nearClose && cornerPointsCanvas.length > 2 && ( + + )} + {cornerPointsCanvas.map((p, i) => { + const isStart = i === 0; + const highlightStart = isStart && nearClose && cornerPointsCanvas.length > 2; + return ( + + ); + })} + + ); +} +export default LiveWireOverlay; \ No newline at end of file diff --git a/PanTS-Demo/src/components/viewer/MeshViewer.tsx b/PanTS-Demo/src/components/viewer/MeshViewer.tsx new file mode 100644 index 0000000..66081da --- /dev/null +++ b/PanTS-Demo/src/components/viewer/MeshViewer.tsx @@ -0,0 +1,127 @@ +import { Bounds, OrbitControls } from "@react-three/drei"; +import { Canvas } from "@react-three/fiber"; +import { Suspense, useEffect, useMemo, useState } from "react"; +import { APP_CONSTANTS } from "../../helpers/constants"; +import { cornerstoneLpsMmToThree, type Vec3 } from "../../helpers/utils"; +import type { MeshManifest } from "../../types"; +import { OrganMesh } from "./OrganMesh"; +import { SceneCrosshair3D } from "./SceneCrosshair3D"; +import type { Color } from "@cornerstonejs/core/types"; +import { LiveSegmentMesh } from "./LiveSegmentMesh"; +import type { CheckBoxData } from "../../types"; +import { getEditedSegments, subscribeToSegmentationEdits } from "../../helpers/CornerstoneNifti2"; + +type SegmentationMeshViewerProps = { + caseId: string; + loading: boolean + checkState: boolean[]; + opacity: number; + crosshairMm: Vec3 | null + customOrgans?: CheckBoxData[]; + labelColorMap?: { [key: number]: Color }; +}; + +export async function fetchMeshManifest(caseId: string): Promise { + const res = await fetch(`${APP_CONSTANTS.API_ORIGIN}/api/cases/${caseId}/mesh-manifest`); + if (!res.ok) throw new Error(`Failed to fetch mesh manifest: ${res.status}`); + return res.json(); +} + +export function SegmentationMeshViewer({ caseId, checkState, loading, opacity, crosshairMm, customOrgans = [], labelColorMap = {}}: SegmentationMeshViewerProps) { + const [manifest, setManifest] = useState(null); + const [loaded, setLoaded] = useState>({}); + // Bumped on every mask edit so editedSegments below is recomputed — the 3D pane + // needs to know the instant a static organ's mask changes, not just at mount. + const [editVersion, setEditVersion] = useState(0); + + useEffect(() => { + const unsubscribe = subscribeToSegmentationEdits(() => setEditVersion((v) => v + 1)); + return unsubscribe; + }, []); + + // Segment indices touched since the case loaded — includes edits to the STATIC + // 32-organ catalog, not just brand-new custom classes. + const editedSegments = useMemo(() => getEditedSegments(), [editVersion]); + + const crosshairPosition = useMemo(() => { + if (!manifest || !crosshairMm) return null; + return cornerstoneLpsMmToThree(crosshairMm, manifest.center); + }, [manifest, crosshairMm]); + + useEffect(() => { + let alive = true; + fetchMeshManifest(caseId) + .then((data) => { + if (!alive) return; + setManifest(data); + const initialLoaded: Record = {}; + for (const organ of data.organs) initialLoaded[organ.id] = true; + setLoaded(initialLoaded); + }) + .catch((err) => console.error(err)); + return () => { alive = false; }; + }, [caseId]); + + const organs = useMemo(() => manifest?.organs ?? [], [manifest]); + + if (!manifest || loading || !checkState || checkState.length === 0) { + return
Loading 3D segmentation...
; + } + return ( +
+
+ + + + + + + + {organs.map((organ) => { + if (!loaded[organ.id]) return null; + // Edited static organ: the server-baked GLB is stale — extract a + // fresh live mesh from the in-memory labelmap instead, same path + // custom classes already use. + if (editedSegments.has(organ.id)) { + return ( + + ); + } + return ( + + ); + })} + {customOrgans.map((organ) => ( + + ))} + + + {crosshairPosition && manifest.bounds && ( + + )} + + + +
+
+ ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/OrganMesh.tsx b/PanTS-Demo/src/components/viewer/OrganMesh.tsx similarity index 89% rename from PanTS-Demo/src/components/OrganMesh.tsx rename to PanTS-Demo/src/components/viewer/OrganMesh.tsx index d548930..da81273 100644 --- a/PanTS-Demo/src/components/OrganMesh.tsx +++ b/PanTS-Demo/src/components/viewer/OrganMesh.tsx @@ -1,8 +1,8 @@ import { useGLTF } from "@react-three/drei"; import { useEffect, useMemo } from "react"; import * as THREE from "three"; -import { segmentation_category_colors } from '../helpers/constants'; -import type { OrganMeshInfo } from "../types"; +import { segmentation_category_colors } from '../../helpers/constants'; +import type { OrganMeshInfo } from "../../types"; type OrganMeshProps = { organ: OrganMeshInfo; visible: boolean; diff --git a/PanTS-Demo/src/components/viewer/SceneCrosshair3D.tsx b/PanTS-Demo/src/components/viewer/SceneCrosshair3D.tsx new file mode 100644 index 0000000..cd3b476 --- /dev/null +++ b/PanTS-Demo/src/components/viewer/SceneCrosshair3D.tsx @@ -0,0 +1,99 @@ +import { useMemo } from "react"; +import * as THREE from "three"; + +type Vec3 = [number, number, number]; + +type SceneBounds = { + min: Vec3; + max: Vec3; +}; + +type SceneCrosshair3DProps = { + position: Vec3; + bounds: SceneBounds; + padding?: number; +}; + +function makeLineGeometry(a: Vec3, b: Vec3) { + const geometry = new THREE.BufferGeometry(); + + geometry.setFromPoints([ + new THREE.Vector3(a[0], a[1], a[2]), + new THREE.Vector3(b[0], b[1], b[2]), + ]); + + return geometry; +} + +function makeLine( + a: Vec3, + b: Vec3, + material: THREE.LineBasicMaterial +) { + const geometry = makeLineGeometry(a, b); + const line = new THREE.Line(geometry, material); + + line.renderOrder = 999; + line.frustumCulled = false; + + return line; +} + +export function SceneCrosshair3D({ + position, + bounds, + padding = 50, +}: SceneCrosshair3DProps) { + const [x, y, z] = position; + + const minX = bounds.min[0] - padding; + const minY = bounds.min[1] - padding; + const minZ = bounds.min[2] - padding; + + const maxX = bounds.max[0] + padding; + const maxY = bounds.max[1] + padding; + const maxZ = bounds.max[2] + padding; + + /** + * X line: + * x varies + * y,z fixed at crosshair position + * + * Y line: + * y varies + * x,z fixed + * + * Z line: + * z varies + * x,y fixed + */ + const material = useMemo(() => { + return new THREE.LineBasicMaterial({ + color: "white", + depthTest: false, + depthWrite: false, + transparent: true, + opacity: 1, + }); + }, []); + + const xLine = useMemo(() => { + return makeLine([minX, y, z], [maxX, y, z], material); + }, [minX, maxX, y, z, material]); + + const yLine = useMemo(() => { + return makeLine([x, minY, z], [x, maxY, z], material); + }, [x, minY, maxY, z, material]); + + const zLine = useMemo(() => { + return makeLine([x, y, minZ], [x, y, maxZ], material); + }, [x, y, minZ, maxZ, material]); + + return ( + + + + + + ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/viewer/zoomHandle.tsx b/PanTS-Demo/src/components/viewer/zoomHandle.tsx new file mode 100644 index 0000000..17e1018 --- /dev/null +++ b/PanTS-Demo/src/components/viewer/zoomHandle.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { centerOnCursor, setZoom, zoomToFit } from "../../helpers/CornerstoneNifti2"; +type Props = { + submitted: number; + setSubmitted: React.Dispatch>; + setZoomMode: React.Dispatch>; +}; +const ZoomHandle = ({ submitted, setSubmitted, setZoomMode: _setZoomMode }: Props) => { + const [_text, setText] = useState(submitted.toString()); + // const [submitted, setSubmitted] = useState(1); + useEffect(() => { + setZoom(submitted); + setText(submitted.toFixed(2)); + }, [submitted]); + + // const handleKeyDown = (e: React.KeyboardEvent) => { + // if (e.key === "Enter") { + // const num = Math.min(Math.max(Number(text), 0.5), 2); + + // if (!isNaN(num)) { + // setSubmitted(num); + // setText(num.toFixed(2)); // clear input if you want + // } else { + // setText(submitted.toFixed(2)); + // } + // } + // }; + return ( +
+
Zoom
+
+
+ Zoom + {submitted.toFixed(2)}× +
+ setSubmitted(Number(e.target.value))} + /> +
+
+ + +
+
+ ); +}; + +export default ZoomHandle; diff --git a/PanTS-Demo/src/components/walkthrough/ToolWalkthrough.css b/PanTS-Demo/src/components/walkthrough/ToolWalkthrough.css new file mode 100644 index 0000000..cf529d9 --- /dev/null +++ b/PanTS-Demo/src/components/walkthrough/ToolWalkthrough.css @@ -0,0 +1,326 @@ +/* ToolWalkthrough.css */ + +.twt { + position: fixed; + inset: 0; + z-index: 200; + font-family: "Space Grotesk", system-ui, sans-serif; + /* Blocking, unlike the old non-blocking overlay: while a walkthrough is + up, NOTHING underneath should react to a click — not the canvas, not + a panel's own buttons — only the walkthrough's own Next/Back/Quit + controls are reachable. Enforced by giving the scrim pointer-events + and swallowing every event that lands on it. */ + pointer-events: auto; +} + +.twt__scrim { + position: absolute; + inset: 0; + background: rgba(4, 5, 7, 0.6); + animation: twt-fade-in 0.18s ease-out; + cursor: default; +} + +.twt__banner { + pointer-events: none; + position: absolute; + top: 22px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 7px; + padding: 8px 16px; + border-radius: 999px; + background: rgba(110, 168, 254, 0.16); + border: 1px solid rgba(110, 168, 254, 0.45); + color: #bcd6ff; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + box-shadow: 0 8px 24px -8px rgba(110, 168, 254, 0.5); + animation: twt-pop 0.22s ease-out; +} + +/* Spotlight ring — positioned dynamically via inline style from a live + getBoundingClientRect() passed in per-step, so it tracks correctly even + though every panel it might point at (popup, dock, corner panel) is + draggable. Purely decorative now (clicks are blocked everywhere), so it + only ever needs to *point*, never to stay clickable. */ +.twt__spotlight { + pointer-events: none; + position: fixed; + border-radius: 12px; + border: 2px dashed rgba(110, 168, 254, 0.7); + box-shadow: 0 0 0 4000px rgba(4, 5, 7, 0.35), 0 0 24px rgba(110, 168, 254, 0.3) inset; + animation: twt-ring-pulse 1.8s ease-in-out infinite; + transition: top 0.15s, left 0.15s, width 0.15s, height 0.15s; +} + +.twt__card { + pointer-events: auto; + position: fixed; + width: 400px; + max-height: calc(100vh - 40px); + overflow-y: auto; + padding: 14px 16px; + border-radius: 12px; + background: rgba(14, 15, 18, 0.97); + backdrop-filter: blur(18px) saturate(150%); + -webkit-backdrop-filter: blur(18px) saturate(150%); + border: 1px solid rgba(110, 168, 254, 0.4); + box-shadow: 0 18px 44px -14px rgba(0, 0, 0, 0.85), 0 0 0 1px rgba(110, 168, 254, 0.1); + animation: twt-pop 0.22s ease-out; + transition: top 0.15s, left 0.15s; +} + +.twt__eyebrow { + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6ea8fe; + margin-bottom: 5px; +} + +.twt__title { + font-size: 13.5px; + font-weight: 700; + color: #fff; + margin-bottom: 4px; +} + +.twt__body { + font-size: 11.5px; + line-height: 1.5; + color: rgba(255, 255, 255, 0.75); +} + +.twt__key { + display: inline-block; + padding: 1px 6px; + border-radius: 5px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.16); + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 10.5px; + color: #fff; + white-space: nowrap; +} + +.twt__shortcut-list { + display: flex; + flex-direction: column; + gap: 7px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.09); +} + +.twt__shortcut-heading { + font-size: 9.5px; + font-weight: 800; + letter-spacing: 0.07em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.4); + margin-bottom: 1px; +} + +.twt__shortcut-row { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 11.5px; + line-height: 1.4; +} + +.twt__shortcut-keys { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 3px; + min-width: 92px; +} + +.twt__shortcut-desc { + color: rgba(255, 255, 255, 0.72); +} + +/* Callout used for extra context that isn't a shortcut — e.g. "there's a + live 3D rendering of all annotations" or "you can type directly into the + slice counter". Visually distinct (tinted box) so it reads as a bonus + tip rather than a required step. */ +.twt__note { + display: flex; + align-items: flex-start; + gap: 7px; + margin-top: 10px; + padding: 8px 9px; + border-radius: 8px; + background: rgba(110, 168, 254, 0.1); + border: 1px solid rgba(110, 168, 254, 0.25); + font-size: 11px; + line-height: 1.45; + color: #bcd6ff; +} + +.twt__note-icon { + flex-shrink: 0; + margin-top: 1px; +} + +.twt__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 12px; +} + +.twt__dots { + display: flex; + gap: 5px; +} + +.twt__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.25); + transition: background 0.15s, transform 0.15s; +} + +.twt__dot.is-active { + background: #6ea8fe; + transform: scale(1.25); +} + +.twt__nav { + display: flex; + gap: 6px; +} + +.twt__btn { + display: flex; + align-items: center; + gap: 5px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: #fff; + font-size: 11.5px; + font-weight: 700; + padding: 6px 10px; + cursor: pointer; +} + +.twt__btn:hover { + background: rgba(255, 255, 255, 0.14); +} + +.twt__btn--primary { + background: #6ea8fe; + border-color: #6ea8fe; + color: #08090b; +} + +.twt__btn--primary:hover { + background: #86b8ff; +} + +/* The ONLY other clickable thing in a walkthrough besides Next/Back — always + red, always available, so quitting is never more than one obvious click + away regardless of which step you're on. */ +.twt__btn--quit { + background: rgba(244, 63, 94, 0.15); + border-color: rgba(244, 63, 94, 0.45); + color: #fda4af; +} + +.twt__btn--quit:hover { + background: rgba(244, 63, 94, 0.28); + color: #fecdd3; +} + +/* On the final step Quit is the only control left, and it doubles as + "Finish" — make it solid/unmissable rather than the quieter outline used + everywhere else. */ +.twt__btn--quit-final { + background: #f43f5e; + border-color: #f43f5e; + color: #fff; + padding: 7px 14px; +} + +.twt__btn--quit-final:hover { + background: #fb6f8b; +} + +/* Launcher button — the small "Show walkthrough" trigger embedded in each + panel's own header. Identical everywhere it appears (brush panel, every + other tool panel, the dock, the segments popup) so it always reads as + the same affordance. */ +.twt-launcher { + display: inline-flex; + align-items: center; + gap: 5px; + background: transparent; + border: 1px solid rgba(110, 168, 254, 0.35); + color: #6ea8fe; + border-radius: 7px; + font-size: 10.5px; + font-weight: 700; + padding: 4px 8px; + cursor: pointer; + white-space: nowrap; +} + +.twt-launcher:hover { + background: rgba(110, 168, 254, 0.12); + color: #9dc2ff; +} + +.twt-launcher--icon-only { + padding: 5px; +} + +@keyframes twt-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes twt-pop { + from { + opacity: 0; + transform: translateY(4px) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes twt-ring-pulse { + + 0%, + 100% { + opacity: 0.55; + } + + 50% { + opacity: 1; + } +} + +@media (max-width: 900px) { + .twt__card { + width: min(340px, calc(100vw - 32px)); + } +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/walkthrough/ToolWalkthrough.tsx b/PanTS-Demo/src/components/walkthrough/ToolWalkthrough.tsx new file mode 100644 index 0000000..e30a3c6 --- /dev/null +++ b/PanTS-Demo/src/components/walkthrough/ToolWalkthrough.tsx @@ -0,0 +1,283 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { IconRoute2, IconArrowLeft, IconArrowRight, IconX, IconBulb } from "@tabler/icons-react"; +import "./ToolWalkthrough.css"; + +export interface WalkthroughShortcut { + /** One or more key tokens rendered as chips, e.g. ["Shift", "["]. */ + keys: string[]; + desc: string; +} + +export interface WalkthroughStep { + title: string; + body: React.ReactNode; + /** Live rect (from getBoundingClientRect) of whatever this step is + * pointing at. Pass null/undefined for steps that aren't about a + * specific bit of UI (e.g. a pure tips/shortcuts recap) — the card + * centers itself on screen instead. Recomputed by the caller on every + * render while the walkthrough is open, so it tracks draggable panels. */ + targetRect?: DOMRect | null; + /** Shortcuts relevant to this specific step. Optional — most steps show + * none, some show one, a final recap step can show several. */ + shortcuts?: WalkthroughShortcut[]; + /** An extra highlighted callout for context that isn't a shortcut — e.g. + * "there's a live 3D rendering of every annotation" or "you can type + * directly into the slice counter". */ + note?: React.ReactNode; +} + +interface ToolWalkthroughProps { + visible: boolean; + /** Shown in the banner and used for the aria-label, e.g. "Brush walkthrough". */ + label: string; + steps: WalkthroughStep[]; + onDismiss: () => void; +} + +const GAP = 14; +const EDGE_MARGIN = 12; + +/** Places the callout card beside its target rect (to the right if there's + * room, otherwise to the left, otherwise clamped under/over it), using the + * card's REAL measured width/height (from a ref, post-render) rather than a + * guessed height — that guess is what let tall cards (lots of shortcuts, a + * note, near a screen edge) render partly off-screen, e.g. the custom class + * walkthrough. Every branch below clamps against actual viewport size, so + * the card — and everything inside it, including the footer buttons — is + * always fully visible regardless of which corner the target sits in. */ +function computeCardPos(rect: DOMRect | null | undefined, cardW: number, cardH: number) { + const vw = window.innerWidth; + const vh = window.innerHeight; + const maxLeft = Math.max(EDGE_MARGIN, vw - cardW - EDGE_MARGIN); + const maxTop = Math.max(EDGE_MARGIN, vh - cardH - EDGE_MARGIN); + + if (!rect) { + return { + top: Math.max(EDGE_MARGIN, (vh - cardH) / 2), + left: Math.max(EDGE_MARGIN, (vw - cardW) / 2), + }; + } + + // Prefer beside the target (right, then left). If neither side has room + // (target spans most of the width), fall back to clamping horizontally + // against the target's own left edge so the card at least stays on-screen. + let left = rect.right + GAP; + if (left + cardW > vw - EDGE_MARGIN) { + left = rect.left - cardW - GAP; + } + if (left < EDGE_MARGIN) { + left = rect.left; + } + left = Math.max(EDGE_MARGIN, Math.min(maxLeft, left)); + + let top = rect.top; + top = Math.max(EDGE_MARGIN, Math.min(maxTop, top)); + return { top, left }; +} + +function Kbd({ children }: { children: React.ReactNode }) { + return {children}; +} + +function ShortcutRow({ shortcut }: { shortcut: WalkthroughShortcut }) { + return ( +
+ + {shortcut.keys.map((k, i) => ( + + {i > 0 && +} + {k} + + ))} + + {shortcut.desc} +
+ ); +} + +/** + * Generic, replayable walkthrough overlay. Used for EVERY walkthrough in the + * app (overview, custom class, and every individual annotation tool) so they + * all share one exact look/feel/behavior: + * + * - Fully blocking: the scrim captures every click, so nothing underneath + * (canvas, panels, buttons) can be triggered by accident while a + * walkthrough is up. The ONLY interactive elements are this card's own + * Back / Next / Quit controls. + * - Quit is always available, always red, and is the only remaining + * control on the final step (Next disappears — there's nothing after it). + * - Replayable: callers own a boolean (e.g. `walkthroughOpen`) and a + * "Show walkthrough" launcher button that just sets it true again — see + * `WalkthroughLauncherButton` below. Reopening always restarts at step 1. + */ +export default function ToolWalkthrough({ visible, label, steps, onDismiss }: ToolWalkthroughProps) { + const [stepIdx, setStepIdx] = useState(0); + const cardRef = useRef(null); + // Starts off-screen (rather than at a guessed spot) so there's never a + // visible flash at the wrong position — useLayoutEffect below corrects it + // using the card's real dimensions before the browser paints. + const [cardPos, setCardPos] = useState<{ top: number; left: number }>({ top: -9999, left: -9999 }); + + useEffect(() => { + if (visible) setStepIdx(0); + }, [visible]); + + useEffect(() => { + if (!visible) return; + const onKey = (e: KeyboardEvent) => { + e.stopPropagation(); + if (e.key === "Escape") { onDismiss(); return; } + if (e.key === "ArrowRight") setStepIdx((s) => Math.min(steps.length - 1, s + 1)); + if (e.key === "ArrowLeft") setStepIdx((s) => Math.max(0, s - 1)); + }; + // Capture phase so this wins over the app's own global shortcut + // listeners (undo, tool hotkeys, etc.) while a walkthrough is up. + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [visible, onDismiss, steps.length]); + + const total = steps.length; + const step = steps[Math.min(stepIdx, total - 1)]; + const isLast = stepIdx >= total - 1; + const targetRect = step?.targetRect; + + // Re-measures the card's actual rendered size (content varies a lot step + // to step — shortcuts list, note callout, body length — so a fixed guess + // was never going to be right) and repositions it fully on-screen. + // Runs before paint, and again on window resize while a walkthrough is up. + useLayoutEffect(() => { + if (!visible || !cardRef.current) return; + const recompute = () => { + const el = cardRef.current; + if (!el) return; + setCardPos(computeCardPos(targetRect, el.offsetWidth, el.offsetHeight)); + }; + recompute(); + window.addEventListener("resize", recompute); + return () => window.removeEventListener("resize", recompute); + }, [visible, stepIdx, targetRect?.top, targetRect?.left, targetRect?.width, targetRect?.height, step?.shortcuts, step?.note, step?.body]); + + if (!visible || total === 0) return null; + + // Swallow every pointer event on the scrim so nothing underneath (canvas, + // panels) reacts — this is what makes the walkthrough non-destructive. + const blockEvent = (e: React.SyntheticEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + return createPortal( +
+
+ +
+ + {label} +
+ + {step.targetRect && ( +
+ )} + +
+
Step {stepIdx + 1} of {total}
+
{step.title}
+
{step.body}
+ + {step.note && ( +
+ + {step.note} +
+ )} + + {step.shortcuts && step.shortcuts.length > 0 && ( +
+
Shortcuts
+ {step.shortcuts.map((s, i) => )} +
+ )} + +
+
+ {steps.map((_, i) => ( + + ))} +
+
+ {!isLast && ( + + )} + {/* Back is available on every step after the first, including the + last one — quitting shouldn't be the only way off the final step. */} + {stepIdx > 0 && ( + + )} + {!isLast && ( + + )} + {isLast && ( + + )} +
+
+
+
, + document.body + ); +} + +/** Small "See demo" trigger, styled identically everywhere it's + * dropped in (brush panel, every other tool panel, the dock, the segments + * popup) — clicking it again after dismissal is how every walkthrough gets + * replayed; there's no separate replay control anywhere. */ +export function WalkthroughLauncherButton({ + onClick, iconOnly, label = "See demo", title, +}: { + onClick: (e: React.MouseEvent) => void; + iconOnly?: boolean; + label?: string; + title?: string; +}) { + return ( + + ); +} \ No newline at end of file diff --git a/PanTS-Demo/src/components/walkthrough/WalkthroughContent.tsx b/PanTS-Demo/src/components/walkthrough/WalkthroughContent.tsx new file mode 100644 index 0000000..2bf079e --- /dev/null +++ b/PanTS-Demo/src/components/walkthrough/WalkthroughContent.tsx @@ -0,0 +1,602 @@ +import type { WalkthroughStep } from "./ToolWalkthrough"; +import type { PrimaryEditTool } from "../viewer/AnnotationToolbar"; + + +// --------------------------------------------------------------------------- +// Overview walkthrough — shown the first time "Annotate" is pressed, and +// replayable any time via its own launcher. Walks through every moving +// piece of the annotation UI: the segments popup (pick or create a target), +// the vertical tool dock, and the per-tool corner panel — including how to +// drag and minimize/expand each of the three. +// --------------------------------------------------------------------------- +export interface OverviewRects { + popupRect?: DOMRect | null; + popupDragRect?: DOMRect | null; + popupMinRect?: DOMRect | null; + dockRect?: DOMRect | null; + dockDragRect?: DOMRect | null; + dockMinRect?: DOMRect | null; + panelRect?: DOMRect | null; + panelDragRect?: DOMRect | null; + panelMinRect?: DOMRect | null; +} + +export function buildOverviewSteps(r: OverviewRects): WalkthroughStep[] { + return [ + { + title: "Welcome to annotation mode", + body: ( + <> + This is a quick tour of the annotation tools — five short steps, then + you're set. Every panel you'll see can be dragged out of the way and + minimized, so nothing here is ever stuck blocking your view. + + ), + note: ( + <> + There's a live 3D rendering of every annotation you draw, updating as + you go — a quick way to sanity-check a shape's overall form, not just + what it looks like on one slice. + + ), + }, + { + title: "Pick or create a target", + body: ( + <> + The segments popup is where you choose what you're about to annotate. + Click an existing organ from the list, or add a custom class of your + own if what you need isn't already there. + + ), + targetRect: r.popupRect, + note: <>Want the details on custom classes specifically? There's a dedicated walkthrough for that from the popup itself., + }, + { + title: "The segments popup can move", + body: ( + <> + Drag anywhere on its header to reposition it when you need it + somewhere else on screen. + + ), + targetRect: r.popupDragRect ?? r.popupRect, + }, + { + title: "Minimizing the popup", + body: ( + <> + Use this button to collapse the popup to a small bar when you need + the screen space — click it again to expand back to the full list. + + ), + targetRect: r.popupMinRect ?? r.popupDragRect ?? r.popupRect, + }, + { + title: "The tool dock", + body: ( + <> + Once a target is picked, this vertical dock lights up with every + drawing tool — brush, scissors, lasso, level tracing, and more. Click + a tool to select it; click it again to deselect. + + ), + targetRect: r.dockRect, + }, + { + title: "Dragging the dock", + body: ( + <> + Drag the small grip at the top to move the dock to either side of the + screen — it snaps to whichever edge it's closest to. + + ), + targetRect: r.dockDragRect ?? r.dockRect, + }, + { + title: "Minimizing the dock", + body: ( + <> + This button at the bottom of the dock collapses it down to just the + active tool's icon — click it again to bring back the full list. + + ), + targetRect: r.dockMinRect ?? r.dockDragRect ?? r.dockRect, + }, + { + title: "Each tool opens its own panel", + body: ( + <> + Selecting a tool opens a panel in the corner with that tool's + controls — size, sensitivity, operation, whatever's relevant — plus a + shared "Applies to" setting that scopes where the tool is allowed to + act (e.g. only inside the current segment). + + ), + targetRect: r.panelRect, + }, + { + title: "Dragging panels", + body: ( + <> + Same idea as the dock: drag the panel's header to move it anywhere on + screen. + + ), + targetRect: r.panelDragRect ?? r.panelRect, + }, + { + title: "Minimizing panels", + body: ( + <> + Use its minimize button to collapse the panel to just the title bar + without losing your settings — click it again to expand. + + ), + targetRect: r.panelMinRect ?? r.panelDragRect ?? r.panelRect, + shortcuts: [ + { keys: ["Ctrl", "Z"], desc: "Undo the last edit (⌘ on Mac)" }, + { keys: ["Shift", "Ctrl", "Z"], desc: "Redo" }, + { keys: ["[", "]"], desc: "Step one slice back / forward" }, + { keys: ["+", "−"], desc: "Zoom toward the cursor" }, + ], + }, + { + title: "You're ready to annotate", + body: ( + <> + Every individual tool also has its own "Show walkthrough" button in + its panel — use it any time you want a refresher on exactly what that + tool does. This overview is replayable too, from the dock's help icon. + + ), + }, + ]; +} + +// --------------------------------------------------------------------------- +// Custom class walkthrough — creating and managing a custom segmentation +// class from the segments popup. +// --------------------------------------------------------------------------- +export interface CustomClassRects { + addClassRect?: DOMRect | null; + tableRect?: DOMRect | null; +} + +export function buildCustomClassSteps(r: CustomClassRects): WalkthroughStep[] { + return [ + { + title: "Add a custom class", + body: ( + <> + Not every structure you need is in the catalog. Use the "Add class" + control in the segments popup to create your own — give it a name and + it's immediately available as a target, exactly like a catalog organ. + + ), + targetRect: r.addClassRect, + }, + { + title: "Pick a color", + body: ( + <> + Each class gets a color swatch so it's easy to tell apart in both the + 2D slices and the 3D rendering. Click the swatch next to its name at + any time to change it. + + ), + targetRect: r.tableRect, + }, + { + title: "Rename, hide, or delete", + body: ( + <> + Double-click a class's name to rename it in place. The eye icon + toggles its visibility without deleting anything, and the trash icon + removes it for good. + + ), + targetRect: r.tableRect, + note: <>A custom class behaves identically to a catalog organ everywhere else — every tool, every masking option works the same on it., + }, + { + title: "Scope it like anything else", + body: ( + <> + Once selected, a custom class shows up in every tool's "Applies to" + control just like a built-in organ — you can restrict brushing, + scissors, and every other tool to it directly. + + ), + }, + ]; +} + +// --------------------------------------------------------------------------- +// Existing organ walkthrough — picking an already-segmented catalog organ +// to edit, from the segments popup's "Existing organ" tab. +// --------------------------------------------------------------------------- +export interface ExistingOrganRects { + listRect?: DOMRect | null; + tabsRect?: DOMRect | null; +} + +export function buildExistingOrganSteps(r: ExistingOrganRects): WalkthroughStep[] { + return [ + { + title: "Edit an already-segmented organ", + body: ( + <> + The "Existing organ" tab lists every organ this scan already has a + segmentation for — pick one here to edit it directly, instead of + creating a new custom class from scratch. + + ), + targetRect: r.listRect, + }, + { + title: "Select one to target it", + body: ( + <> + Click any organ in the list to make it the active target — the tool + dock lights up and every drawing tool now applies to that organ. Click + it again to deselect. + + ), + targetRect: r.listRect, + note: <>A small target icon marks whichever organ is currently active, exactly like it does on the Custom tab., + }, + { + title: "Switch tabs any time", + body: ( + <> + Use these tabs to jump between editing an existing organ and creating + or picking a custom class — your selection on one tab isn't lost when + you switch to the other. + + ), + targetRect: r.tabsRect, + }, + ]; +} + +// --------------------------------------------------------------------------- +// Per-tool walkthroughs. Every tool shares the same three rects: the corner +// panel as a whole (its controls), the "Applies to" masking select (shared +// across every tool), and — for the two slice-anchor tools — the slice +// counter overlay in the corner of each 2D view. +// --------------------------------------------------------------------------- +export interface ToolRects { + panelRect?: DOMRect | null; + fieldRect?: DOMRect | null; + maskingRect?: DOMRect | null; + sliceJumpRect?: DOMRect | null; +} + +const maskingStep = (r: ToolRects): WalkthroughStep => ({ + title: "Choose where it can act", + body: ( + <> + "Applies to" scopes every tool the same way — restrict it to only the + active segment, only outside it, only visible segments, and so on. It + defaults to "Everywhere". + + ), + targetRect: r.maskingRect, +}); + +const undoShortcuts = [ + { keys: ["Ctrl", "Z"], desc: "Undo the last edit (⌘ on Mac)" }, + { keys: ["Shift", "Ctrl", "Z"], desc: "Redo" }, +]; + +const sliceJumpNote = ( + <> + You don't have to click through slice by slice — click directly on the + slice counter in the corner of any 2D view to type an exact slice number. + +); + +export function buildToolSteps(tool: Exclude, r: ToolRects): WalkthroughStep[] { + switch (tool) { + case "paint": + return [ + { + title: "Paint with a round brush", + body: <>Click and drag on any slice to paint into the active target. The brush is a round, size-adjustable disc., + targetRect: r.panelRect, + }, + { + title: "Adjust brush size", + body: ( + <> + Drag the slider or type an exact millimeter value. Mid-stroke, + skip the slider entirely with the keyboard. + + ), + targetRect: r.fieldRect ?? r.panelRect, + shortcuts: [{ keys: ["Shift", "[", "/", "]"], desc: "Shrink / grow the brush by 2mm" }], + }, + maskingStep(r), + { + title: "Useful shortcuts while painting", + body: <>These work throughout — not just while the Brush is active., + shortcuts: [ + ...undoShortcuts, + { keys: ["[", "]"], desc: "Step one slice" }, + { keys: ["+", "−"], desc: "Zoom toward the cursor" }, + ], + note: <>Every brush stroke also updates the live 3D rendering in real time., + }, + ]; + + case "erase": + return [ + { + title: "Erase with a round brush", + body: <>Same control as the Brush, but it clears voxels from the active target instead of adding them., + targetRect: r.panelRect, + }, + { + title: "Adjust eraser size", + body: <>Drag the slider or type an exact millimeter value., + targetRect: r.fieldRect ?? r.panelRect, + shortcuts: [{ keys: ["Shift", "[", "/", "]"], desc: "Shrink / grow the eraser by 2mm" }], + }, + maskingStep(r), + { + title: "Useful shortcuts", + body: <>These work throughout — not just while Erase is active., + shortcuts: [...undoShortcuts, { keys: ["[", "]"], desc: "Step one slice" }], + }, + ]; + + case "scissors": + return [ + { + title: "Cut through the whole segment", + body: ( + <> + Draw a shape on the current slice; the cut applies through the + entire segment from this viewpoint, not just this one slice. + + ), + targetRect: r.panelRect, + }, + { + title: "Choose the operation", + body: <>Erase or fill, inside or outside the shape you draw — four combinations covering most cuts., + targetRect: r.fieldRect ?? r.panelRect, + }, + { + title: "Magnetic edge snap", + body: <>Turn this on and each point you place snaps to the nearest strong intensity edge, like a magnetic lasso — much faster for tracing organ or bone boundaries., + targetRect: r.fieldRect ?? r.panelRect, + }, + maskingStep(r), + { + title: "Closing and undoing a shape", + body: <>Click back on the red start point to close the shape and commit the cut., + shortcuts: [{ keys: ["Ctrl", "Z"], desc: "Undo the last placed point while drawing" }], + }, + ]; + + case "levelTracing": + return [ + { + title: "Trace by intensity", + body: <>Hover to preview the region of matching intensity around the cursor, click to apply it., + targetRect: r.panelRect, + }, + { + title: "Choose the operation", + body: <>Fill or erase, inside or outside the traced region., + targetRect: r.fieldRect ?? r.panelRect, + }, + { + title: "Sensitivity", + body: <>Higher sensitivity traces a wider range of intensities around the cursor — raise it if the region stops short of where you'd expect., + targetRect: r.fieldRect ?? r.panelRect, + }, + maskingStep(r), + { + title: "Shortcuts", + body: <>Standard editing shortcuts apply here too., + shortcuts: undoShortcuts, + }, + ]; + + case "margin": + return [ + { + title: "Grow or shrink a boundary", + body: <>Expand the segment outward, or pull it inward, by a fixed margin in millimeters., + targetRect: r.panelRect, + }, + { + title: "Set the margin", + body: <>Drag the slider or type an exact value, then press Apply., + targetRect: r.fieldRect ?? r.panelRect, + }, + maskingStep(r), + ]; + + case "smoothing": + return [ + { + title: "Smooth boundaries", + body: <>Rounds off jagged edges left behind by manual brush or scissors work., + targetRect: r.panelRect, + }, + { + title: "Kernel size", + body: <>Larger values smooth more aggressively — start small and increase only if it's still rough., + targetRect: r.fieldRect ?? r.panelRect, + }, + maskingStep(r), + ]; + + case "islands": + return [ + { + title: "Manage disconnected pieces", + body: <>An "island" is any disconnected blob within a segment — useful for cleaning up stray voxels or splitting a segment apart., + targetRect: r.panelRect, + }, + { + title: "Choose an operation", + body: ( + <> + Keep only the largest island, remove everything under a minimum + size, or click to pick a specific island to keep, remove, or + split off on its own. + + ), + targetRect: r.fieldRect ?? r.panelRect, + }, + { + title: "Picking an island", + body: <>When an operation needs a specific island, click "Start picking" then click directly on it in any 2D view., + targetRect: r.fieldRect ?? r.panelRect, + }, + maskingStep(r), + ]; + + case "logicalOperators": + return [ + { + title: "Combine or transform segments", + body: <>Copy another segment's shape, merge one into this one, invert, clear, or fully fill this segment., + targetRect: r.panelRect, + }, + { + title: "Pick a source segment", + body: <>Copy and Add both need a second segment to work with — pick it from the dropdown that appears., + targetRect: r.fieldRect ?? r.panelRect, + }, + { + title: "Read the plain-English summary", + body: <>The panel always spells out exactly what Apply is about to do before you press it — check it if you're ever unsure., + targetRect: r.fieldRect ?? r.panelRect, + }, + { + title: "Bypass masking (advanced)", + body: <>Normally an operation can't overwrite another segment's voxels. Flip this switch to allow it to — use with care., + targetRect: r.fieldRect ?? r.panelRect, + }, + ]; + + case "growFromSeeds": + return [ + { + title: "Grow a region from a few clicks", + body: <>Mark a few points inside what you want filled, optionally mark points to exclude, then fill — a fast way to rough in a shape., + targetRect: r.panelRect, + }, + { + title: "Step 1 — mark inside", + body: <>Drop a few points anywhere inside the region. A handful of points spread around works better than a single click., + targetRect: r.fieldRect ?? r.panelRect, + }, + { + title: "Step 2 — mark outside (optional)", + body: <>If a neighboring structure keeps bleeding in, dot it here to keep it excluded. Skip this step if you don't need it., + targetRect: r.fieldRect ?? r.panelRect, + }, + { + title: "Step 3 — fill", + body: <>Grows outward from your inside points, staying clear of anything marked outside, on the current slice or across the whole volume depending on scope., + targetRect: r.fieldRect ?? r.panelRect, + note: <>"Start over" at any point clears every mark and resets to step 1., + }, + ]; + + case "fillBetweenSlices": + return [ + { + title: "Interpolate between two slices", + body: <>Draw the shape on two slices and this fills in every slice between them automatically., + targetRect: r.panelRect, + }, + { + title: "Pick your two slices", + body: <>Press Start, then click the shape on the first slice, then click the shape on the last slice — both need the shape already drawn., + targetRect: r.fieldRect ?? r.panelRect, + }, + { + title: "Jumping to a specific slice", + body: <>Navigating to the right slice first makes picking much faster., + targetRect: r.sliceJumpRect, + note: sliceJumpNote, + shortcuts: [ + { keys: ["[", "]"], desc: "Step one slice" }, + { keys: ["Shift", "[", "/", "]"], desc: "Step ten slices" }, + { keys: ["Home", "End"], desc: "Jump to first / last slice" }, + ], + }, + { + title: "Run it", + body: <>Once both slices are picked, press "Interpolate" — you can always "Start over" to pick different slices., + }, + ]; + + case "copyAcrossSlices": + return [ + { + title: "Copy a shape across a range", + body: <>Copies the exact shape from one slice onto every slice up to another — unlike interpolation, the destination doesn't need anything drawn on it already., + targetRect: r.panelRect, + }, + { + title: "Pick source and destination", + body: <>Press Start, click the shape on the source slice, then click anywhere on the destination slice., + targetRect: r.fieldRect ?? r.panelRect, + }, + { + title: "Jumping to a specific slice", + body: <>Navigating to the right slice first makes picking much faster., + targetRect: r.sliceJumpRect, + note: sliceJumpNote, + shortcuts: [ + { keys: ["[", "]"], desc: "Step one slice" }, + { keys: ["Shift", "[", "/", "]"], desc: "Step ten slices" }, + { keys: ["Home", "End"], desc: "Jump to first / last slice" }, + ], + }, + { + title: "Run it", + body: <>Once both slices are picked, press "Copy across slices"., + }, + ]; + + case "hollow": + return [ + { + title: "Turn a solid segment into a shell", + body: <>Replaces the segment with a uniform-thickness shell — useful for wall thickness or 3D-printable casings., + targetRect: r.panelRect, + }, + { + title: "Choose the surface", + body: <>Inside, medial, or outside — controls whether the shell grows inward from the current boundary, outward, or straddles it., + targetRect: r.fieldRect ?? r.panelRect, + }, + { + title: "Set thickness", + body: <>Drag the slider or type an exact value in millimeters., + targetRect: r.fieldRect ?? r.panelRect, + }, + maskingStep(r), + ]; + + default: + return [ + { + title: "This tool", + body: <>Adjust the controls in the panel, then press Apply., + targetRect: r.panelRect, + }, + maskingStep(r), + ]; + } +} \ No newline at end of file diff --git a/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx b/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx index f80724d..94bab80 100644 --- a/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx +++ b/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx @@ -8,7 +8,7 @@ import { NEW_CLASS_PALETTE } from "./constants"; import vtkImageData from "@kitware/vtk.js/Common/DataModel/ImageData"; import vtkDataArray from "@kitware/vtk.js/Common/Core/DataArray"; import vtkImageMarchingCubes from "@kitware/vtk.js/Filters/General/ImageMarchingCubes"; - +import type { MaskingArea } from "../components/segmentation/MaskingSelect"; type viewportIdTypes = 'CT_NIFTI_AXIAL' | 'CT_NIFTI_SAGITTAL' | 'CT_NIFTI_CORONAL'; const { @@ -135,7 +135,7 @@ let _lastColorLUT: ColorLUT | null = null; // User-created segment labels, reset on each case load. let _customSegmentLabels: Record = {}; -let _crosshairChangeCallbacks = new Set<(mm: number[]) => void>(); +const _crosshairChangeCallbacks = new Set<(mm: number[]) => void>(); let _isSyncing = false; let _crosshairListenerRegistered = false; @@ -513,7 +513,10 @@ export async function renderVisualization(ref1: HTMLDivElement, ref2: HTMLDivEle } } ]); + if (segmentationURL && segmentationImageIds.length > 0) { + segmentation.activeSegmentation.setActiveSegmentation(viewport.viewportId, segmentationId); + } }); } @@ -693,14 +696,6 @@ export function setActiveMaskEditTool(toolName: MaskEditToolName | null) { }); } -export function setActiveEditSegment(segmentIndex: number) { - _activeEditSegment = segmentIndex; - try { - segmentation.segmentIndex.setActiveSegmentIndex(segmentationId, segmentIndex); - } catch { - /* segmentation not loaded yet */ - } -} // Picks a color for a brand-new segment index by cycling through the NEW_CLASS_PALETTE. export function colorForNewClass(segmentIndex: number): Color { return NEW_CLASS_PALETTE[(segmentIndex - 1) % NEW_CLASS_PALETTE.length]; @@ -1780,6 +1775,57 @@ export function getOrganLabelAtPoint(pane: CinePane, clientX: number, clientY: n if (typeof res === "number") return res; } +// Resolves a raw page click (clientX/clientY) to "which pane, which viewport slice, +// what's under the cursor" — without the caller needing to already know which pane +// was clicked. Tries every MPR pane's canvas in turn (cheap — at most 3 bounding-rect +// checks) and returns the first one whose canvas actually contains the point. Powers +// the guided "click the shape on this slice" pickers (copy/interpolate) so the user +// never has to type a pane name or a slice number themselves. +export function pickSliceAnchorAtClientPoint( + clientX: number, + clientY: number +): { pane: CinePane; sliceIndex: number; segmentAtPoint: number | undefined } | null { + const engine = getRenderingEngine(renderingEngineId); + if (!engine) return null; + const volume = cache.getVolume(segmentationId); + if (!volume?.voxelManager || !volume.imageData) return null; + + const panes = Object.keys(CINE_VIEWPORT_BY_PANE) as CinePane[]; + for (const pane of panes) { + const viewport = engine.getViewport(CINE_VIEWPORT_BY_PANE[pane]) as unknown as + | { getCanvas(): HTMLCanvasElement; canvasToWorld(canvasPos: Point2): Point3; getSliceIndex(): number } + | undefined; + if (!viewport) continue; + + let canvas: HTMLCanvasElement; + try { + canvas = viewport.getCanvas(); + } catch { + continue; + } + const rect = canvas.getBoundingClientRect(); + if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) continue; + + const canvasPos: Point2 = [clientX - rect.left, clientY - rect.top]; + let world: Point3; + try { + world = viewport.canvasToWorld(canvasPos); + } catch { + continue; + } + const [i, j, k] = volume.imageData.worldToIndex(world).map((v: number) => Math.round(v)); + const [dimX, dimY, dimZ] = volume.voxelManager.dimensions; + const inBounds = i >= 0 && j >= 0 && k >= 0 && i < dimX && j < dimY && k < dimZ; + const raw = inBounds ? volume.voxelManager.getAtIJK(i, j, k) : undefined; + return { + pane, + sliceIndex: viewport.getSliceIndex(), + segmentAtPoint: typeof raw === "number" ? raw : undefined, + }; + } + return null; +} + // Centroid (world mm) of every segment label, from one pass over the labelmap. Cached for // the loaded case (reset in renderVisualization). Lets the UI jump the crosshair to an // organ. Returns null until the segmentation volume is available. @@ -1940,6 +1986,11 @@ function _transformVertices( } return out; } +// ============================================================================ +// SECTION: Live Mesh Extraction (Marching Cubes) +// ============================================================================ + + // ============================================================================ // SECTION: Threshold Fill (Dual-Scribble) // ============================================================================ @@ -1951,26 +2002,102 @@ function _transformVertices( // then flood-fills in 3D from the foreground seeds, stopping at that // threshold, the seeds' bounding box, and any other segment's voxels. // --------------------------------------------------------------------------- +// Robust stats: mean/min/max are kept for display, but the threshold itself +// is derived from percentiles (median + a trimmed spread) so a single stray +// scribble voxel landing on a partial-volume edge pixel can't drag the whole +// split threshold toward the wrong tissue the way a raw mean can. +function _huStats(ctData: ArrayLike, idxOf: (i: number, j: number, k: number) => number, seeds: [number, number, number][]) { + const values: number[] = new Array(seeds.length); + let min = Infinity, max = -Infinity, sum = 0; + for (let s = 0; s < seeds.length; s++) { + const [i, j, k] = seeds[s]; + const v = ctData[idxOf(i, j, k)]; + values[s] = v; + if (v < min) min = v; + if (v > max) max = v; + sum += v; + } + values.sort((a, b) => a - b); + const percentile = (p: number) => { + if (values.length === 1) return values[0]; + const idx = p * (values.length - 1); + const lo = Math.floor(idx), hi = Math.ceil(idx); + return values[lo] + (values[hi] - values[lo]) * (idx - lo); + }; + return { min, max, mean: sum / seeds.length, median: percentile(0.5), p25: percentile(0.25), p75: percentile(0.75) }; +} + +export type DualFillStats = ReturnType; + +// Computes the separating HU threshold from a foreground/background scribble +// pair without touching the segmentation — lets the UI show "growing at +// -40 HU" and offer a slider to nudge it before (or after) the fill runs, +// the way 3D Slicer's Local Threshold effect surfaces its computed value. +export function computeDualScribbleThreshold( + foregroundSeeds: Array<[number, number, number]>, + backgroundSeeds: Array<[number, number, number]> +): { threshold: number; fgIsBrighter: boolean; fgStats: DualFillStats; bgStats: DualFillStats } | null { + if (!foregroundSeeds.length || !backgroundSeeds.length) return null; + const ctVolume = _currentCtVolumeId ? cache.getVolume(_currentCtVolumeId) : undefined; + if (!ctVolume) return null; + const ctVm = ctVolume.voxelManager as any; + if (!ctVm) return null; + const [dimX, dimY] = ctVm.dimensions; + let ctData: ArrayLike | undefined; + try { ctData = ctVm.getCompleteScalarDataArray?.(); } catch { /* fall through */ } + if (!ctData || !ctData.length) return null; + const idxOf = (i: number, j: number, k: number) => i + j * dimX + k * dimX * dimY; + + const fgStats = _huStats(ctData, idxOf, foregroundSeeds); + const bgStats = _huStats(ctData, idxOf, backgroundSeeds); + const fgIsBrighter = fgStats.median >= bgStats.median; + // Split the gap between the *closest-facing* robust edges of each + // distribution (fg's p25 vs bg's p75, or vice versa) rather than the + // means — this keeps the line near the true tissue boundary even when + // one scribble has more outliers than the other. + const fgEdge = fgIsBrighter ? fgStats.p25 : fgStats.p75; + const bgEdge = fgIsBrighter ? bgStats.p75 : bgStats.p25; + const threshold = (fgEdge + bgEdge) / 2; + return { threshold, fgIsBrighter, fgStats, bgStats }; +} export type DualFillOptions = { connectivity?: 6 | 26; maxVoxels?: number; boundingBoxMargin?: number; respectOtherLabels?: boolean; - sliceLock?: { pane: CinePane } | null; // restrict the fill to the seeds' own slice + sliceLock?: { pane: CinePane } | null; + maskFilter?: MaskFilter; + // Overrides the auto-computed threshold — wire this to a slider so the + // radiologist can tighten/loosen the fill after seeing the preview, + // instead of having to re-scribble to change the result. + manualThresholdHu?: number; + // Post-fill morphological closing (dilate-then-erode, in voxels) that + // patches single-voxel pinholes and smooths the jagged edge a raw + // flood-fill leaves on noisy CT. 0/undefined = off. 1 is usually enough. + closingRadius?: number; + // When true, computes and returns the mask without writing it to the + // segmentation — for a live "ghost" preview overlay while the user is + // still scribbling, mirroring Slicer's continuous preview. + dryRun?: boolean; }; + export function runDualScribbleFill( foregroundSeeds: Array<[number, number, number]>, backgroundSeeds: Array<[number, number, number]>, options: DualFillOptions = {} -): { filledVoxels: number } | null { +): { filledVoxels: number; threshold: number; voxels?: Array<[number, number, number]> } | null { const { connectivity = 6, maxVoxels = 2_000_000, boundingBoxMargin = 30, - respectOtherLabels = false, // was true — smart fill now overwrites like the brush does + respectOtherLabels = false, sliceLock = null, + maskFilter = () => true, + manualThresholdHu, + closingRadius = 0, + dryRun = false, } = options; if (!foregroundSeeds.length || !backgroundSeeds.length) { @@ -2002,8 +2129,11 @@ export function runDualScribbleFill( const fgStats = _huStats(ctData, idxOf, fgValid); const bgStats = _huStats(ctData, idxOf, bgValid); - const fgIsBrighter = fgStats.mean >= bgStats.mean; - const threshold = (fgStats.mean + bgStats.mean) / 2; + const fgIsBrighter = fgStats.median >= bgStats.median; + const fgEdge = fgIsBrighter ? fgStats.p25 : fgStats.p75; + const bgEdge = fgIsBrighter ? bgStats.p75 : bgStats.p25; + const autoThreshold = (fgEdge + bgEdge) / 2; + const threshold = manualThresholdHu ?? autoThreshold; const passes = (hu: number) => (fgIsBrighter ? hu >= threshold : hu <= threshold); let bi0 = dimX, bi1 = -1, bj0 = dimY, bj1 = -1, bk0 = dimZ, bk1 = -1; @@ -2046,7 +2176,11 @@ export function runDualScribbleFill( if (!visited.has(lin)) { visited.add(lin); stack.push(lin); } } - const touched: Array<{ i: number; j: number; k: number; prev: number }> = []; + // Mask of every voxel the flood-fill accepts, keyed by linear index — built + // regardless of dryRun so the (optional) closing pass and the preview path + // share one code path instead of duplicating the flood-fill logic. + const filledSet = new Set(); + const filledCoords: Array<[number, number, number]> = []; while (stack.length) { const lin = stack.pop()!; @@ -2056,15 +2190,17 @@ export function runDualScribbleFill( const i = rem - j * dimX; if (!passes(ctData[lin])) continue; + if (!maskFilter(i, j, k)) continue; // <-- gate - const existing: number = segVm.getAtIJK(i, j, k); - if (respectOtherLabels && existing !== 0 && existing !== activeSegment) continue; - if (existing !== activeSegment) { - touched.push({ i, j, k, prev: existing }); - segVm.setAtIJK(i, j, k, activeSegment); + if (!dryRun) { + const existing: number = segVm.getAtIJK(i, j, k); + if (respectOtherLabels && existing !== 0 && existing !== activeSegment) continue; } - if (touched.length > maxVoxels) { + filledSet.add(lin); + filledCoords.push([i, j, k]); + + if (filledCoords.length > maxVoxels) { console.warn("Smart fill: hit voxel safety cap, stopping early."); break; } @@ -2077,7 +2213,78 @@ export function runDualScribbleFill( } } - if (!touched.length) return { filledVoxels: 0 }; + if (!filledCoords.length) return { filledVoxels: 0, threshold }; + + if (dryRun) { + // Preview path: caller (the live-scribble overlay) draws these voxels as + // a ghost outline and never touches the segmentation. + return { filledVoxels: filledCoords.length, threshold, voxels: filledCoords }; + } + + // Morphological closing: dilate the accepted mask by `closingRadius`, then + // erode it back down. This patches the single-voxel pinholes and shaves + // the jagged edge that a raw HU-threshold flood-fill leaves on real + // (noisy) CT data, without letting the boundary drift outward net — the + // erode step removes exactly what the dilate step added, except where + // dilation bridged a gap and closed a hole for good. + let finalSet = filledSet; + if (closingRadius > 0) { + const dilate = (src: Set) => { + const out = new Set(src); + for (const lin of src) { + const k = Math.floor(lin / sliceSize); + const rem = lin - k * sliceSize; + const j = Math.floor(rem / dimX); + const i = rem - j * dimX; + for (const [di, dj, dk] of offsets26) { + const ni = i + di, nj = j + dj, nk = k + dk; + if (ni < bi0 || ni > bi1 || nj < bj0 || nj > bj1 || nk < bk0 || nk > bk1) continue; + if (!inBounds(ni, nj, nk)) continue; + const nlin = idxOf(ni, nj, nk); + if (!maskFilter(ni, nj, nk)) continue; + out.add(nlin); + } + } + return out; + }; + const erode = (src: Set) => { + const out = new Set(); + for (const lin of src) { + const k = Math.floor(lin / sliceSize); + const rem = lin - k * sliceSize; + const j = Math.floor(rem / dimX); + const i = rem - j * dimX; + let keep = true; + for (const [di, dj, dk] of offsets26) { + const ni = i + di, nj = j + dj, nk = k + dk; + if (ni < bi0 || ni > bi1 || nj < bj0 || nj > bj1 || nk < bk0 || nk > bk1) { keep = false; break; } + if (!src.has(idxOf(ni, nj, nk))) { keep = false; break; } + } + if (keep) out.add(lin); + } + return out; + }; + let grown = filledSet; + for (let r = 0; r < closingRadius; r++) grown = dilate(grown); + for (let r = 0; r < closingRadius; r++) grown = erode(grown); + finalSet = grown; + } + + const touched: Array<{ i: number; j: number; k: number; prev: number }> = []; + for (const lin of finalSet) { + const k = Math.floor(lin / sliceSize); + const rem = lin - k * sliceSize; + const j = Math.floor(rem / dimX); + const i = rem - j * dimX; + const existing: number = segVm.getAtIJK(i, j, k); + if (respectOtherLabels && existing !== 0 && existing !== activeSegment) continue; + if (existing !== activeSegment) { + touched.push({ i, j, k, prev: existing }); + segVm.setAtIJK(i, j, k, activeSegment); + } + } + + if (!touched.length) return { filledVoxels: 0, threshold }; _pushFillHistory({ undo: () => { for (const { i, j, k, prev } of touched) segVm.setAtIJK(i, j, k, prev); _notifySegmentationChanged(); }, @@ -2085,20 +2292,8 @@ export function runDualScribbleFill( }); _notifySegmentationChanged(); - return { filledVoxels: touched.length }; -} - -function _huStats(ctData: ArrayLike, idxOf: (i: number, j: number, k: number) => number, seeds: [number, number, number][]) { - let min = Infinity, max = -Infinity, sum = 0; - for (const [i, j, k] of seeds) { - const v = ctData[idxOf(i, j, k)]; - if (v < min) min = v; - if (v > max) max = v; - sum += v; - } - return { min, max, mean: sum / seeds.length }; + return { filledVoxels: touched.length, threshold }; } - function _notifySegmentationChanged() { try { // This is what BrushTool's own strategies call after painting — it invalidates the @@ -2133,6 +2328,14 @@ function _pushFillHistory(entry: FillHistoryEntry) { _fillHistoryIndex = _fillHistory.length - 1; } +// Exposed so hooks/components outside this module (e.g. useSmartFill's +// scribble-point placement) can register their own undo/redo pairs on the +// same shared history stack as every other edit tool, rather than keeping +// a separate parallel undo system just for scribbles. +export function pushEditHistory(entry: FillHistoryEntry) { + _pushFillHistory(entry); +} + export function undoSmartFill(): boolean { if (_fillHistoryIndex < 0) return false; _fillHistory[_fillHistoryIndex].undo(); @@ -2172,6 +2375,511 @@ function _sliceAxisForPane(pane: CinePane): 0 | 1 | 2 { return pane === "sagittal" ? 0 : pane === "coronal" ? 1 : 2; } +// Public wrapper — lets callers outside this module (e.g. the level-tracing click +// handler) resolve which IJK axis a pane scrolls along without duplicating the mapping. +export function sliceAxisForPane(pane: CinePane): 0 | 1 | 2 { + return _sliceAxisForPane(pane); +} + +// ============================================================================ +// SECTION: Magnetic Lasso ("Live Wire" / Intelligent Scissors) — Mortensen & +// Barrett's classic formulation. The current slice is treated as a weighted +// graph (each pixel a node, 8-connected to its neighbors); edge cost is low +// across a strong intensity boundary and high in flat regions, so the +// lowest-cost path between two points (found via Dijkstra) hugs nearby edges +// instead of cutting straight through tissue. A user click still "freezes" a +// fastening point exactly where clicked (usePolygonDraw handles that part); +// this function only answers "what's the best path from the last fastening +// point to here" for the live preview, and for baking that path in on click. +// ============================================================================ + +// Cost image window is capped for perf/latency (this runs on every mousemove). +// If the last fastening point and the cursor are further apart than this (in +// voxels), the search window would be too large to stay interactive — the +// caller falls back to a straight segment for that leg instead. +const LIVEWIRE_MAX_SPAN_VOXELS = 110; +const LIVEWIRE_WINDOW_MARGIN = 8; + +// Static per-pixel cost weights (Mortensen & Barrett's fG/fZ/fD terms). +const LIVEWIRE_W_GRADIENT = 0.55; +const LIVEWIRE_W_LAPLACIAN = 0.25; +const LIVEWIRE_W_DIRECTION = 0.20; + +// Minimal binary min-heap keyed by a numeric priority — enough for Dijkstra +// over a few thousand nodes without pulling in a dependency. +class _MinHeap { + private heap: Array<{ node: number; dist: number }> = []; + get size() { return this.heap.length; } + push(node: number, dist: number) { + const h = this.heap; + h.push({ node, dist }); + let i = h.length - 1; + while (i > 0) { + const parent = (i - 1) >> 1; + if (h[parent].dist <= h[i].dist) break; + [h[parent], h[i]] = [h[i], h[parent]]; + i = parent; + } + } + pop(): { node: number; dist: number } | undefined { + const h = this.heap; + if (!h.length) return undefined; + const top = h[0]; + const last = h.pop()!; + if (h.length) { + h[0] = last; + let i = 0; + for (;;) { + const l = i * 2 + 1, r = i * 2 + 2; + let smallest = i; + if (l < h.length && h[l].dist < h[smallest].dist) smallest = l; + if (r < h.length && h[r].dist < h[smallest].dist) smallest = r; + if (smallest === i) break; + [h[smallest], h[i]] = [h[i], h[smallest]]; + i = smallest; + } + } + return top; + } +} + +/** + * Lowest-cost ("live wire") path from `from` to `to`, both canvas points on + * `pane`, hugging nearby intensity edges. Returns a dense array of canvas + * points from `from` to `to` inclusive, or null if the two points are too + * far apart to search interactively, or anything needed is unavailable — + * callers should fall back to a straight line between the two points. + */ +export function computeLiveWirePath( + pane: CinePane, + from: Point2, + to: Point2 +): Array<[number, number]> | null { + const engine = getRenderingEngine(renderingEngineId); + if (!engine) return null; + const viewport = engine.getViewport(CINE_VIEWPORT_BY_PANE[pane]) as any; + const ctVolume = _currentCtVolumeId ? cache.getVolume(_currentCtVolumeId) : undefined; + if (!viewport || !ctVolume?.imageData) return null; + const ctVm = ctVolume.voxelManager as any; + if (!ctVm) return null; + + let ctData: ArrayLike | undefined; + try { ctData = ctVm.getCompleteScalarDataArray?.(); } catch { /* fall through */ } + if (!ctData || !ctData.length) return null; + + const [dimX, dimY, dimZ] = ctVm.dimensions; + const axis = _sliceAxisForPane(pane); + const [sliceDimA, sliceDimB] = axis === 2 ? [dimX, dimY] : axis === 0 ? [dimY, dimZ] : [dimX, dimZ]; + const sliceSize = dimX * dimY; + const idxOf = (i: number, j: number, k: number) => i + j * dimX + k * sliceSize; + + const toIJK = (canvasPos: Point2): [number, number, number] | null => { + try { + const world = viewport.canvasToWorld(canvasPos); + return ctVolume.imageData.worldToIndex(world).map((v: number) => Math.round(v)) as [number, number, number]; + } catch { + return null; + } + }; + const fromIJK = toIJK(from); + const toIJK_ = toIJK(to); + if (!fromIJK || !toIJK_) return null; + + const aOf = (ijk: [number, number, number]) => (axis === 2 ? ijk[0] : axis === 0 ? ijk[1] : ijk[0]); + const bOf = (ijk: [number, number, number]) => (axis === 2 ? ijk[1] : axis === 0 ? ijk[2] : ijk[2]); + const sliceOf = (a: number, b: number): [number, number, number] => + axis === 2 ? [a, b, fromIJK[2]] : axis === 0 ? [fromIJK[0], a, b] : [a, fromIJK[1], b]; + + const seedA = aOf(fromIJK), seedB = bOf(fromIJK); + const targetA = aOf(toIJK_), targetB = bOf(toIJK_); + + if (Math.hypot(targetA - seedA, targetB - seedB) > LIVEWIRE_MAX_SPAN_VOXELS) return null; + + // Local search window: bounding box of the two endpoints, padded, clamped + // to the slice. + const winA0 = Math.max(0, Math.min(seedA, targetA) - LIVEWIRE_WINDOW_MARGIN); + const winA1 = Math.min(sliceDimA - 1, Math.max(seedA, targetA) + LIVEWIRE_WINDOW_MARGIN); + const winB0 = Math.max(0, Math.min(seedB, targetB) - LIVEWIRE_WINDOW_MARGIN); + const winB1 = Math.min(sliceDimB - 1, Math.max(seedB, targetB) + LIVEWIRE_WINDOW_MARGIN); + const winW = winA1 - winA0 + 1, winH = winB1 - winB0 + 1; + if (winW < 2 || winH < 2) return null; + + const huAt = (a: number, b: number): number => { + const ca = Math.min(winA1, Math.max(winA0, a)); + const cb = Math.min(winB1, Math.max(winB0, b)); + const [i, j, k] = sliceOf(ca, cb); + return ctData![idxOf(i, j, k)]; + }; + + const local = (a: number, b: number) => (a - winA0) + (b - winB0) * winW; + const n = winW * winH; + + // Precompute Sobel gradient (gx, gy) and a simple discrete Laplacian at + // every pixel in the window, tracking the max magnitudes for normalization. + const gx = new Float32Array(n), gy = new Float32Array(n), lap = new Float32Array(n); + let maxGradMag = 0, maxAbsLap = 0; + for (let b = winB0; b <= winB1; b++) { + for (let a = winA0; a <= winA1; a++) { + const li = local(a, b); + const l = huAt(a - 1, b), r = huAt(a + 1, b), u = huAt(a, b - 1), d = huAt(a, b + 1); + const c = huAt(a, b); + const ggx = r - l, ggy = d - u; + gx[li] = ggx; gy[li] = ggy; + const mag = Math.hypot(ggx, ggy); + if (mag > maxGradMag) maxGradMag = mag; + const l2 = l + r + u + d - 4 * c; + lap[li] = l2; + const absLap = Math.abs(l2); + if (absLap > maxAbsLap) maxAbsLap = absLap; + } + } + if (maxGradMag <= 0) return null; // perfectly flat window — nothing to hug + + // Static per-node cost: low near strong edges (fG) and near Laplacian + // zero-crossings (fZ) — both normalized to [0, 1], low = "cheap to cross". + const nodeCost = new Float32Array(n); + for (let li = 0; li < n; li++) { + const fG = 1 - Math.hypot(gx[li], gy[li]) / maxGradMag; + const fZ = maxAbsLap > 0 ? Math.abs(lap[li]) / maxAbsLap : 0; + nodeCost[li] = LIVEWIRE_W_GRADIENT * fG + LIVEWIRE_W_LAPLACIAN * fZ; + } + + const seedLocal = local(seedA, seedB); + const targetLocal = local(targetA, targetB); + + const dist = new Float32Array(n).fill(Infinity); + const prev = new Int32Array(n).fill(-1); + const visited = new Uint8Array(n); + dist[seedLocal] = 0; + + const heap = new _MinHeap(); + heap.push(seedLocal, 0); + + const NEIGHBORS: Array<[number, number]> = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]; + + while (heap.size) { + const { node: cur, dist: curDist } = heap.pop()!; + if (visited[cur]) continue; + visited[cur] = 1; + if (cur === targetLocal) break; + + const curA = winA0 + (cur % winW), curB = winB0 + Math.floor(cur / winW); + // Direction of travel into `cur` (used for the bending penalty on the + // NEXT step) — undefined at the seed itself. + const prevNode = prev[cur]; + let inDirX = 0, inDirY = 0, hasInDir = false; + if (prevNode >= 0) { + const pa = winA0 + (prevNode % winW), pb = winB0 + Math.floor(prevNode / winW); + const ddx = curA - pa, ddy = curB - pb; + const dlen = Math.hypot(ddx, ddy) || 1; + inDirX = ddx / dlen; inDirY = ddy / dlen; + hasInDir = true; + } + + for (const [da, db] of NEIGHBORS) { + const na = curA + da, nb = curB + db; + if (na < winA0 || na > winA1 || nb < winB0 || nb > winB1) continue; + const ni = local(na, nb); + if (visited[ni]) continue; + + const linkLen = Math.hypot(da, db); // 1 or sqrt(2) + // Direction/bending cost: penalize turning sharply relative to the + // incoming direction, so the path favors smooth, continuous curves + // over jagged zig-zags (fD in Mortensen & Barrett). + let fD = 0; + if (hasInDir) { + const outLen = linkLen; + const dot = (inDirX * da + inDirY * db) / outLen; + fD = 1 - Math.max(-1, Math.min(1, dot)); // 0 = straight ahead, up to 2 = reversal + fD = fD / 2; // normalize to [0, 1] + } + const stepCost = linkLen * (nodeCost[ni] + LIVEWIRE_W_DIRECTION * fD + 0.02); // small floor avoids zero-cost loops + const nd = curDist + stepCost; + if (nd < dist[ni]) { + dist[ni] = nd; + prev[ni] = cur; + heap.push(ni, nd); + } + } + } + + if (!visited[targetLocal] && dist[targetLocal] === Infinity) return null; + + // Walk back from target to seed, then reverse. + const pathLocal: number[] = []; + let walk = targetLocal; + let guard = n + 1; + while (walk !== -1 && guard-- > 0) { + pathLocal.push(walk); + if (walk === seedLocal) break; + walk = prev[walk]; + } + if (pathLocal[pathLocal.length - 1] !== seedLocal) return null; // unreachable + pathLocal.reverse(); + + const points: Array<[number, number]> = []; + for (const li of pathLocal) { + const a = winA0 + (li % winW), b = winB0 + Math.floor(li / winW); + const [i, j, k] = sliceOf(a, b); + try { + const world = ctVolume.imageData.indexToWorld([i, j, k]); + const canvasPt = viewport.worldToCanvas(world); + points.push([canvasPt[0], canvasPt[1]]); + } catch { + /* skip unmappable point */ + } + } + return points.length >= 2 ? points : null; +} + +// ============================================================================ +// SECTION: Margin (mm-based grow/shrink) — Slicer's Margin effect converts a +// physical mm size to an iteration count per axis via spacing, then reuses +// the same erode/dilate voxel logic already in this file. +// ============================================================================ + +export function applyMargin( + operation: "grow" | "shrink", + marginMm: number, + applyToVisibleSegments = false, + visibleSegmentIndices: number[] = [], + maskFilter: MaskFilter = () => true +): { changedVoxels: number } | null { + const segVolume = cache.getVolume(segmentationId); + if (!segVolume) return null; + const spacing = segVolume.spacing as number[]; + const avgSpacing = (spacing[0] + spacing[1] + spacing[2]) / 3; + // Clamp to a sane range: guards against NaN/Infinity if spacing is + // degenerate, and against a runaway iteration count for extreme mm inputs. + // (BFS in _morphDistanceMask is O(volume) regardless of iteration count, + // so this cap is a sanity/UX limit rather than a perf necessity now — but + // there's no reason to march further than the volume can possibly span.) + const rawIterations = marginMm / avgSpacing; + const iterations = Number.isFinite(rawIterations) ? Math.min(2000, Math.max(1, Math.round(rawIterations))) : 1; + const targets = applyToVisibleSegments && visibleSegmentIndices.length ? visibleSegmentIndices : [_activeEditSegment]; + + let total = 0; + const savedActive = _activeEditSegment; + for (const segmentIndex of targets) { + _activeEditSegment = segmentIndex; + const r = operation === "grow" + ? dilateActiveSegment(iterations, 6, undefined, maskFilter) + : erodeActiveSegment(iterations, 6, undefined, maskFilter); + if (r) total += r.changedVoxels; + } + _activeEditSegment = savedActive; + return { changedVoxels: total }; +} +// Actual physical margin size given the current pixel-space (for the "Actual: 2.5 x 2.5 x 2.4mm" readout). +export function getActualMarginMm(marginMm: number): { mm: [number, number, number]; voxels: [number, number, number] } | null { + const segVolume = cache.getVolume(segmentationId); + if (!segVolume) return null; + const spacing = segVolume.spacing as number[]; + const voxels: [number, number, number] = [ + Math.max(1, Math.round(marginMm / spacing[0])), + Math.max(1, Math.round(marginMm / spacing[1])), + Math.max(1, Math.round(marginMm / spacing[2])), + ]; + return { mm: [voxels[0] * spacing[0], voxels[1] * spacing[1], voxels[2] * spacing[2]], voxels }; +} + +// ============================================================================ +// SECTION: Hollow — mirrors Slicer's Hollow effect: converts the active +// segment into a uniform-thickness shell, using the original segment +// boundary as the inside / medial / outside surface of that shell. Built on +// the same BFS distance-transform primitive (_morphDistanceMask) that powers +// erode/dilate/margin above, so it inherits the same O(volume) performance +// regardless of shell thickness. +// ============================================================================ + +export type HollowSurface = "inside" | "medial" | "outside"; + +function _hollowShellMask( + orig: Uint8Array, w: number, h: number, d: number, + offsets: number[][], surface: HollowSurface, thicknessIter: number +): Uint8Array { + const out = new Uint8Array(orig.length); + if (surface === "inside") { + // Original segment is the OUTSIDE of the shell: shell = original minus (original eroded by thickness). + const eroded = _morphDistanceMask(orig, w, h, d, offsets, thicknessIter, "erode"); + for (let li = 0; li < orig.length; li++) out[li] = orig[li] && !eroded[li] ? 1 : 0; + return out; + } + if (surface === "outside") { + // Original segment is the INSIDE of the shell: shell = (original dilated by thickness) minus original. + const dilated = _morphDistanceMask(orig, w, h, d, offsets, thicknessIter, "dilate"); + for (let li = 0; li < orig.length; li++) out[li] = dilated[li] && !orig[li] ? 1 : 0; + return out; + } + // "medial": original boundary runs through the middle of the shell — split + // the thickness evenly, growing half outward and half inward from it. + const half = Math.max(1, Math.round(thicknessIter / 2)); + const eroded = _morphDistanceMask(orig, w, h, d, offsets, half, "erode"); + const dilated = _morphDistanceMask(orig, w, h, d, offsets, half, "dilate"); + for (let li = 0; li < orig.length; li++) out[li] = dilated[li] && !eroded[li] ? 1 : 0; + return out; +} + +export function applyHollow( + surface: HollowSurface, + thicknessMm: number, + connectivity: 6 | 26 = 6, + maskFilter: MaskFilter = () => true +): { changedVoxels: number } | null { + const segVolume = cache.getVolume(segmentationId); + const vmGlobal = segVolume?.voxelManager as any; + if (!segVolume || !vmGlobal) return null; + const activeSegment = _activeEditSegment; + + const spacing = segVolume.spacing as number[]; + const avgSpacing = (spacing[0] + spacing[1] + spacing[2]) / 3; + const rawIterations = thicknessMm / avgSpacing; + const iterations = Number.isFinite(rawIterations) ? Math.min(2000, Math.max(1, Math.round(rawIterations))) : 1; + + const margin = iterations + 1; + const tight = _segBBox(vmGlobal, activeSegment, 0); + const thinAxis = tight ? _tightExtentAxis(tight.i0, tight.i1, tight.j0, tight.j1, tight.k0, tight.k1) : null; + const bbox = _segBBox(vmGlobal, activeSegment, margin); + if (!bbox) return null; + const { i0, i1, j0, j1, k0, k1 } = bbox; + const w = i1 - i0 + 1, h = j1 - j0 + 1, d = k1 - k0 + 1; + const idxLocal = (i: number, j: number, k: number) => (i - i0) + (j - j0) * w + (k - k0) * w * h; + + const offsets = _filterOffsetsAxis(connectivity === 6 ? _OFFSETS6 : _OFFSETS26, thinAxis); + + const orig = new Uint8Array(w * h * d); + for (let k = k0; k <= k1; k++) for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) + if (vmGlobal.getAtIJK(i, j, k) === activeSegment) orig[idxLocal(i, j, k)] = 1; + + const shell = _hollowShellMask(orig, w, h, d, offsets, surface, iterations); + + const changes: Array<{ i: number; j: number; k: number; prev: number; next: number }> = []; + for (let k = k0; k <= k1; k++) for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) { + const li = idxLocal(i, j, k); + if (!maskFilter(i, j, k)) continue; // <-- global masking gate + const wantFg = shell[li] === 1; + const existing = vmGlobal.getAtIJK(i, j, k); + if (wantFg && existing !== activeSegment) { + if (existing !== 0) continue; + changes.push({ i, j, k, prev: existing, next: activeSegment }); + } else if (!wantFg && existing === activeSegment) { + changes.push({ i, j, k, prev: existing, next: 0 }); + } + } + if (!changes.length) return { changedVoxels: 0 }; + for (const c of changes) vmGlobal.setAtIJK(c.i, c.j, c.k, c.next); + _pushFillHistory({ + undo: () => { for (const c of changes) vmGlobal.setAtIJK(c.i, c.j, c.k, c.prev); _notifySegmentationChanged(); }, + redo: () => { for (const c of changes) vmGlobal.setAtIJK(c.i, c.j, c.k, c.next); _notifySegmentationChanged(); }, + }); + _notifySegmentationChanged(); + return { changedVoxels: changes.length }; +} + +// Actual physical shell thickness given the current pixel spacing — same +// mm→voxel rounding as getActualMarginMm, reused here for the Hollow panel's +// "Actual: 2.5 x 2.5 x 2.4mm (4x4x3 pixel)" readout. +export function getActualHollowMm(thicknessMm: number): { mm: [number, number, number]; voxels: [number, number, number] } | null { + return getActualMarginMm(thicknessMm); +} + +// ============================================================================ +// SECTION: Islands — mirrors Slicer's Islands effect exactly: Keep largest +// island / Remove small islands / Split islands to segments / Keep selected +// island / Remove selected island. Built on the same connected-component +// labeling as _isolateComponentAt/_activeSegmentComponents. +// ============================================================================ + +export type IslandsOperation = "keepLargest" | "removeSmall" | "splitToSegments" | "keepSelected" | "removeSelected"; + +export function applyIslandsOperation( + operation: IslandsOperation, + minimumSizeVoxels = 1000, + seedVoxel?: [number, number, number], + maskFilter: MaskFilter = () => true +): { changedVoxels: number; newSegmentsCreated?: number; createdSegments?: { id: number; label: string; color: Color }[] } | null { + const comp = _activeSegmentComponents(26); + if (!comp) return null; + const { vm, bbox, w, h, labels, sizes } = comp; + const { i0, j0, k0 } = bbox; + const activeSegment = _activeEditSegment; + + const changes: Array<{ i: number; j: number; k: number; prev: number; next: number }> = []; + const idxLocal3 = (i: number, j: number, k: number) => (i - i0) + (j - j0) * w + (k - k0) * w * h; + + const largestLabel = sizes.length ? sizes.indexOf(Math.max(...sizes)) : -1; + let selectedLabel = -1; + if (seedVoxel && (operation === "keepSelected" || operation === "removeSelected")) { + const [si, sj, sk] = seedVoxel; + const li = si - i0, lj = sj - j0, lk = sk - k0; + if (li >= 0 && lj >= 0 && lk >= 0 && li < w && lj < h) { + selectedLabel = labels[li + lj * w + lk * w * h]; + } + } + + let newSegmentsCreated = 0; + const createdSegments: { id: number; label: string; color: Color }[] = []; + const newLabelForComponent = new Map(); + + const walkAndDecide = (i: number, j: number, k: number, existing: number) => { + const label = labels[idxLocal3(i, j, k)]; + if (label === undefined || label === -1) return; + const size = sizes[label]; + + switch (operation) { + case "keepLargest": + if (label !== largestLabel) changes.push({ i, j, k, prev: existing, next: 0 }); + break; + case "removeSmall": + if (size < minimumSizeVoxels) changes.push({ i, j, k, prev: existing, next: 0 }); + break; + case "keepSelected": + if (selectedLabel !== -1 && label !== selectedLabel) changes.push({ i, j, k, prev: existing, next: 0 }); + break; + case "removeSelected": + if (selectedLabel !== -1 && label === selectedLabel) changes.push({ i, j, k, prev: existing, next: 0 }); + break; + case "splitToSegments": { + if (!newLabelForComponent.has(label)) { + const nextIdx = _getNextAvailableSegmentIndex() + newLabelForComponent.size; + newLabelForComponent.set(label, nextIdx); + } + const target = newLabelForComponent.get(label)!; + if (target !== activeSegment) changes.push({ i, j, k, prev: existing, next: target }); + break; + } + } + }; + + for (let k = bbox.k0; k <= bbox.k1; k++) + for (let j = bbox.j0; j <= bbox.j1; j++) + for (let i = bbox.i0; i <= bbox.i1; i++) { + if (!maskFilter(i, j, k)) continue; + const existing = vm.getAtIJK(i, j, k); + if (existing !== activeSegment) continue; + walkAndDecide(i, j, k, existing); + } // <-- was missing + + if (operation === "splitToSegments") { + newSegmentsCreated = newLabelForComponent.size; + for (const newIdx of newLabelForComponent.values()) { + const color = colorForNewClass(newIdx); + const label = `Segment_${newIdx}`; + registerNewSegmentColor(newIdx, color); + _customSegmentLabels[newIdx] = label; + createdSegments.push({ id: newIdx, label, color }); + } + } + + if (!changes.length) return { changedVoxels: 0, newSegmentsCreated: 0, createdSegments }; + for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, c.next); + _pushFillHistory({ + undo: () => { for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, c.prev); _notifySegmentationChanged(); }, + redo: () => { for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, c.next); _notifySegmentationChanged(); }, + }); + _notifySegmentationChanged(); + return { changedVoxels: changes.length, newSegmentsCreated, createdSegments }; +} // ============================================================================ // SECTION: Morphology (Erode / Dilate) // ============================================================================ @@ -2222,39 +2930,93 @@ const _OFFSETS26: number[][] = (() => { return out; })(); -function _morphPass(arr: Uint8Array, w: number, h: number, d: number, offsets: number[][], mode: "dilate" | "erode"): Uint8Array { - const idx = (i: number, j: number, k: number) => i + j * w + k * w * h; - const next = new Uint8Array(arr.length); - for (let k = 0; k < d; k++) for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) { - const li = idx(i, j, k); - if (mode === "dilate") { - if (arr[li]) { next[li] = 1; continue; } - let found = false; - for (const [di, dj, dk] of offsets) { - const ni = i + di, nj = j + dj, nk = k + dk; - if (ni < 0 || ni >= w || nj < 0 || nj >= h || nk < 0 || nk >= d) continue; - if (arr[idx(ni, nj, nk)]) { found = true; break; } +// Applying `_morphPass` `iterations` times is O(iterations * volume) — fine +// for the small radii used by e.g. post-fill closing, but at margin-tool +// scale (a 100mm grow/shrink can mean iterations in the hundreds) it turns +// into hundreds of full-volume scans and locks up the tab. +// +// A multi-source BFS computes the exact same result — because each +// iteration of `_morphPass` is just "expand one graph layer" using the same +// neighbor offsets — in a single O(volume) pass regardless of how large +// `iterations` is, since each voxel is only ever visited once. +// +// For "erode", a foreground voxel touching the array boundary is removed +// after 1 iteration in the original algorithm (an out-of-bounds neighbor +// counts as background). We reproduce that by padding the working volume +// with one voxel of background on every side before running BFS, so the +// boundary behaves exactly like real background instead of needing a +// special case in the traversal. +function _morphDistanceMask(seed: Uint8Array, w: number, h: number, d: number, offsets: number[][], iterations: number, mode: "dilate" | "erode"): Uint8Array { + if (iterations <= 0) return seed.slice(); + + const runBfs = (bg: Uint8Array, bw: number, bh: number, bd: number): Int32Array => { + const n = bw * bh * bd; + const dist = new Int32Array(n).fill(-1); + let frontier: number[] = []; + for (let li = 0; li < n; li++) if (bg[li]) { dist[li] = 0; frontier.push(li); } + let level = 0; + while (frontier.length && level < iterations) { + const next: number[] = []; + for (const li of frontier) { + const k = (li / (bw * bh)) | 0; + const rem = li - k * bw * bh; + const j = (rem / bw) | 0; + const i = rem - j * bw; + for (const [di, dj, dk] of offsets) { + const ni = i + di, nj = j + dj, nk = k + dk; + if (ni < 0 || ni >= bw || nj < 0 || nj >= bh || nk < 0 || nk >= bd) continue; + const nli = ni + nj * bw + nk * bw * bh; + if (dist[nli] === -1) { dist[nli] = level + 1; next.push(nli); } + } } - next[li] = found ? 1 : 0; - } else { - if (!arr[li]) { next[li] = 0; continue; } - let allFg = true; - for (const [di, dj, dk] of offsets) { - const ni = i + di, nj = j + dj, nk = k + dk; - if (ni < 0 || ni >= w || nj < 0 || nj >= h || nk < 0 || nk >= d) { allFg = false; break; } // volume edge = background - if (!arr[idx(ni, nj, nk)]) { allFg = false; break; } - } - next[li] = allFg ? 1 : 0; + frontier = next; + level++; + } + return dist; + }; + + const out = new Uint8Array(seed.length); + if (mode === "dilate") { + // Distance from any seeded foreground voxel; survives if within `iterations` steps. + const dist = runBfs(seed, w, h, d); + for (let li = 0; li < seed.length; li++) out[li] = dist[li] !== -1 ? 1 : 0; + return out; + } + + // erode: pad with a 1-voxel background shell so out-of-bounds = background, + // matching _morphPass's boundary behavior exactly. + const pw = w + 2, ph = h + 2, pd = d + 2; + const bg = new Uint8Array(pw * ph * pd); // 1 = background seed + for (let k = 0; k < d; k++) for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) { + const li = i + j * w + k * w * h; + if (!seed[li]) { + const pli = (i + 1) + (j + 1) * pw + (k + 1) * pw * ph; + bg[pli] = 1; } } - return next; + // Padded shell itself is background. + for (let k = 0; k < pd; k++) for (let j = 0; j < ph; j++) for (let i = 0; i < pw; i++) { + if (i === 0 || i === pw - 1 || j === 0 || j === ph - 1 || k === 0 || k === pd - 1) { + bg[i + j * pw + k * pw * ph] = 1; + } + } + const dist = runBfs(bg, pw, ph, pd); + for (let k = 0; k < d; k++) for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) { + const li = i + j * w + k * w * h; + if (!seed[li]) { out[li] = 0; continue; } + const pli = (i + 1) + (j + 1) * pw + (k + 1) * pw * ph; + const dv = dist[pli]; + out[li] = (dv === -1 || dv > iterations) ? 1 : 0; + } + return out; } function _applyMorphSequence( passes: Array<"dilate" | "erode">, iterationsEach: number, connectivity: 6 | 26, - seedVoxel?: [number, number, number] + seedVoxel?: [number, number, number], + maskFilter: MaskFilter = () => true ): { changedVoxels: number } | null { const segVolume = cache.getVolume(segmentationId); const vmGlobal = segVolume?.voxelManager as any; @@ -2265,13 +3027,21 @@ function _applyMorphSequence( let vm: any, i0: number, i1: number, j0: number, j1: number, k0: number, k1: number, w: number, h: number, d: number; let otherMask: Uint8Array | null = null; let thinAxis: number | null; + let seedSelfMask: Uint8Array | null = null; if (seedVoxel) { - const tight = _isolateComponentAt(seedVoxel, connectivity, 0); + // Connected-component labeling of the whole active segment is expensive + // (full-volume flood fill) — compute it once and reuse it for both the + // tight-extent check and the padded isolate call, instead of redoing it + // three separate times (the "island" scope used to pay for this 3x). + const comp = _activeSegmentComponents(connectivity); + if (!comp) return null; + const tight = _isolateComponentAt(seedVoxel, connectivity, 0, comp); thinAxis = tight ? _tightExtentAxis(tight.i0, tight.i1, tight.j0, tight.j1, tight.k0, tight.k1) : null; - const iso = _isolateComponentAt(seedVoxel, connectivity, margin); + const iso = _isolateComponentAt(seedVoxel, connectivity, margin, comp); if (!iso) return null; ({ vm, i0, i1, j0, j1, k0, k1, w, h, d, otherMask } = iso); + seedSelfMask = iso.selfMask; } else { const tight = _segBBox(vmGlobal, activeSegment, 0); thinAxis = tight ? _tightExtentAxis(tight.i0, tight.i1, tight.j0, tight.j1, tight.k0, tight.k1) : null; @@ -2287,21 +3057,25 @@ function _applyMorphSequence( let cur = new Uint8Array(w * h * d); if (seedVoxel) { - const iso = _isolateComponentAt(seedVoxel, connectivity, margin)!; - cur.set(iso.selfMask); + cur.set(seedSelfMask!); } else { for (let k = k0; k <= k1; k++) for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) if (vm.getAtIJK(i, j, k) === activeSegment) cur[idxLocal(i, j, k)] = 1; } + // BFS-based distance transform: exactly equivalent to running _morphPass + // `iterationsEach` times (each BFS layer == one pass), but O(volume) total + // instead of O(iterations * volume). This is what keeps large mm margins + // (which can translate to hundreds of iterations) from freezing the tab. for (const mode of passes) { - for (let it = 0; it < iterationsEach; it++) cur = _morphPass(cur, w, h, d, offsets, mode); + cur = _morphDistanceMask(cur, w, h, d, offsets, iterationsEach, mode); } const changes: Array<{ i: number; j: number; k: number; prev: number; next: number }> = []; for (let k = k0; k <= k1; k++) for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) { const li = idxLocal(i, j, k); - if (otherMask && otherMask[li]) continue; // never touch a different island of the same label + if (otherMask && otherMask[li]) continue; + if (!maskFilter(i, j, k)) continue; // <-- global masking gate const wantFg = cur[li] === 1; const existing = vm.getAtIJK(i, j, k); if (wantFg && existing !== activeSegment) { @@ -2321,14 +3095,15 @@ function _applyMorphSequence( return { changedVoxels: changes.length }; } -export function erodeActiveSegment(iterations = 1, connectivity: 6 | 26 = 6, seedVoxel?: [number, number, number]) { - return _applyMorphSequence(["erode"], iterations, connectivity, seedVoxel); +export function erodeActiveSegment(iterations = 1, connectivity: 6 | 26 = 6, seedVoxel?: [number, number, number], maskFilter: MaskFilter = () => true) { + return _applyMorphSequence(["erode"], iterations, connectivity, seedVoxel, maskFilter); } -export function dilateActiveSegment(iterations = 1, connectivity: 6 | 26 = 6, seedVoxel?: [number, number, number]) { - return _applyMorphSequence(["dilate"], iterations, connectivity, seedVoxel); +export function dilateActiveSegment(iterations = 1, connectivity: 6 | 26 = 6, seedVoxel?: [number, number, number], maskFilter: MaskFilter = () => true) { + return _applyMorphSequence(["dilate"], iterations, connectivity, seedVoxel, maskFilter); } + function _activeSegmentComponents(connectivity: 6 | 26): { vm: any; bbox: NonNullable>; w: number; h: number; d: number; labels: Int32Array; sizes: number[] } | null { const segVolume = cache.getVolume(segmentationId); const vm = segVolume?.voxelManager as any; @@ -2374,7 +3149,8 @@ function _activeSegmentComponents(connectivity: 6 | 26): { vm: any; bbox: NonNul function _isolateComponentAt( seed: [number, number, number], connectivity: 6 | 26, - marginVoxels: number + marginVoxels: number, + precomputedComp?: ReturnType ): { vm: any; i0: number; i1: number; j0: number; j1: number; k0: number; k1: number; @@ -2382,7 +3158,10 @@ function _isolateComponentAt( selfMask: Uint8Array; otherMask: Uint8Array; } | null { - const comp = _activeSegmentComponents(connectivity); + // Connected-component labeling of the whole active segment is expensive + // (full-volume flood fill) — reuse a caller-supplied result instead of + // recomputing it every time this is called. + const comp = precomputedComp ?? _activeSegmentComponents(connectivity); if (!comp) return null; const { vm, bbox, w: compW, h: compH, labels } = comp; const { i0: cbi0, j0: cbj0, k0: cbk0 } = bbox; @@ -2541,6 +3320,30 @@ function _signedDistanceTransform2D(mask: Uint8Array, w: number, h: number, clam return dist; } +// Converts a VIEWPORT slice number (what the user sees / types — i.e. what +// `viewport.getSliceIndex()` and `sliceInfo.total` are based on) into the real +// volume-space IJK index along that pane's through-plane axis. These are only +// guaranteed to be the same number when the volume's direction matrix is +// identity — see the comment on getVolumeSliceIndexForPane above. For any +// other orientation (common for real NIfTI data) they can differ by an offset +// and/or be reversed, which is why "slice 93" typed by the user was landing on +// the wrong IJK slice and never finding the segment the user actually drew. +// We navigate the pane to the requested viewport slice, read back the true +// IJK index via the existing camera/world→index helper, then restore the +// viewport's original position so this is invisible to the user. +function _viewportSliceToVolumeIJK(pane: CinePane, viewportSliceIndex: number): number | null { + const viewport = _getMprViewport(pane); + if (!viewport) return null; + const original = viewport.getSliceIndex(); + const delta = viewportSliceIndex - original; + if (delta !== 0) viewport.scroll(delta); + const ijk = getVolumeSliceIndexForPane(pane); + const after = viewport.getSliceIndex(); + const restoreDelta = original - after; + if (restoreDelta !== 0) viewport.scroll(restoreDelta); + return ijk; +} + function _extractSliceMask(vm: any, pane: CinePane, sliceIndex: number, segmentIndex: number): { mask: Uint8Array; dimA: number; dimB: number } { const [dimX, dimY, dimZ] = vm.dimensions; const axis = _sliceAxisForPane(pane); @@ -2584,7 +3387,9 @@ export function interpolateSegmentBetweenSlices( pane: CinePane, sliceA: number, sliceB: number, - segmentIndex = _activeEditSegment + segmentIndex = _activeEditSegment, + maskFilter: MaskFilter = () => true + ): { changedVoxels: number; slicesWritten: number } | null { const segVolume = cache.getVolume(segmentationId); const vm = segVolume?.voxelManager as any; @@ -2596,8 +3401,16 @@ export function interpolateSegmentBetweenSlices( console.warn("Interpolate: pick two different slices."); return null; } - const lo = Math.min(sliceA, sliceB); - const hi = Math.max(sliceA, sliceB); + // sliceA/sliceB arrive as viewport slice numbers — resolve them to real IJK + // indices before doing any mask work (see _viewportSliceToVolumeIJK). + const ijkA = _viewportSliceToVolumeIJK(pane, sliceA); + const ijkB = _viewportSliceToVolumeIJK(pane, sliceB); + if (ijkA == null || ijkB == null) { + console.warn("Interpolate: could not resolve the requested slices on this pane."); + return null; + } + const lo = Math.min(ijkA, ijkB); + const hi = Math.max(ijkA, ijkB); if (hi - lo < 2) { console.warn("Interpolate: slices are adjacent, nothing in between to fill."); return { changedVoxels: 0, slicesWritten: 0 }; @@ -2638,6 +3451,7 @@ export function interpolateSegmentBetweenSlices( const li = a + b * dimA; if (!healed[li]) continue; const [i, j, k] = at(a, b, slice); + if (!maskFilter(i, j, k)) continue; // <-- gate const existing = vm.getAtIJK(i, j, k); if (existing === segmentIndex) continue; // already this class — nothing to change changes.push({ i, j, k, prev: existing }); // overwrite anything else, same as brush/smart-fill @@ -2662,7 +3476,8 @@ export function copySegmentAcrossSlices( pane: CinePane, fromSlice: number, toSlice: number, - segmentIndex = _activeEditSegment + segmentIndex = _activeEditSegment, + maskFilter: MaskFilter = () => true ): { changedVoxels: number; slicesWritten: number } | null { const segVolume = cache.getVolume(segmentationId); const vm = segVolume?.voxelManager as any; @@ -2670,14 +3485,22 @@ export function copySegmentAcrossSlices( console.warn("Copy across slices: no segmentation loaded."); return null; } - const lo = Math.min(fromSlice, toSlice); - const hi = Math.max(fromSlice, toSlice); - if (lo === hi) { + if (fromSlice === toSlice) { console.warn("Copy across slices: pick two different slices."); return { changedVoxels: 0, slicesWritten: 0 }; } + // fromSlice/toSlice arrive as viewport slice numbers — resolve them to real + // IJK indices before doing any mask work (see _viewportSliceToVolumeIJK). + const ijkFrom = _viewportSliceToVolumeIJK(pane, fromSlice); + const ijkTo = _viewportSliceToVolumeIJK(pane, toSlice); + if (ijkFrom == null || ijkTo == null) { + console.warn("Copy across slices: could not resolve the requested slices on this pane."); + return null; + } + const lo = Math.min(ijkFrom, ijkTo); + const hi = Math.max(ijkFrom, ijkTo); - const { mask: srcMask, dimA, dimB } = _extractSliceMask(vm, pane, fromSlice, segmentIndex); + const { mask: srcMask, dimA, dimB } = _extractSliceMask(vm, pane, ijkFrom, segmentIndex); const count = srcMask.reduce((s, v) => s + v, 0); if (!count) { console.warn( @@ -2693,11 +3516,12 @@ export function copySegmentAcrossSlices( const changes: Array<{ i: number; j: number; k: number; prev: number }> = []; for (let slice = lo; slice <= hi; slice++) { - if (slice === fromSlice) continue; + if (slice === ijkFrom) continue; for (let b = 0; b < dimB; b++) { for (let a = 0; a < dimA; a++) { if (!srcMask[a + b * dimA]) continue; const [i, j, k] = at(a, b, slice); + if (!maskFilter(i, j, k)) continue; // <-- gate const existing = vm.getAtIJK(i, j, k); if (existing === segmentIndex) continue; changes.push({ i, j, k, prev: existing }); @@ -2729,7 +3553,172 @@ export function getInterpolationEndpoint(pane: CinePane): number | null { export function clearInterpolationEndpoints(pane: CinePane) { _interpEndpoints[pane] = null; } +// CornerstoneNifti2.ts — add near lassoCommitPolygon + +export type ScissorsOperation = "eraseInside" | "eraseOutside" | "fillInside" | "fillOutside"; +export type ScissorsSliceCut = "unlimited" | "positive" | "negative" | "symmetric"; + +export interface ScissorsCutParams { + operation: ScissorsOperation; + sliceCut: ScissorsSliceCut; + sliceCutDepthMm: number; + applyToVisibleSegments: boolean; + visibleSegmentIndices: number[]; +} + +function _rasterizePolygonInsideMask( + dimA: number, + dimB: number, + polygonAB: Array<[number, number]> +): Uint8Array { + let minA = Infinity, maxA = -Infinity, minB = Infinity, maxB = -Infinity; + for (const [a, b] of polygonAB) { + minA = Math.min(minA, a); maxA = Math.max(maxA, a); + minB = Math.min(minB, b); maxB = Math.max(maxB, b); + } + const boxA0 = Math.max(0, Math.floor(minA) - 1); + const boxA1 = Math.min(dimA - 1, Math.ceil(maxA) + 1); + const boxB0 = Math.max(0, Math.floor(minB) - 1); + const boxB1 = Math.min(dimB - 1, Math.ceil(maxB) + 1); + const w = boxA1 - boxA0 + 1; + const h = boxB1 - boxB0 + 1; + const full = new Uint8Array(dimA * dimB); + if (w < 2 || h < 2 || polygonAB.length < 3) return full; + + const idxLocal = (a: number, b: number) => (a - boxA0) + (b - boxB0) * w; + const boundary = new Uint8Array(w * h); + const drawLine = (a0: number, b0: number, a1: number, b1: number) => { + let x0 = Math.round(a0), y0 = Math.round(b0); + const x1 = Math.round(a1), y1 = Math.round(b1); + const dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1; + const dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1; + let err = dx + dy; + for (;;) { + if (x0 >= boxA0 && x0 <= boxA1 && y0 >= boxB0 && y0 <= boxB1) boundary[idxLocal(x0, y0)] = 1; + if (x0 === x1 && y0 === y1) break; + const e2 = 2 * err; + if (e2 >= dy) { err += dy; x0 += sx; } + if (e2 <= dx) { err += dx; y0 += sy; } + } + }; + for (let i = 0; i < polygonAB.length; i++) { + const [a0, b0] = polygonAB[i]; + const [a1, b1] = polygonAB[(i + 1) % polygonAB.length]; + drawLine(a0, b0, a1, b1); + } + + const outside = new Uint8Array(w * h); + const stack: number[] = []; + const tryPush = (x: number, y: number) => { + if (x < 0 || y < 0 || x >= w || y >= h) return; + const li = x + y * w; + if (boundary[li] || outside[li]) return; + outside[li] = 1; + stack.push(li); + }; + for (let x = 0; x < w; x++) { tryPush(x, 0); tryPush(x, h - 1); } + for (let y = 0; y < h; y++) { tryPush(0, y); tryPush(w - 1, y); } + while (stack.length) { + const li = stack.pop()!; + const x = li % w, y = Math.floor(li / w); + tryPush(x + 1, y); tryPush(x - 1, y); tryPush(x, y + 1); tryPush(x, y - 1); + } + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const li = x + y * w; + if (!outside[li]) full[(x + boxA0) + (y + boxB0) * dimA] = 1; // boundary + interior + } + } + return full; +} +export function cutSegmentWithPolygon( + pane: CinePane, + canvasPoints: Array<[number, number]>, + params: ScissorsCutParams, + segmentIndex = _activeEditSegment, + maskFilter: MaskFilter = () => true +): { changedVoxels: number } | null { + const voxelPts = canvasPoints.map((cp) => canvasPointToVoxel(pane, cp)); + if (voxelPts.some((v) => !v) || voxelPts.length < 3) return null; + const pts = voxelPts as Array<[number, number, number]>; + + const segVolume = cache.getVolume(segmentationId); + const vm = segVolume?.voxelManager as any; + if (!segVolume || !vm) return null; + const [dimX, dimY, dimZ] = vm.dimensions; + + const axis = _sliceAxisForPane(pane); + const throughVals = pts + .map((p) => (axis === 2 ? p[2] : axis === 0 ? p[0] : p[1])) + .sort((a, b) => a - b); + const drawnSlice = throughVals[Math.floor(throughVals.length / 2)]; + + const [dimA, dimB] = axis === 2 ? [dimX, dimY] : axis === 0 ? [dimY, dimZ] : [dimX, dimZ]; + const polygonAB: Array<[number, number]> = pts.map((p) => + axis === 2 ? [p[0], p[1]] : axis === 0 ? [p[1], p[2]] : [p[0], p[2]] + ); + const insideMask = _rasterizePolygonInsideMask(dimA, dimB, polygonAB); + + const spacing = segVolume.spacing as number[]; + const axisSpacing = spacing[axis] || 1; + const dimAxis = axis === 2 ? dimZ : axis === 0 ? dimX : dimY; + + let sliceLo = drawnSlice, sliceHi = drawnSlice; + if (params.sliceCut !== "unlimited" && params.sliceCutDepthMm > 0) { + const depthVoxels = Math.max(0, Math.round(params.sliceCutDepthMm / axisSpacing)); + if (params.sliceCut === "symmetric") { + sliceLo = Math.max(0, drawnSlice - depthVoxels); + sliceHi = Math.min(dimAxis - 1, drawnSlice + depthVoxels); + } else if (params.sliceCut === "positive") { + sliceHi = Math.min(dimAxis - 1, drawnSlice + depthVoxels); + } else if (params.sliceCut === "negative") { + sliceLo = Math.max(0, drawnSlice - depthVoxels); + } + } + + const targets = + params.applyToVisibleSegments && params.visibleSegmentIndices.length + ? params.visibleSegmentIndices + : [segmentIndex]; + + const at = (a: number, b: number, slice: number): [number, number, number] => + axis === 2 ? [a, b, slice] : axis === 0 ? [slice, a, b] : [a, slice, b]; + + const wantsInside = params.operation === "eraseInside" || params.operation === "fillInside"; + const paints = params.operation === "fillInside" || params.operation === "fillOutside"; + + const changes: Array<{ i: number; j: number; k: number; prev: number; next: number }> = []; + for (let slice = sliceLo; slice <= sliceHi; slice++) { + for (let b = 0; b < dimB; b++) { + for (let a = 0; a < dimA; a++) { + const isInside = insideMask[a + b * dimA] === 1; + if (isInside !== wantsInside) continue; + const [i, j, k] = at(a, b, slice); + if (!maskFilter(i, j, k)) continue; + for (const target of targets) { + const existing = vm.getAtIJK(i, j, k); + if (paints) { + if (existing === target) continue; + changes.push({ i, j, k, prev: existing, next: target }); + } else { + if (existing !== target) continue; + changes.push({ i, j, k, prev: existing, next: 0 }); + } + } + } + } + } + if (!changes.length) return { changedVoxels: 0 }; + for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, c.next); + _pushFillHistory({ + undo: () => { for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, c.prev); _notifySegmentationChanged(); }, + redo: () => { for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, c.next); _notifySegmentationChanged(); }, + }); + _notifySegmentationChanged(); + return { changedVoxels: changes.length }; +} // ============================================================================ // SECTION: Lasso (Straight-Edge Polygon Fill) // ============================================================================ @@ -2744,7 +3733,8 @@ function _rasterizeClosedPolygon( pane: CinePane, sliceIndex: number, polygonAB: Array<[number, number]>, - segmentIndex = _activeEditSegment + segmentIndex = _activeEditSegment, + maskFilter: MaskFilter = () => true ): { filledVoxels: number } | null { const segVolume = cache.getVolume(segmentationId); const vm = segVolume?.voxelManager as any; @@ -2818,6 +3808,7 @@ function _rasterizeClosedPolygon( if (outside[li]) continue; const a = x + boxA0, b = y + boxB0; const [i, j, k] = at(a, b); + if (!maskFilter(i, j, k)) continue; // <-- gate if (i < 0 || j < 0 || k < 0 || i >= dimX || j >= dimY || k >= dimZ) continue; const existing = vm.getAtIJK(i, j, k); if (existing === segmentIndex) continue; // already this class — nothing to change @@ -2834,14 +3825,196 @@ function _rasterizeClosedPolygon( _notifySegmentationChanged(); return { filledVoxels: changes.length }; } +// CornerstoneNifti2.ts — add near _rasterizeClosedPolygon / lassoCommitPolygon + +export type ScissorsCutOperation = "eraseInside" | "eraseOutside" | "fillInside" | "fillOutside"; + +export type ScissorsCutOptions = { + operation: ScissorsCutOperation; + applyToVisibleSegments?: boolean; + visibleSegmentIndices?: number[]; + // Restrict the cut to a range of slices around the drawn slice, in mm each + // direction along the through-plane axis. null/0 = unlimited (only the drawn slice). + sliceCutDepthMm?: number; + sliceCutMode?: "unlimited" | "positive" | "negative" | "symmetric"; +}; + +// Rasterizes the polygon into a same-size 0/1 mask over the pane's 2D footprint, +// WITHOUT touching the labelmap — used by scissors to know inside/outside before +// deciding erase vs fill per-voxel. +function _rasterizePolygonMask( + dimA: number, + dimB: number, + polygonAB: Array<[number, number]> +): Uint8Array { + let minA = Infinity, maxA = -Infinity, minB = Infinity, maxB = -Infinity; + for (const [a, b] of polygonAB) { + minA = Math.min(minA, a); maxA = Math.max(maxA, a); + minB = Math.min(minB, b); maxB = Math.max(maxB, b); + } + const boxA0 = Math.max(0, Math.floor(minA) - 1); + const boxA1 = Math.min(dimA - 1, Math.ceil(maxA) + 1); + const boxB0 = Math.max(0, Math.floor(minB) - 1); + const boxB1 = Math.min(dimB - 1, Math.ceil(maxB) + 1); + const w = boxA1 - boxA0 + 1; + const h = boxB1 - boxB0 + 1; + const full = new Uint8Array(dimA * dimB); + if (w < 2 || h < 2 || polygonAB.length < 3) return full; + + const idxLocal = (a: number, b: number) => (a - boxA0) + (b - boxB0) * w; + const boundary = new Uint8Array(w * h); + const drawLine = (a0: number, b0: number, a1: number, b1: number) => { + let x0 = Math.round(a0), y0 = Math.round(b0); + const x1 = Math.round(a1), y1 = Math.round(b1); + const dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1; + const dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1; + let err = dx + dy; + for (;;) { + if (x0 >= boxA0 && x0 <= boxA1 && y0 >= boxB0 && y0 <= boxB1) boundary[idxLocal(x0, y0)] = 1; + if (x0 === x1 && y0 === y1) break; + const e2 = 2 * err; + if (e2 >= dy) { err += dy; x0 += sx; } + if (e2 <= dx) { err += dx; y0 += sy; } + } + }; + for (let i = 0; i < polygonAB.length; i++) { + const [a0, b0] = polygonAB[i]; + const [a1, b1] = polygonAB[(i + 1) % polygonAB.length]; + drawLine(a0, b0, a1, b1); + } + + const outside = new Uint8Array(w * h); + const stack: number[] = []; + const tryPush = (x: number, y: number) => { + if (x < 0 || y < 0 || x >= w || y >= h) return; + const li = x + y * w; + if (boundary[li] || outside[li]) return; + outside[li] = 1; + stack.push(li); + }; + for (let x = 0; x < w; x++) { tryPush(x, 0); tryPush(x, h - 1); } + for (let y = 0; y < h; y++) { tryPush(0, y); tryPush(w - 1, y); } + while (stack.length) { + const li = stack.pop()!; + const x = li % w, y = Math.floor(li / w); + tryPush(x + 1, y); tryPush(x - 1, y); tryPush(x, y + 1); tryPush(x, y - 1); + } + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const li = x + y * w; + const inside = !outside[li] && !boundary[li] ? 1 : (boundary[li] ? 1 : 0); + if (inside) full[(x + boxA0) + (y + boxB0) * dimA] = 1; + } + } + return full; +} + +// Applies a scissors cut using the drawn polygon (in one slice's 2D pixel space). +// Unlike lassoCommitPolygon (always fills), this branches on operation: +// eraseInside — clear the active segment's voxels inside the shape +// eraseOutside — clear the active segment's voxels outside the shape +// fillInside — paint the active segment inside the shape +// fillOutside — paint the active segment outside the shape (rare, but Slicer supports it) +// sliceCutMode/Depth extends the cut to neighboring slices along the through-plane axis. +export function applyScissorsCut( + pane: CinePane, + canvasPoints: Array<[number, number]>, + options: ScissorsCutOptions, + segmentIndex = _activeEditSegment, + maskFilter: MaskFilter = () => true +): { changedVoxels: number } | null { + const voxelPts = canvasPoints.map((cp) => canvasPointToVoxel(pane, cp)); + if (voxelPts.some((v) => !v) || voxelPts.length < 3) { + console.warn("Scissors: draw a closed shape with at least 3 points inside the volume."); + return null; + } + const pts = voxelPts as Array<[number, number, number]>; + + const segVolume = cache.getVolume(segmentationId); + const vm = segVolume?.voxelManager as any; + if (!segVolume || !vm) return null; + const [dimX, dimY, dimZ] = vm.dimensions; + + const axis = _sliceAxisForPane(pane); + const throughVals = pts + .map((p) => (axis === 2 ? p[2] : axis === 0 ? p[0] : p[1])) + .sort((a, b) => a - b); + const drawnSlice = throughVals[Math.floor(throughVals.length / 2)]; + + const [dimA, dimB] = axis === 2 ? [dimX, dimY] : axis === 0 ? [dimY, dimZ] : [dimX, dimZ]; + const polygonAB: Array<[number, number]> = pts.map((p) => + axis === 2 ? [p[0], p[1]] : axis === 0 ? [p[1], p[2]] : [p[0], p[2]] + ); + const insideMask = _rasterizePolygonMask(dimA, dimB, polygonAB); + + // Resolve which slices this cut touches. + const spacing = segVolume.spacing as number[]; + const axisSpacing = spacing[axis] || 1; + const mode = options.sliceCutMode ?? "unlimited"; + let sliceLo = drawnSlice, sliceHi = drawnSlice; + if (mode !== "unlimited" && options.sliceCutDepthMm) { + const depthVoxels = Math.max(0, Math.round(options.sliceCutDepthMm / axisSpacing)); + const dimAxis = axis === 2 ? dimZ : axis === 0 ? dimX : dimY; + if (mode === "symmetric") { + sliceLo = Math.max(0, drawnSlice - depthVoxels); + sliceHi = Math.min(dimAxis - 1, drawnSlice + depthVoxels); + } else if (mode === "positive") { + sliceHi = Math.min(dimAxis - 1, drawnSlice + depthVoxels); + } else if (mode === "negative") { + sliceLo = Math.max(0, drawnSlice - depthVoxels); + } + } + + const targets = + options.applyToVisibleSegments && options.visibleSegmentIndices?.length + ? options.visibleSegmentIndices + : [segmentIndex]; + + const at = (a: number, b: number, slice: number): [number, number, number] => + axis === 2 ? [a, b, slice] : axis === 0 ? [slice, a, b] : [a, slice, b]; + const changes: Array<{ i: number; j: number; k: number; prev: number; next: number }> = []; + const wantsInside = options.operation === "eraseInside" || options.operation === "fillInside"; + const paints = options.operation === "fillInside" || options.operation === "fillOutside"; + + for (let slice = sliceLo; slice <= sliceHi; slice++) { + for (let b = 0; b < dimB; b++) { + for (let a = 0; a < dimA; a++) { + const isInside = insideMask[a + b * dimA] === 1; + if (isInside !== wantsInside) continue; + const [i, j, k] = at(a, b, slice); + if (!maskFilter(i, j, k)) continue; // <-- gate + for (const target of targets) { + const existing = vm.getAtIJK(i, j, k); + if (paints) { + if (existing === target) continue; + changes.push({ i, j, k, prev: existing, next: target }); + } else { + if (existing !== target) continue; + changes.push({ i, j, k, prev: existing, next: 0 }); + } + } + } + } + } + if (!changes.length) return { changedVoxels: 0 }; + for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, c.next); + _pushFillHistory({ + undo: () => { for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, c.prev); _notifySegmentationChanged(); }, + redo: () => { for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, c.next); _notifySegmentationChanged(); }, + }); + _notifySegmentationChanged(); + return { changedVoxels: changes.length }; +} // Plain (straight-edge) lasso — every clicked canvas point is converted to a // voxel directly, and the resulting polygon is rasterized in one pass. No // edge-snapping: the outline is exactly what the user drew. export function lassoCommitPolygon( pane: CinePane, canvasPoints: Array<[number, number]>, - segmentIndex = _activeEditSegment + segmentIndex = _activeEditSegment, + maskFilter: MaskFilter = () => true ): { filledVoxels: number } | null { const voxelPts = canvasPoints.map((cp) => canvasPointToVoxel(pane, cp)); if (voxelPts.some((v) => !v)) { @@ -2851,8 +4024,6 @@ export function lassoCommitPolygon( const pts = voxelPts as Array<[number, number, number]>; const axis = _sliceAxisForPane(pane); - // Median through-plane index across all clicked points — robust to any single - // point rounding to a neighboring slice. const throughVals = pts .map((p) => (axis === 2 ? p[2] : axis === 0 ? p[0] : p[1])) .sort((a, b) => a - b); @@ -2862,9 +4033,8 @@ export function lassoCommitPolygon( axis === 2 ? [p[0], p[1]] : axis === 0 ? [p[1], p[2]] : [p[0], p[2]] ); - return _rasterizeClosedPolygon(pane, sliceIndex, polygonAB, segmentIndex); + return _rasterizeClosedPolygon(pane, sliceIndex, polygonAB, segmentIndex, maskFilter); } - // ============================================================================ // SECTION: Mouse Tool Release Helper // ============================================================================ @@ -2878,11 +4048,6 @@ export function releasePrimaryMouseTools() { for (const name of MEASUREMENT_TOOL_NAMES) toolGroup.setToolPassive(name); } -// ============================================================================ -// SECTION: Live Mesh Extraction (Marching Cubes) -// ============================================================================ - - export function extractSegmentSurface( segmentIndex: number, manifestCenter: [number, number, number] @@ -2913,4 +4078,771 @@ export function extractSegmentSurface( const indices = _vtkPolysToIndices(polys); return { positions, indices }; +} + +// ============================================================================ +// SECTION: Smoothing (median) — mirrors Slicer's Smoothing effect's Median +// method: same "kernel size in mm -> voxel radius" conversion. +// ============================================================================ + +export type SmoothingMethod = "median"; + +const MAX_SMOOTHING_KERNEL_MM = 3; + +function _kernelRadiusVoxels(kernelMm: number, spacing: number[]): [number, number, number] { + // Cap at the same 3mm ceiling the UI slider enforces — a stray call site + // (or a future UI regression) passing something like 50mm would otherwise + // blow the kernel radius out to dozens of voxels, making the bbox/filter + // loops below O(radius^3) against a huge volume and hanging the tab. + const clampedMm = Math.min(MAX_SMOOTHING_KERNEL_MM, Math.max(0, kernelMm)); + return [ + Math.max(1, Math.round(clampedMm / 2 / spacing[0])), + Math.max(1, Math.round(clampedMm / 2 / spacing[1])), + Math.max(1, Math.round(clampedMm / 2 / spacing[2])), + ]; +} + +// 3D median filter over a binary mask within a local bbox — Slicer's "Median" method. +function _medianFilter3D(mask: Uint8Array, w: number, h: number, d: number, rx: number, ry: number, rz: number): Uint8Array { + const idx = (i: number, j: number, k: number) => i + j * w + k * w * h; + const out = new Uint8Array(mask.length); + for (let k = 0; k < d; k++) for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) { + let on = 0, total = 0; + for (let dk = -rz; dk <= rz; dk++) for (let dj = -ry; dj <= ry; dj++) for (let di = -rx; di <= rx; di++) { + const ni = i + di, nj = j + dj, nk = k + dk; + if (ni < 0 || ni >= w || nj < 0 || nj >= h || nk < 0 || nk >= d) continue; + total++; + if (mask[idx(ni, nj, nk)]) on++; + } + out[idx(i, j, k)] = on * 2 >= total ? 1 : 0; + } + return out; +} + +// applyToVisibleSegments mirrors Slicer's "Apply to visible segments" checkbox — +// when true, runs on every currently-visible segment index, not just the active one. +export function applySmoothing( + kernelMm: number, + applyToVisibleSegments = false, + visibleSegmentIndices: number[] = [], + maskFilter: MaskFilter = () => true +): { changedVoxels: number } | null { + const segVolume = cache.getVolume(segmentationId); + const vm = segVolume?.voxelManager as any; + if (!segVolume || !vm) return null; + const spacing = segVolume.spacing as number[]; + const [rx, ry, rz] = _kernelRadiusVoxels(kernelMm, spacing); + const targets = applyToVisibleSegments && visibleSegmentIndices.length ? visibleSegmentIndices : [_activeEditSegment]; + + let totalChanged = 0; + const allChanges: Array<{ i: number; j: number; k: number; prev: number; next: number }> = []; + + for (const segmentIndex of targets) { + const bbox = _segBBox(vm, segmentIndex, Math.max(rx, ry, rz) + 1); + if (!bbox) continue; + const { i0, i1, j0, j1, k0, k1 } = bbox; + const w = i1 - i0 + 1, h = j1 - j0 + 1, d = k1 - k0 + 1; + const idxLocal = (i: number, j: number, k: number) => (i - i0) + (j - j0) * w + (k - k0) * w * h; + let cur = new Uint8Array(w * h * d); + let originalCount = 0; + // Track the segment's own TRUE extent (unpadded) per axis — this is what + // the kernel radius must be capped against, not the padded bbox below. + let ti0 = i1, ti1 = i0, tj0 = j1, tj1 = j0, tk0 = k1, tk1 = k0; + for (let k = k0; k <= k1; k++) for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) + if (vm.getAtIJK(i, j, k) === segmentIndex) { + cur[idxLocal(i, j, k)] = 1; originalCount++; + if (i < ti0) ti0 = i; if (i > ti1) ti1 = i; + if (j < tj0) tj0 = j; if (j > tj1) tj1 = j; + if (k < tk0) tk0 = k; if (k > tk1) tk1 = k; + } + if (originalCount === 0) continue; + + // A box-kernel majority filter is inherently erosive on convex boundaries + // (curvature means the true surface always sits at <50% local occupancy), + // so an oversized kernel relative to the segment eats straight through it. + // Critically, this isn't just about overall size: a segment confined to a + // single slice has a true z-extent of 1 voxel, so ANY z-radius >= 1 means + // the kernel's z-neighbors are entirely empty (the segment doesn't exist + // off that slice) and every voxel loses its majority vote regardless of + // how large the segment is in-plane — guaranteed full erasure. Capping + // per axis against the segment's own true extent (allowing radius 0, i.e. + // "don't smooth across an axis the object doesn't extend into") fixes + // both the general over-smoothing case and this single-slice case. + const segRx = Math.min(rx, Math.floor((ti1 - ti0 + 1) / 2)); + const segRy = Math.min(ry, Math.floor((tj1 - tj0 + 1) / 2)); + const segRz = Math.min(rz, Math.floor((tk1 - tk0 + 1) / 2)); + + cur = _medianFilter3D(cur, w, h, d, segRx, segRy, segRz); + + // Safety net: smoothing should refine a boundary, never delete the + // segment outright. If the filtered result came back empty while the + // segment started non-empty, skip this segment untouched rather than + // wiping it. + let survivingCount = 0; + for (let v = 0; v < cur.length; v++) if (cur[v]) survivingCount++; + if (survivingCount === 0) continue; + + for (let k = k0; k <= k1; k++) for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) { + if (!maskFilter(i, j, k)) continue; // <-- gate + const wantFg = cur[idxLocal(i, j, k)] === 1; + const existing = vm.getAtIJK(i, j, k); + if (wantFg && existing !== segmentIndex) { + if (existing !== 0) continue; + allChanges.push({ i, j, k, prev: existing, next: segmentIndex }); + } else if (!wantFg && existing === segmentIndex) { + allChanges.push({ i, j, k, prev: existing, next: 0 }); + } + } + } + + if (!allChanges.length) return { changedVoxels: 0 }; + for (const c of allChanges) vm.setAtIJK(c.i, c.j, c.k, c.next); + totalChanged = allChanges.length; + _pushFillHistory({ + undo: () => { for (const c of allChanges) vm.setAtIJK(c.i, c.j, c.k, c.prev); _notifySegmentationChanged(); }, + redo: () => { for (const c of allChanges) vm.setAtIJK(c.i, c.j, c.k, c.next); _notifySegmentationChanged(); }, + }); + _notifySegmentationChanged(); + return { changedVoxels: totalChanged }; +} + +// ============================================================================ +// SECTION: Logical Operators — mirrors Slicer's Logical operators effect: +// Copy / Add / Invert / Clear / Fill, with an optional "Bypass masking" flag +// (skip the existing-voxel-ownership check). Subtract/Intersect are omitted: +// this segmentation is a single shared label array (one segment index per +// voxel), so two segments can never truly overlap in the data — those two +// ops would always be no-ops (subtract) or wipe the whole target +// (intersect). Re-add them only if segments move to independent per-segment +// masks. +// ============================================================================ + +export type LogicalOperation = "copy" | "add" | "invert" | "clear" | "fill"; + +export function applyLogicalOperator( + operation: LogicalOperation, + targetSegmentIndex: number, + sourceSegmentIndex: number | null, + bypassMasking = false, + maskFilter: MaskFilter = () => true +): { changedVoxels: number } | null { + const segVolume = cache.getVolume(segmentationId); + const vm = segVolume?.voxelManager as any; + if (!segVolume || !vm) return null; + const [dimX, dimY, dimZ] = vm.dimensions; + + const changes: Array<{ i: number; j: number; k: number; prev: number; next: number }> = []; + const setVoxel = (i: number, j: number, k: number, next: number) => { + if (!maskFilter(i, j, k)) return; // <-- gate + const prev = vm.getAtIJK(i, j, k); + if (prev === next) return; + if (!bypassMasking && next !== 0 && prev !== 0 && prev !== targetSegmentIndex) return; + changes.push({ i, j, k, prev, next }); + }; + for (let k = 0; k < dimZ; k++) for (let j = 0; j < dimY; j++) for (let i = 0; i < dimX; i++) { + const targetOn = vm.getAtIJK(i, j, k) === targetSegmentIndex; + const sourceOn = sourceSegmentIndex !== null && vm.getAtIJK(i, j, k) === sourceSegmentIndex; + switch (operation) { + case "copy": + if (sourceOn) setVoxel(i, j, k, targetSegmentIndex); + else if (targetOn) setVoxel(i, j, k, 0); + break; + case "add": + if (sourceOn) setVoxel(i, j, k, targetSegmentIndex); + break; + case "invert": + setVoxel(i, j, k, targetOn ? 0 : targetSegmentIndex); + break; + case "clear": + if (targetOn) setVoxel(i, j, k, 0); + break; + case "fill": + setVoxel(i, j, k, targetSegmentIndex); + break; + } + } + + if (!changes.length) return { changedVoxels: 0 }; + for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, c.next); + _pushFillHistory({ + undo: () => { for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, c.prev); _notifySegmentationChanged(); }, + redo: () => { for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, c.next); _notifySegmentationChanged(); }, + }); + _notifySegmentationChanged(); + return { changedVoxels: changes.length }; +} + +// ============================================================================ +// SECTION: Level Tracing — mirrors Slicer's Level Tracing effect: trace the +// iso-contour of equal-or-similar intensity around the point the cursor is +// over, on the current slice, then fill the traced outline. Implemented as a +// 2D contour trace via marching-squares-style boundary following on a +// thresholded slice mask, using the same _extractSliceMask/_rasterizeClosedPolygon +// machinery already used by Copy/Interpolate/Lasso. +// +// Slicer's own Level Tracing effect has no sensitivity/tolerance control — +// it just traces the region of matching intensity under the cursor. We match +// that: the tolerance below is a fixed internal constant, not user-facing. +// ============================================================================ + +const LEVEL_TRACE_TOLERANCE_HU = 45; + +// The four operations Level Tracing can commit, same semantics as the Scissors +// tool's operation set: "inside"/"outside" refer to the traced iso-intensity +// region on this slice, "fill" writes segmentIndex, "erase" writes 0 (clears). +export type LevelTraceOperation = "fillInside" | "fillOutside" | "eraseInside" | "eraseOutside"; + +export function levelTraceAtPoint( + pane: CinePane, + sliceIndex: number, + seedIJK: [number, number, number], + segmentIndex = _activeEditSegment, + maskFilter: MaskFilter = () => true, + toleranceHu: number = LEVEL_TRACE_TOLERANCE_HU +): { filledVoxels: number } | null { + const ctVolume = _currentCtVolumeId ? cache.getVolume(_currentCtVolumeId) : undefined; + const segVolume = cache.getVolume(segmentationId); + if (!ctVolume || !segVolume) return null; + const ctVm = ctVolume.voxelManager as any; + const segVm = segVolume.voxelManager as any; + if (!ctVm || !segVm) return null; + + const [dimX, dimY, dimZ] = ctVm.dimensions; + let ctData: ArrayLike | undefined; + try { ctData = ctVm.getCompleteScalarDataArray?.(); } catch { /* fall through */ } + if (!ctData || !ctData.length) return null; + + const axis = _sliceAxisForPane(pane); + const [dimA, dimB] = axis === 2 ? [dimX, dimY] : axis === 0 ? [dimY, dimZ] : [dimX, dimZ]; + const at = (a: number, b: number): [number, number, number] => + axis === 2 ? [a, b, sliceIndex] : axis === 0 ? [sliceIndex, a, b] : [a, sliceIndex, b]; + const [si, sj, sk] = seedIJK; + const seedA = axis === 2 ? si : axis === 0 ? sj : si; + const seedB = axis === 2 ? sj : axis === 0 ? sk : sk; + const sliceSize = dimX * dimY; + const idxOf = (i: number, j: number, k: number) => i + j * dimX + k * sliceSize; + + const seedHu = ctData[idxOf(si, sj, sk)]; + const inBand = (a: number, b: number) => { + if (a < 0 || b < 0 || a >= dimA || b >= dimB) return false; + const [i, j, k] = at(a, b); + return Math.abs(ctData![idxOf(i, j, k)] - seedHu) <= toleranceHu; + }; + + // Flood-fill the connected iso-intensity region on this single slice (this is + // what Slicer's level tracing traces the boundary of before filling). + const mask = new Uint8Array(dimA * dimB); + const stack: number[] = [seedA + seedB * dimA]; + mask[stack[0]] = 1; + while (stack.length) { + const li = stack.pop()!; + const a = li % dimA, b = Math.floor(li / dimA); + for (const [da, db] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const na = a + da, nb = b + db; + if (!inBand(na, nb)) continue; + const nli = na + nb * dimA; + if (!mask[nli]) { mask[nli] = 1; stack.push(nli); } + } + } + + const changes: Array<{ i: number; j: number; k: number; prev: number }> = []; + for (let b = 0; b < dimB; b++) for (let a = 0; a < dimA; a++) { + if (!mask[a + b * dimA]) continue; + const [i, j, k] = at(a, b); + if (!maskFilter(i, j, k)) continue; // <-- gate + const existing = segVm.getAtIJK(i, j, k); + if (existing === segmentIndex) continue; + changes.push({ i, j, k, prev: existing }); + } + if (!changes.length) return { filledVoxels: 0 }; + for (const c of changes) segVm.setAtIJK(c.i, c.j, c.k, segmentIndex); + _pushFillHistory({ + undo: () => { for (const c of changes) segVm.setAtIJK(c.i, c.j, c.k, c.prev); _notifySegmentationChanged(); }, + redo: () => { for (const c of changes) segVm.setAtIJK(c.i, c.j, c.k, segmentIndex); _notifySegmentationChanged(); }, + }); + _notifySegmentationChanged(); + return { filledVoxels: changes.length }; +} +export function isSegmentPresent(segmentIndex: number): boolean { + const volume = cache.getVolume(segmentationId); + const vm = volume?.voxelManager as any; + if (!volume || !vm) { + // Expected while the segmentation volume is still loading — not an error. + return false; + } + let data: ArrayLike | undefined; + try { data = vm.getCompleteScalarDataArray?.(); } catch { /* fall through */ } + if (!data || !data.length) { + return false; + } + for (let i = 0; i < data.length; i++) { + if (data[i] === segmentIndex) return true; + } + return false; +} +export function hasSegmentationVolume(): boolean { + return !!cache.getVolume(segmentationId); +} + +// ============================================================================ +// SECTION: Level Tracing — live preview + commit (3D Slicer style). On every +// mouse move, flood-fill the connected same-intensity region under the +// cursor on the current slice and hand back a canvas-space outline for a +// preview; a click commits the last-computed region into the active +// segment. No user-facing sensitivity control — matches Slicer, which traces +// off the cursor's own pixel value with a fixed internal band. +// +// Previously this was clipped to a small fixed-radius window, which is what +// produced the square "bounding box" look whenever the real uniform-intensity +// region was bigger than the window — the flood fill hit the window edge and +// got cut off into a box instead of following the actual tissue boundary. +// Tracing the full slice removes that artifact; the `MAX_TRACE_PIXELS` cap +// below only guards against pathological cases (e.g. a seed landing in a +// vast uniform air region) turning into an unbounded fill. +// ============================================================================ + +export type LevelTraceMask = { + mask: Uint8Array; + dimA: number; + dimB: number; + boxA0: number; + boxB0: number; + sliceIndex: number; +}; + +// Safety cap on how much of a slice a single trace is allowed to claim — +// not a "window" in the old sense (nothing gets geometrically clipped: the +// flood fill still runs edge-to-edge across the slice), just a circuit +// breaker so an accidental click on a huge uniform region (e.g. background +// air) can't fill the majority of the slice. +const MAX_TRACE_PIXELS = 60000; + +// Flood-fills the connected iso-intensity region around seedIJK on one slice. +// Does NOT write to the segmentation — call commitLevelTraceMask to apply it. +export function computeLevelTraceMask( + pane: CinePane, + seedIJK: [number, number, number], + toleranceHu: number = LEVEL_TRACE_TOLERANCE_HU +): LevelTraceMask | null { + const ctVolume = _currentCtVolumeId ? cache.getVolume(_currentCtVolumeId) : undefined; + if (!ctVolume) return null; + const ctVm = ctVolume.voxelManager as any; + if (!ctVm) return null; + + const [dimX, dimY, dimZ] = ctVm.dimensions; + let ctData: ArrayLike | undefined; + try { ctData = ctVm.getCompleteScalarDataArray?.(); } catch { /* fall through */ } + if (!ctData || !ctData.length) return null; + + const axis = _sliceAxisForPane(pane); + const [dimA, dimB] = axis === 2 ? [dimX, dimY] : axis === 0 ? [dimY, dimZ] : [dimX, dimZ]; + const sliceOf = (a: number, b: number): [number, number, number] => + axis === 2 ? [a, b, seedIJK[2]] : axis === 0 ? [seedIJK[0], a, b] : [a, seedIJK[1], b]; + const [si, sj, sk] = seedIJK; + const seedA = axis === 2 ? si : axis === 0 ? sj : si; + const seedB = axis === 2 ? sj : axis === 0 ? sk : sk; + + const sliceSize = dimX * dimY; + const idxOf = (i: number, j: number, k: number) => i + j * dimX + k * sliceSize; + const seedHu = ctData[idxOf(si, sj, sk)]; + + const local = (a: number, b: number) => a + b * dimA; + const inBand = (a: number, b: number) => { + if (a < 0 || b < 0 || a >= dimA || b >= dimB) return false; + const [i, j, k] = sliceOf(a, b); + return Math.abs(ctData![idxOf(i, j, k)] - seedHu) <= toleranceHu; + }; + + const mask = new Uint8Array(dimA * dimB); + const stack: number[] = [local(seedA, seedB)]; + mask[stack[0]] = 1; + let filled = 1; + // 8-connected neighborhood — matches Slicer's ITK-based level tracing, + // which walks diagonals as well as orthogonal neighbors. The previous + // 4-connected fill let single-pixel-wide diagonal seams (common at + // oblique tissue boundaries and partial-volume edges) act as a wall, + // so the trace would stop short of the real boundary instead of + // following it all the way around. + while (stack.length) { + const li = stack.pop()!; + const a = li % dimA, b = Math.floor(li / dimA); + for (const [da, db] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]) { + const na = a + da, nb = b + db; + if (!inBand(na, nb)) continue; + const nli = local(na, nb); + if (!mask[nli]) { + mask[nli] = 1; + filled++; + if (filled > MAX_TRACE_PIXELS) return null; // background/huge uniform region — not a useful trace + stack.push(nli); + } + } + } + + return { + mask, dimA, dimB, boxA0: 0, boxB0: 0, + sliceIndex: axis === 2 ? sk : axis === 0 ? si : sj, + }; +} + +// Writes a previously-computed mask into (or out of) the active segment on its +// slice, per `operation`: +// - fillInside: write segmentIndex where the trace mask is set (old/only behavior) +// - eraseInside: write 0 where the trace mask is set +// - fillOutside: write segmentIndex everywhere on the slice EXCEPT the trace mask +// - eraseOutside: write 0 everywhere on the slice EXCEPT the trace mask +export function commitLevelTraceMask( + pane: CinePane, + traced: LevelTraceMask, + segmentIndex: number, + operation: LevelTraceOperation = "fillInside", + maskFilter: MaskFilter = () => true +): { filledVoxels: number } | null { + const segVolume = cache.getVolume(segmentationId); + const segVm = segVolume?.voxelManager as any; + if (!segVolume || !segVm) return null; + const axis = _sliceAxisForPane(pane); + const at = (a: number, b: number): [number, number, number] => + axis === 2 ? [a, b, traced.sliceIndex] : axis === 0 ? [traced.sliceIndex, a, b] : [a, traced.sliceIndex, b]; + + const wantsInside = operation === "fillInside" || operation === "eraseInside"; + const paints = operation === "fillInside" || operation === "fillOutside"; + const next = paints ? segmentIndex : 0; + + // Use the solid, hole-free fill derived from the trace's outer boundary — + // not the raw intensity mask, which can be porous (small vessels, + // calcifications, noise) and previously left gaps in fillInside/eraseInside, + // and made fillOutside/eraseOutside act against the wrong region entirely. + const solidMask = _solidFillFromLevelTraceMask(traced); + + const changes: Array<{ i: number; j: number; k: number; prev: number }> = []; + for (let b = 0; b < traced.dimB; b++) for (let a = 0; a < traced.dimA; a++) { + const isInside = solidMask[a + b * traced.dimA] === 1; + if (isInside !== wantsInside) continue; + const [i, j, k] = at(a + traced.boxA0, b + traced.boxB0); + if (!maskFilter(i, j, k)) continue; // <-- gate + const existing = segVm.getAtIJK(i, j, k); + if (paints) { + if (existing === next) continue; + } else { + if (existing !== segmentIndex) continue; // erase only touches the target segment + } + changes.push({ i, j, k, prev: existing }); + } + if (!changes.length) return { filledVoxels: 0 }; + for (const c of changes) segVm.setAtIJK(c.i, c.j, c.k, next); + _pushFillHistory({ + undo: () => { for (const c of changes) segVm.setAtIJK(c.i, c.j, c.k, c.prev); _notifySegmentationChanged(); }, + redo: () => { for (const c of changes) segVm.setAtIJK(c.i, c.j, c.k, next); _notifySegmentationChanged(); }, + }); + _notifySegmentationChanged(); + return { filledVoxels: changes.length }; +} + +// Converts a traced mask's boundary into a canvas-space polygon (Moore-neighbor +// boundary trace, then IJK -> world -> canvas per point) — one outline, good +// enough for a live preview. +// Traces the outer boundary of a flood-filled region mask via Moore-neighbor +// tracing and returns it as a polygon in the mask's own local (a,b) pixel +// coordinates — shared by the canvas-path preview (below) and by the solid +// interior fill used to commit fillInside/eraseInside/fillOutside/eraseOutside +// without leaving holes wherever the underlying intensity mask was porous. +function _traceMaskBoundaryAB(mask: Uint8Array, dimA: number, dimB: number): Array<[number, number]> | null { + let startA = -1, startB = -1; + outer: for (let b = 0; b < dimB; b++) { + for (let a = 0; a < dimA; a++) { + if (mask[a + b * dimA]) { startA = a; startB = b; break outer; } + } + } + if (startA === -1) return null; + + const isFg = (a: number, b: number) => a >= 0 && b >= 0 && a < dimA && b < dimB && mask[a + b * dimA] === 1; + const dirs: Array<[number, number]> = [[1,0],[1,1],[0,1],[-1,1],[-1,0],[-1,-1],[0,-1],[1,-1]]; + const boundary: Array<[number, number]> = []; + let curA = startA, curB = startB; + let backtrackDir = 6; + const maxSteps = dimA * dimB * 4; + for (let step = 0; step < maxSteps; step++) { + boundary.push([curA, curB]); + let found = false; + for (let k = 0; k < 8; k++) { + const dirIdx = (backtrackDir + 1 + k) % 8; + const [da, db] = dirs[dirIdx]; + const na = curA + da, nb = curB + db; + if (isFg(na, nb)) { + curA = na; curB = nb; + backtrackDir = (dirIdx + 4) % 8; + found = true; + break; + } + } + if (!found) break; + if (curA === startA && curB === startB && boundary.length > 2) break; + } + return boundary.length >= 3 ? boundary : null; +} + +// Turns the raw (potentially porous, hole-riddled) intensity flood-fill mask +// into a solid filled region: trace its outer boundary, then rasterize that +// boundary as a closed polygon exactly like the Scissors tool does. This is +// what fillInside/eraseInside/fillOutside/eraseOutside should actually judge +// "inside" against — the outline the user sees in the preview — not the raw +// per-pixel intensity match, which naturally has gaps (calcifications, small +// vessels, noise) inside an otherwise uniform organ. Falls back to the raw +// mask if the boundary trace fails (e.g. a 1-2px sliver with no clean contour). +function _solidFillFromLevelTraceMask(traced: LevelTraceMask): Uint8Array { + const boundaryAB = _traceMaskBoundaryAB(traced.mask, traced.dimA, traced.dimB); + if (!boundaryAB) return traced.mask; + const solid = _rasterizePolygonInsideMask(traced.dimA, traced.dimB, boundaryAB); + return solid; +} + +export function levelTraceMaskToCanvasPath(pane: CinePane, traced: LevelTraceMask): Array<[number, number]> | null { + const engine = getRenderingEngine(renderingEngineId); + const volume = _currentCtVolumeId ? cache.getVolume(_currentCtVolumeId) : undefined; + if (!engine || !volume?.imageData) return null; + const viewport = engine.getViewport(CINE_VIEWPORT_BY_PANE[pane]) as any; + if (!viewport) return null; + + const { dimA, dimB, boxA0, boxB0, sliceIndex } = traced; + const axis = _sliceAxisForPane(pane); + const at = (a: number, b: number): [number, number, number] => + axis === 2 ? [a, b, sliceIndex] : axis === 0 ? [sliceIndex, a, b] : [a, sliceIndex, b]; + + const boundary = _traceMaskBoundaryAB(traced.mask, dimA, dimB); + if (!boundary) return null; + + const points: Array<[number, number]> = []; + for (const [a, b] of boundary) { + const [i, j, k] = at(a + boxA0, b + boxB0); + const world = volume.imageData.indexToWorld([i, j, k]); + try { + const canvasPt = viewport.worldToCanvas(world); + points.push([canvasPt[0], canvasPt[1]]); + } catch { + /* skip unmappable point */ + } + } + return points.length >= 3 ? points : null; +} + +export type MaskFilter = (i: number, j: number, k: number) => boolean; + +// Builds a voxel-level predicate from the global MaskingSelect choice. `ids` is the +// resolved segment set ("all segments" / "visible segments" / just the active one); +// "everywhere" ignores ids entirely. Every edit function below ANDs this into its +// existing per-voxel accept check, so "outside" truly means "outside those segments' +// current voxels" — not just "which segment index the operation targets." +export function buildMaskFilter(area: MaskingArea, ids: number[]): MaskFilter { + if (area === "everywhere") return () => true; + + // No concrete target resolved for this scope (e.g. "this segment" selected + // but no active/target segment yet) — don't silently fall back to + // "allow everything" or "block everything"; just no-op back to unrestricted + // and let the caller decide whether to warn the user. + if (ids.length === 0) { + console.warn(`Masking scope "${area}" has no resolved segment ids — ignoring scope for this operation.`); + return () => true; + } + + const idSet = new Set(ids); + const inside = area.startsWith("inside"); + const segVolume = cache.getVolume(segmentationId); + const vm = segVolume?.voxelManager as any; + if (!vm) return () => true; + + return (i: number, j: number, k: number) => { + const label = vm.getAtIJK(i, j, k); + return inside ? idSet.has(label) : !idSet.has(label); + }; +} + + +// CornerstoneNifti2.ts +// Locks/unlocks segments for the BRUSH specifically, based on the current global +// masking selection — NOT just "lock everything except the active segment." +// "everywhere" must unlock everything (the brush can paint over any organ); +// the inside/outside-segment(s) variants restrict which OTHER segments the +// brush may overwrite, so their locking mirrors what maskFilter is doing at +// the voxel level for every other (non-brush) tool. +function _applyBrushLockState(activeIndex: number, unlockedIds: number[] | "all") { + try { + const volume = cache.getVolume(segmentationId); + const vm = volume?.voxelManager as any; + if (!volume || !vm) return; + let data: ArrayLike | undefined; + try { data = vm.getCompleteScalarDataArray?.(); } catch { /* fall through */ } + + let maxSeen = 0; + if (data && data.length) { + for (let i = 0; i < data.length; i++) { + const v = data[i]; + if (typeof v === "number" && v > maxSeen) maxSeen = v; + } + } + + const upper = Math.max(maxSeen, _lastColorLUT?.length ?? 0, activeIndex); + const unlockedSet = unlockedIds === "all" ? null : new Set(unlockedIds); + // Start at 0 (background), not 1 — background must be explicitly lockable too, + // or "inside segments" scopes can never actually exclude unsegmented voxels + // (background would silently stay paintable regardless of scope). + for (let i = 0; i <= upper; i++) { + const isUnlocked = i === activeIndex || unlockedIds === "all" || unlockedSet!.has(i); + segmentation.segmentLocking.setSegmentIndexLocked(segmentationId, i, !isUnlocked); + } + } catch { + /* segmentation not loaded yet */ + } +} + + + +export function getActiveEditSegment(): number { + return _activeEditSegment; +} + +// Reads the raw segmentation label at a given voxel index — used to check +// whether an island-picker click actually landed inside the segment the +// operation is about to run on (islands only exist within the active +// segment, so a click anywhere else can't be a valid pick). +export function getSegmentAtVoxel(voxel: [number, number, number]): number | undefined { + const segVolume = cache.getVolume(segmentationId); + const vm = segVolume?.voxelManager as any; + if (!segVolume || !vm) return undefined; + const [i, j, k] = voxel; + const [dimX, dimY, dimZ] = vm.dimensions; + if (i < 0 || j < 0 || k < 0 || i >= dimX || j >= dimY || k >= dimZ) return undefined; + const res = vm.getAtIJK(i, j, k); + return typeof res === "number" ? res : undefined; +} + +// Permanently clears every voxel belonging to a segment (used when a segment +// is deleted from the Segments popup — without this, deleting only hid the +// row in the UI while the labelmap data for it stayed in the volume, so it +// could still show up in the 3D render / masking scopes / islands, etc). +export function deleteSegmentEverywhere(segmentIndex: number): { changedVoxels: number } | null { + const segVolume = cache.getVolume(segmentationId); + const vm = segVolume?.voxelManager as any; + if (!segVolume || !vm) return null; + const [dimX, dimY, dimZ] = vm.dimensions; + const changes: Array<{ i: number; j: number; k: number }> = []; + for (let k = 0; k < dimZ; k++) { + for (let j = 0; j < dimY; j++) { + for (let i = 0; i < dimX; i++) { + if (vm.getAtIJK(i, j, k) === segmentIndex) changes.push({ i, j, k }); + } + } + } + if (!changes.length) return { changedVoxels: 0 }; + for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, 0); + _pushFillHistory({ + undo: () => { for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, segmentIndex); _notifySegmentationChanged(); }, + redo: () => { for (const c of changes) vm.setAtIJK(c.i, c.j, c.k, 0); _notifySegmentationChanged(); }, + }); + if (_activeEditSegment === segmentIndex) _activeEditSegment = 0; + _notifySegmentationChanged(); + return { changedVoxels: changes.length }; +} + +export function setActiveEditSegment(segmentIndex: number) { + _activeEditSegment = segmentIndex; + try { + segmentation.segmentIndex.setActiveSegmentIndex(segmentationId, segmentIndex); + // Re-apply using whatever masking scope was last set, so switching the active + // segment doesn't silently reset locking back to "only this segment." + _applyBrushLockState(segmentIndex, _brushUnlockedScope); + } catch { + /* segmentation not loaded yet */ + } +} + +// Module-level: which segments the brush is currently allowed to paint over, +// besides the active one. "all" = everywhere (no locking). Set by the UI whenever +// the global masking selection or its resolved id list changes. +let _brushUnlockedScope: number[] | "all" = []; + +// Called by the UI (VisualizationPage) whenever maskingArea/resolved ids change, +// so the brush's lock state always matches the same "Applies to" selection every +// other tool's maskFilter already respects. +export function setBrushMaskingScope(scope: number[] | "all") { + _brushUnlockedScope = scope; + _applyBrushLockState(_activeEditSegment, scope); +} + +let _brushStrokeSnapshot: ArrayLike | null = null; + +// Call when a brush/eraser stroke starts (pointerdown on a pane while brush/eraser is active). +export function beginBrushMaskGuard() { + const volume = cache.getVolume(segmentationId); + const vm = volume?.voxelManager as any; + if (!volume || !vm) { _brushStrokeSnapshot = null; return; } + try { + const data = vm.getCompleteScalarDataArray?.(); + _brushStrokeSnapshot = data && data.length ? (data as any).slice() : null; + } catch { + _brushStrokeSnapshot = null; + } +} + +// Same inside/outside rule as buildMaskFilter, but evaluated against a plain +// label snapshot instead of the live volume. endBrushMaskGuard must judge each +// voxel by what it WAS before the stroke, not by what the brush just painted +// into it — a MaskFilter built from the live vm.getAtIJK() answers the wrong +// question here (see endBrushMaskGuard below for why that broke both +// "inside" and "outside" scopes). +function buildSnapshotMaskFilter( + area: MaskingArea, + ids: number[], + snapshot: ArrayLike, + dimX: number, + dimY: number +): MaskFilter { + if (area === "everywhere") return () => true; + if (ids.length === 0) return () => true; + const idSet = new Set(ids); + const inside = area.startsWith("inside"); + const sliceSize = dimX * dimY; + return (i: number, j: number, k: number) => { + const label = snapshot[i + j * dimX + k * sliceSize]; + return inside ? idSet.has(label) : !idSet.has(label); + }; +} + +// Call when the stroke ends (pointerup) — reverts anything the brush touched +// that falls outside the given masking scope. +// +// IMPORTANT: this takes `area`/`ids` (the same inputs buildMaskFilter takes), +// NOT a pre-built MaskFilter. A MaskFilter built from the live volume reads +// each voxel's label AFTER the stroke already wrote it — so by the time this +// ran, a voxel the brush just painted now carried the *active* segment's +// label. For "inside" scopes that made a background voxel the brush painted +// look like it was already inside the scope (idSet often contains the active +// segment), so nothing ever got reverted — "inside" silently behaved like +// "everywhere". For "outside" scopes it made the exact opposite mistake: the +// freshly-painted voxel now matches the very id it's supposed to stay outside +// of, so the filter rejected it and every stroke got reverted immediately. +// Judging eligibility from the pre-stroke snapshot (what the voxel WAS, not +// what it became) fixes both. +export function endBrushMaskGuard(area: MaskingArea, ids: number[]) { + const snapshot = _brushStrokeSnapshot; + _brushStrokeSnapshot = null; + if (!snapshot) return; + if (area === "everywhere") return; // nothing was restricted — nothing to revert + const volume = cache.getVolume(segmentationId); + const vm = volume?.voxelManager as any; + if (!volume || !vm) return; + const [dimX, dimY, dimZ] = vm.dimensions; + const sliceSize = dimX * dimY; + const filter = buildSnapshotMaskFilter(area, ids, snapshot, dimX, dimY); + let reverted = 0; + for (let k = 0; k < dimZ; k++) for (let j = 0; j < dimY; j++) for (let i = 0; i < dimX; i++) { + const idx = i + j * dimX + k * sliceSize; + const before = snapshot[idx]; + const after = vm.getAtIJK(i, j, k); + if (after !== before && !filter(i, j, k)) { + vm.setAtIJK(i, j, k, before); + reverted++; + } + } + if (reverted) _notifySegmentationChanged(); } \ No newline at end of file diff --git a/PanTS-Demo/src/helpers/viewer/useDraggablePanel.ts b/PanTS-Demo/src/helpers/viewer/useDraggablePanel.ts new file mode 100644 index 0000000..a4e62ef --- /dev/null +++ b/PanTS-Demo/src/helpers/viewer/useDraggablePanel.ts @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +interface Pos { + x: number; + y: number; +} + +interface Size { + width: number; + height: number; +} + +interface Options { + initial: Pos; + expandedSize: Size; + minimizedSize?: Size; + marginX?: number; + marginY?: number; + anchorBottom?: boolean; + /** + * When true, horizontal dragging is clamped flush to the viewport's + * left/right edges (margin is treated as 0 on the X axis) instead of + * allowing the panel to hang partway off-screen. Vertical clamping is + * unaffected by this flag. + */ + lockToScreenEdges?: boolean; +} + +/** + * Shared drag/minimize/clamp behavior for every floating panel in the + * annotation UI (tool dock, segments popup, tool option panels, ...). + * + * Panels are clamped to stay on-screen whenever they're actually moved + * (dragged, or expanded/minimized), but a panel's position is otherwise + * left alone — in particular it does NOT reclamp on window "resize". That + * used to happen and caused a visible jump any time the viewport's layout + * width changed without the user touching the panel at all, e.g. opening + * browser devtools or an in-app side console. Panels now stay exactly + * where the user left them (or at their default position) across that + * kind of resize. + */ +export function useDraggablePanel({ + initial, + expandedSize, + minimizedSize = { width: 160, height: 46 }, + marginX = -30, + marginY = 12, + anchorBottom = false, + lockToScreenEdges = false, +}: Options) { + const [pos, setPos] = useState(initial); + const [minimized, setMinimized] = useState(false); + + const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); + const minimizedRef = useRef(minimized); + minimizedRef.current = minimized; + + const clamp = useCallback( + (p: Pos, mini: boolean): Pos => { + // Horizontal: honor the panel's actual current width (mini vs expanded + // can legitimately differ in width). When lockToScreenEdges is set, + // never let it hang off-screen — the whole point of a dock is that + // dragging it resolves to flush against the left or right edge. + // + // Use clientWidth (excludes the scrollbar) rather than innerWidth + // (includes it) — a fixed-position element clamped against + // innerWidth sits partly behind the scrollbar, leaving a visible + // gap on the real right edge of the page content. + const viewportWidth = typeof document !== "undefined" ? document.documentElement.clientWidth : window.innerWidth; + const width = mini ? minimizedSize.width : expandedSize.width; + const effMarginX = lockToScreenEdges ? 0 : marginX; + const maxX = Math.max(effMarginX, viewportWidth - width - effMarginX); + + // Vertical: ALWAYS reserve room for the expanded height, even while + // currently minimized. Otherwise a panel dragged near the bottom + // edge while minimized can later be expanded into a position where + // its lower icons/content render off-screen and become unreachable. + const height = expandedSize.height; + let minY = marginY; + let maxY = window.innerHeight - marginY; + if (anchorBottom) { + minY = height + marginY; + maxY = window.innerHeight - marginY; + } else { + maxY = Math.max(marginY, window.innerHeight - height - marginY); + } + + return { + x: Math.min(Math.max(p.x, effMarginX), maxX), + y: Math.min(Math.max(p.y, minY), maxY), + }; + }, + [expandedSize.width, expandedSize.height, minimizedSize.width, minimizedSize.height, marginX, marginY, anchorBottom, lockToScreenEdges] + ); + + // Re-clamp whenever the panel is minimized/expanded (its effective size + // just changed), but intentionally NOT on window resize — see the + // doc comment above. + useEffect(() => { + setPos((p) => clamp(p, minimized)); + }, [minimized, clamp]); + + const onPointerMove = useCallback( + (e: PointerEvent) => { + if (!dragState.current) return; + const { startX, startY, origX, origY } = dragState.current; + const next = { x: origX + (e.clientX - startX), y: origY + (e.clientY - startY) }; + setPos(clamp(next, minimizedRef.current)); + }, + [clamp] + ); + + const onPointerUp = useCallback(() => { + dragState.current = null; + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + }, [onPointerMove]); + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + dragState.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y }; + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + }, + [pos, onPointerMove, onPointerUp] + ); + + // Safety net: if the component unmounts mid-drag, don't leak listeners. + useEffect( + () => () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + }, + [onPointerMove, onPointerUp] + ); + + return { pos, minimized, setMinimized, dragHandleProps: { onPointerDown } }; +} \ No newline at end of file diff --git a/PanTS-Demo/src/helpers/viewer/useKeyboardShortcuts.ts b/PanTS-Demo/src/helpers/viewer/useKeyboardShortcuts.ts index 2915692..1998473 100644 --- a/PanTS-Demo/src/helpers/viewer/useKeyboardShortcuts.ts +++ b/PanTS-Demo/src/helpers/viewer/useKeyboardShortcuts.ts @@ -20,7 +20,7 @@ import { type PrimaryMouseToolName, type SliceInfo, } from "../CornerstoneNifti2"; -import type { MaskEditMode } from "../../components/MaskEditPanel/MaskEditPanel"; +import type { MaskEditMode } from "../../routes/VisualizationPage"; const TOOL_BY_KEY: Record = { l: LENGTH_TOOL, @@ -55,15 +55,11 @@ interface UseKeyboardShortcutsArgs { setCrosshairToolActive: (active: boolean) => void; setShowStats: (v: boolean) => void; setShowMetadata: (v: boolean) => void; - setShowEditPanel: (v: boolean) => void; + setShowAnnotationToolbar: (v: boolean) => void; // renamed from setShowEditPanel setShowMeasurePanel: Dispatch>; - /** Which pane single-pane actions (slice step/jump, zoom-to-cursor fallback) target. */ getFocusedPane: () => CinePane; - /** Live per-pane slice info, read at keypress time (not a render-time snapshot). */ sliceInfoRef: MutableRefObject>; - /** Current mask-edit mode — determines Shift+[ / Shift+]'s meaning. */ editMode: MaskEditMode; - /** Toolbar zoom slider state, kept in sync with keyboard zoom so the two never disagree. */ setZoomLevel: Dispatch>; } @@ -95,7 +91,7 @@ export function useKeyboardShortcuts({ setCrosshairToolActive, setShowStats, setShowMetadata, - setShowEditPanel, + setShowAnnotationToolbar, setShowMeasurePanel, getFocusedPane, sliceInfoRef, @@ -276,7 +272,7 @@ export function useKeyboardShortcuts({ } else if (key === "m") { setShowStats(false); setShowMetadata(false); - setShowEditPanel(false); + setShowAnnotationToolbar(false); setEditMode(null); setShowMeasurePanel((v) => !v); } else { @@ -295,11 +291,11 @@ export function useKeyboardShortcuts({ setCrosshairToolActive, setShowStats, setShowMetadata, - setShowEditPanel, + setShowAnnotationToolbar, setShowMeasurePanel, getFocusedPane, sliceInfoRef, editMode, setZoomLevel, ]); -} \ No newline at end of file +} diff --git a/PanTS-Demo/src/helpers/viewer/useLassoTool.ts b/PanTS-Demo/src/helpers/viewer/useLassoTool.ts index fe310c0..11d87ae 100644 --- a/PanTS-Demo/src/helpers/viewer/useLassoTool.ts +++ b/PanTS-Demo/src/helpers/viewer/useLassoTool.ts @@ -1,86 +1,37 @@ -import { useRef, useState, type MouseEvent } from "react"; -import { lassoCommitPolygon, type CinePane } from "../CornerstoneNifti2"; +import { lassoCommitPolygon, type MaskFilter } from "../CornerstoneNifti2"; +import { usePolygonDraw } from "./usePolygonDraw"; interface UseLassoToolArgs { - /** Only active while the caller's edit mode is "lasso". */ enabled: boolean; + maskFilter: MaskFilter; onLog?: (detail: string) => void; } -/** - * Plain point-and-click polygon lasso: click to drop anchor points on a - * single pane, then close the loop to fill the enclosed region. Deliberately - * has no magnetic/edge-snapping — straight lines between clicked points only. - */ -export function useLassoTool({ enabled, onLog }: UseLassoToolArgs) { - const [anchorsCanvas, setAnchorsCanvas] = useState>([]); - const [livePreview, setLivePreview] = useState<[number, number] | null>(null); - const paneRef = useRef(null); - - const reset = () => { - setAnchorsCanvas([]); - setLivePreview(null); - paneRef.current = null; - }; - - const handleClick = (pane: CinePane) => (e: MouseEvent) => { - if (!enabled) return; - const target = e.currentTarget as HTMLElement; - const rect = target.getBoundingClientRect(); - const pos: [number, number] = [e.clientX - rect.left, e.clientY - rect.top]; - - if (!paneRef.current) { - paneRef.current = pane; - setAnchorsCanvas([pos]); - return; - } - // One polygon lives on one pane at a time — ignore clicks elsewhere until - // this one is closed or cancelled. - if (paneRef.current !== pane) return; - setAnchorsCanvas((prev) => [...prev, pos]); - }; - - const handleMouseMove = (pane: CinePane) => (e: MouseEvent) => { - if (!enabled || paneRef.current !== pane || !anchorsCanvas.length) return; - const target = e.currentTarget as HTMLElement; - const rect = target.getBoundingClientRect(); - setLivePreview([e.clientX - rect.left, e.clientY - rect.top]); - }; - - const undo = () => { - if (anchorsCanvas.length <= 1) { - reset(); - return; - } - setAnchorsCanvas((prev) => prev.slice(0, -1)); - }; - - const cancel = () => reset(); - - const close = () => { - const pane = paneRef.current; - if (!pane || anchorsCanvas.length < 3) { - onLog?.("Lasso: need at least 3 points before closing."); - return; - } - const result = lassoCommitPolygon(pane, anchorsCanvas); - if (result?.filledVoxels) { - onLog?.(`Lasso fill (${result.filledVoxels.toLocaleString()} vox)`); - } else { - onLog?.("Lasso: no voxels filled — try a larger loop."); - } - reset(); - }; +export function useLassoTool({ enabled, maskFilter, onLog }: UseLassoToolArgs) { + const draw = usePolygonDraw({ + enabled, + onClose: (pane, points) => { + const result = lassoCommitPolygon(pane, points, undefined, maskFilter); + onLog?.( + result?.filledVoxels + ? `Lasso fill (${result.filledVoxels.toLocaleString()} vox)` + : "Lasso: no voxels filled — try a larger loop." + ); + }, + }); return { - pane: paneRef.current, - anchorsCanvas, - livePreview, - handleClick, - handleMouseMove, - undo, - cancel, - close, - reset, + pane: draw.pane, + anchorsCanvas: draw.points, + cornersCanvas: draw.corners, + livePreview: draw.livePreview, + livePreviewPath: draw.livePreviewPath, + nearClose: draw.nearClose, + handleClick: draw.handleClick, + handleDoubleClick: draw.handleDoubleClick, + handleMouseMove: draw.handleMouseMove, + undo: draw.undo, + cancel: draw.cancel, + reset: draw.reset, }; } \ No newline at end of file diff --git a/PanTS-Demo/src/helpers/viewer/useLevelTracing.ts b/PanTS-Demo/src/helpers/viewer/useLevelTracing.ts new file mode 100644 index 0000000..aafbe2b --- /dev/null +++ b/PanTS-Demo/src/helpers/viewer/useLevelTracing.ts @@ -0,0 +1,91 @@ +// helpers/viewer/useLevelTracing.ts +import { useRef, useState, type MouseEvent } from "react"; +import { + canvasPointToVoxel, + computeLevelTraceMask, + commitLevelTraceMask, + levelTraceMaskToCanvasPath, + type LevelTraceMask, + type LevelTraceOperation, + type MaskFilter, + type CinePane, +} from "../CornerstoneNifti2"; + +interface UseLevelTracingArgs { + enabled: boolean; + /** Sensitivity in HU — how far from the cursor's own intensity a neighboring + * pixel can be and still count as "the same region". Fed straight into + * computeLevelTraceMask, so dragging the slider actually changes the traced + * area (previously this was ignored in favor of a fixed internal constant). */ + toleranceHu: number; + operation: LevelTraceOperation; + activeSegmentIndex: number | null; + maskFilter: MaskFilter; + onLog?: (detail: string) => void; +} + +/** Slicer-style level tracing: on hover, flood-fill the connected same-intensity + * 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, +}: UseLevelTracingArgs) { + const [previewPane, setPreviewPane] = useState(null); + const [previewPath, setPreviewPath] = useState | null>(null); + // Last computed trace, kept alongside the preview state so a click can reuse + // it without recomputing when the click point maps to the same voxel. + const tracedRef = useRef<{ pane: CinePane; mask: LevelTraceMask } | null>(null); + + const clearPreview = () => { + tracedRef.current = null; + setPreviewPane(null); + setPreviewPath(null); + }; + + const computeAt = (pane: CinePane, canvasPos: [number, number]): LevelTraceMask | null => { + const seed = canvasPointToVoxel(pane, canvasPos); + if (!seed) return null; + return computeLevelTraceMask(pane, seed, toleranceHu); + }; + + const handleMouseMove = (pane: CinePane) => (e: MouseEvent) => { + if (!enabled) return; + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const canvasPos: [number, number] = [e.clientX - rect.left, e.clientY - rect.top]; + const traced = computeAt(pane, canvasPos); + if (!traced) { clearPreview(); return; } + tracedRef.current = { pane, mask: traced }; + setPreviewPane(pane); + setPreviewPath(levelTraceMaskToCanvasPath(pane, traced)); + }; + + const handleClick = (pane: CinePane) => (e: MouseEvent) => { + if (!enabled) return; + if (activeSegmentIndex == null) { + onLog?.("Level tracing: no target segment selected."); + return; + } + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const canvasPos: [number, number] = [e.clientX - rect.left, e.clientY - rect.top]; + // Recompute fresh off the click point rather than trusting a possibly + // stale hover preview (e.g. a click with no preceding mousemove). + const traced = computeAt(pane, canvasPos) ?? (tracedRef.current?.pane === pane ? tracedRef.current.mask : null); + if (!traced) { + onLog?.("Level tracing: no traceable region under the cursor."); + return; + } + const result = commitLevelTraceMask(pane, traced, activeSegmentIndex, operation, maskFilter); + onLog?.( + result?.filledVoxels + ? `Level trace ${operation} (${result.filledVoxels.toLocaleString()} vox)` + : "Level tracing: no voxels changed — try a different spot or higher sensitivity." + ); + }; + + return { + handleClick, + handleMouseMove, + previewPane, + previewPath, + }; +} \ No newline at end of file diff --git a/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts b/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts new file mode 100644 index 0000000..e7d66f2 --- /dev/null +++ b/PanTS-Demo/src/helpers/viewer/usePolygonDraw.ts @@ -0,0 +1,170 @@ +// helpers/viewer/usePolygonDraw.ts +import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react"; +import type { CinePane } from "../CornerstoneNifti2"; + +const CLOSE_CLICK_RADIUS_PX = 10; + +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. */ + 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. */ + computeLivePath?: ( + pane: CinePane, + from: [number, number], + to: [number, number] + ) => Array<[number, number]> | null | undefined; +} + +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>([]); + // 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>([]); + // 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. + const legLengthsRef = useRef([]); + const [livePreview, setLivePreview] = useState<[number, number] | null>(null); + const [livePreviewPath, setLivePreviewPath] = useState | null>(null); + // True when the cursor is currently within closing range of the first point — + // drives the "click here to close" highlight on the start anchor. + const [nearClose, setNearClose] = useState(false); + const paneRef = useRef(null); + + const reset = useCallback(() => { + setPoints([]); + setCorners([]); + legLengthsRef.current = []; + setLivePreview(null); + setLivePreviewPath(null); + setNearClose(false); + paneRef.current = null; + }, []); + + useEffect(() => { + if (!enabled) reset(); + }, [enabled, reset]); + + 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]; + const closingLeg = computeLivePath?.(pane, last, first); + const closingPoints = closingLeg && closingLeg.length >= 2 ? closingLeg.slice(1) : [first]; + onClose(pane, [...points, ...closingPoints]); + reset(); + }, [points, corners, 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]; + + if (!paneRef.current) { + paneRef.current = pane; + setPoints([rawPos]); + setCorners([rawPos]); + 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) { + 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 handleDoubleClick = (pane: CinePane) => (e: MouseEvent) => { + if (!enabled || paneRef.current !== pane) return; + e.preventDefault(); + close(); + }; + + const handleMouseMove = (pane: CinePane) => (e: MouseEvent) => { + if (!enabled || paneRef.current !== pane || !corners.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]); + + if (corners.length >= 3) { + const [fx, fy] = corners[0]; + setNearClose(Math.hypot(rawPos[0] - fx, rawPos[1] - fy) < CLOSE_CLICK_RADIUS_PX); + } + }; + + const undo = () => { + if (corners.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)); + }; + + useEffect(() => { + if (!enabled) return; + 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(); } + // 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) { + e.preventDefault(); + undo(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [enabled, corners, reset, close]); + + return { + pane: paneRef.current, + points, + corners, + livePreview, + livePreviewPath, + nearClose, + handleClick, + handleDoubleClick, + handleMouseMove, + undo, + cancel: reset, + reset, + }; +} \ No newline at end of file diff --git a/PanTS-Demo/src/helpers/viewer/useScissorsTool.ts b/PanTS-Demo/src/helpers/viewer/useScissorsTool.ts new file mode 100644 index 0000000..61da239 --- /dev/null +++ b/PanTS-Demo/src/helpers/viewer/useScissorsTool.ts @@ -0,0 +1,68 @@ +import { cutSegmentWithPolygon, computeLiveWirePath, type ScissorsOperation, type MaskFilter } from "../CornerstoneNifti2"; +import { usePolygonDraw } from "./usePolygonDraw"; + +interface UseScissorsToolArgs { + enabled: boolean; + operation: ScissorsOperation; + applyToVisibleSegments: boolean; + visibleSegmentIndices: number[]; + activeSegmentIndex: number | null; + maskFilter: MaskFilter; // <-- was missing + /** When true, every placed point (and the live preview point) snaps onto + * the nearest strong intensity edge within a small radius — the "magnet" + * helper, like Photoshop's magnetic lasso. */ + magnetEnabled?: boolean; + onLog?: (detail: string) => void; +} + +/** Draw a closed shape, cut with it: erase/fill inside or outside, on the drawn slice only. */ +export function useScissorsTool({ + enabled, operation, applyToVisibleSegments, visibleSegmentIndices, activeSegmentIndex, maskFilter, magnetEnabled, onLog, +}: UseScissorsToolArgs) { + const draw = usePolygonDraw({ + enabled, + // Real Photoshop-style magnetic lasso behavior: between fastening + // points, the path is the lowest-cost route (Mortensen & Barrett + // "live wire", via Dijkstra over gradient/Laplacian/direction cost) + // hugging nearby intensity edges, instead of snapping each raw click + // onto the nearest edge pixel (which is what made it feel like it was + // "going wherever it wants" — a single point snap has no notion of a + // path between points, so it could jump to an unrelated nearby edge). + computeLivePath: magnetEnabled + ? (pane, from, to) => computeLiveWirePath(pane, from, to) + : undefined, + onClose: (pane, points) => { + if (activeSegmentIndex == null) { + onLog?.("Scissors: no target segment selected."); + return; + } + const result = cutSegmentWithPolygon( + pane, + points, + { operation, sliceCut: "unlimited", sliceCutDepthMm: 0, applyToVisibleSegments, visibleSegmentIndices }, + activeSegmentIndex, + maskFilter // <-- was missing, so it always defaulted to () => true ("everywhere") + ); + onLog?.( + result?.changedVoxels + ? `Scissors ${operation} (${result.changedVoxels.toLocaleString()} vox)` + : "Scissors: no voxels changed — try repositioning the shape." + ); + }, + }); + + return { + pane: draw.pane, + anchorsCanvas: draw.points, + cornersCanvas: draw.corners, + livePreview: draw.livePreview, + livePreviewPath: draw.livePreviewPath, + nearClose: draw.nearClose, + handleClick: draw.handleClick, + handleDoubleClick: draw.handleDoubleClick, + handleMouseMove: draw.handleMouseMove, + undo: draw.undo, + cancel: draw.cancel, + reset: draw.reset, + }; +} \ No newline at end of file diff --git a/PanTS-Demo/src/helpers/viewer/useSliceAnchorPicker.ts b/PanTS-Demo/src/helpers/viewer/useSliceAnchorPicker.ts new file mode 100644 index 0000000..8a868b0 --- /dev/null +++ b/PanTS-Demo/src/helpers/viewer/useSliceAnchorPicker.ts @@ -0,0 +1,107 @@ +// useSliceAnchorPicker.ts +// +// Powers the guided "click the shape on the first/last slice" flow shared by +// CopyAcrossSlicesFlyout and FillBetweenSlicesFlyout. The user never types a +// pane name or a slice number — they click directly in whichever pane it's +// easiest to see the organ in, and this resolves pane + slice index for them +// via pickSliceAnchorAtClientPoint. +import { useEffect, useRef, useState } from "react"; +import { pickSliceAnchorAtClientPoint, type CinePane } from "../CornerstoneNifti2"; + +export type SliceAnchor = { pane: CinePane; sliceIndex: number }; +type Step = "first" | "last"; +type Phase = "idle" | "picking" | "ready"; + +interface Options { + segmentIndex: number; + /** + * Does the LAST click also need to land on an existing drawing of the + * segment? True for interpolation (both ends must already be drawn). + * False for copy (the destination slice is expected to be empty — + * that's the point of copying there), where the last click just needs + * to land on a valid slice in the same pane. + */ + lastRequiresSegment: boolean; + /** Every rejected click (wrong spot, wrong pane, same slice, etc.) is reported here — never silent. */ + onError: (detail: string) => void; +} + +export function useSliceAnchorPicker({ segmentIndex, lastRequiresSegment, onError }: Options) { + const [step, setStep] = useState("first"); + const [phase, setPhase] = useState("idle"); + const [first, setFirst] = useState(null); + const [last, setLast] = useState(null); + const stepRef = useRef(step); + stepRef.current = step; + const firstRef = useRef(first); + firstRef.current = first; + + useEffect(() => { + if (phase !== "picking") return; + + const onClick = (e: PointerEvent) => { + const hit = pickSliceAnchorAtClientPoint(e.clientX, e.clientY); + if (!hit) { + onError("Click inside one of the image panes."); + return; + } + e.preventDefault(); + e.stopPropagation(); + + const isFirstStep = stepRef.current === "first"; + const needsSegment = isFirstStep || lastRequiresSegment; + if (needsSegment && hit.segmentAtPoint !== segmentIndex) { + onError( + isFirstStep + ? "Nothing drawn there — click directly on the shape you want to copy/fill from." + : "Nothing drawn there — click directly on the shape on this slice." + ); + return; + } + + if (isFirstStep) { + setFirst({ pane: hit.pane, sliceIndex: hit.sliceIndex }); + setStep("last"); + return; // stay in picking mode for the second click + } + + // step === "last" + if (firstRef.current && hit.pane !== firstRef.current.pane) { + onError(`Click in the same view (${firstRef.current.pane}) as the first slice.`); + return; + } + if (firstRef.current && hit.sliceIndex === firstRef.current.sliceIndex) { + onError("That's the same slice — scroll to a different one first."); + return; + } + setLast({ pane: hit.pane, sliceIndex: hit.sliceIndex }); + setPhase("ready"); + }; + + window.addEventListener("pointerdown", onClick, true); + return () => window.removeEventListener("pointerdown", onClick, true); + }, [phase, segmentIndex, lastRequiresSegment, onError]); + + const startPicking = () => { + setPhase("picking"); + setStep(first ? "last" : "first"); + }; + const cancelPicking = () => setPhase(first && last ? "ready" : "idle"); + + const reset = () => { + setFirst(null); + setLast(null); + setStep("first"); + setPhase("idle"); + }; + + return { + phase, // "idle" | "picking" | "ready" + step, // which click we're waiting for while picking + first, + last, + startPicking, + cancelPicking, + reset, + }; +} \ No newline at end of file diff --git a/PanTS-Demo/src/helpers/viewer/useSmartFill.ts b/PanTS-Demo/src/helpers/viewer/useSmartFill.ts index 17c9f1e..aa12f92 100644 --- a/PanTS-Demo/src/helpers/viewer/useSmartFill.ts +++ b/PanTS-Demo/src/helpers/viewer/useSmartFill.ts @@ -2,8 +2,10 @@ import { useRef, useState, type MouseEvent } from "react"; import { canvasPointToVoxel, runDualScribbleFill, + pushEditHistory, type CinePane, type SliceInfo, + type MaskFilter, } from "../CornerstoneNifti2"; type ScribblePoint = { pos: [number, number]; slice: number }; @@ -21,6 +23,8 @@ interface UseSmartFillArgs { /** Read the current slice index per pane (kept as a ref by the caller so * this hook doesn't need to re-render on every slice change). */ sliceInfoRef: React.MutableRefObject>; + /** Global "applies to" masking predicate — same one every other tool uses. */ + maskFilter: MaskFilter; /** Optional reading-session logger. */ onLog?: (detail: string) => void; } @@ -31,7 +35,7 @@ interface UseSmartFillArgs { * foreground region away from the background markers. Scope can be locked to * the pane/slice the scribbles started on, or applied across the whole volume. */ -export function useSmartFill({ enabled, sliceInfoRef, onLog }: 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); @@ -41,11 +45,29 @@ export function useSmartFill({ enabled, sliceInfoRef, onLog }: UseSmartFillArgs) const bgVoxelsRef = useRef<[number, number, number][]>([]); const paneRef = useRef(null); + // Mirrors `preview` so stroke bookkeeping can read the latest value + // synchronously (state updates are async/batched, refs aren't). + const previewRef = useRef(preview); + const updatePreview = (next: Record) => { + previewRef.current = next; + setPreview(next); + }; + + // Captures everything needed to undo/redo one whole click-and-drag + // stroke as a single step — not one undo per pixel, the same way a + // brush stroke undoes as one action rather than one per sampled point. + const strokeRef = useRef<{ + mode: "fg" | "bg"; + pane: CinePane; + voxelStart: number; + previewStart: number; + } | null>(null); + const clearScribbles = () => { fgVoxelsRef.current = []; bgVoxelsRef.current = []; paneRef.current = null; - setPreview(EMPTY_PREVIEW); + updatePreview(EMPTY_PREVIEW); }; const addPoint = (pane: CinePane, e: MouseEvent) => { @@ -59,13 +81,13 @@ export function useSmartFill({ enabled, sliceInfoRef, onLog }: UseSmartFillArgs) (markMode === "fg" ? fgVoxelsRef : bgVoxelsRef).current.push(voxel); const sliceIdx = sliceInfoRef.current[pane]?.current ?? -1; - setPreview((prev) => ({ - ...prev, + updatePreview({ + ...previewRef.current, [pane]: { - ...prev[pane], - [markMode]: [...prev[pane][markMode], { pos: canvasPos, slice: sliceIdx }], + ...previewRef.current[pane], + [markMode]: [...previewRef.current[pane][markMode], { pos: canvasPos, slice: sliceIdx }], }, - })); + }); }; const apply = () => { @@ -74,7 +96,7 @@ export function useSmartFill({ enabled, sliceInfoRef, onLog }: UseSmartFillArgs) if (!fg.length || !bg.length) return; const sliceLock = scope === "slice" && paneRef.current ? { pane: paneRef.current } : null; - const result = runDualScribbleFill(fg, bg, { sliceLock }); + const result = runDualScribbleFill(fg, bg, { sliceLock, maskFilter }); if (result) onLog?.(`Smart fill: ${result.filledVoxels.toLocaleString()} voxels`); clearScribbles(); }; @@ -83,6 +105,12 @@ export function useSmartFill({ enabled, sliceInfoRef, onLog }: UseSmartFillArgs) if (!enabled) return; e.preventDefault(); scribbleActiveRef.current = true; + strokeRef.current = { + mode: markMode, + pane, + voxelStart: (markMode === "fg" ? fgVoxelsRef : bgVoxelsRef).current.length, + previewStart: previewRef.current[pane][markMode].length, + }; addPoint(pane, e); }; const handleMouseMove = (pane: CinePane) => (e: MouseEvent) => { @@ -91,6 +119,32 @@ export function useSmartFill({ enabled, sliceInfoRef, onLog }: UseSmartFillArgs) }; const handleMouseUp = () => { scribbleActiveRef.current = false; + const stroke = strokeRef.current; + strokeRef.current = null; + if (!stroke) return; + + const { mode, pane, voxelStart, previewStart } = stroke; + const voxelsRef = mode === "fg" ? fgVoxelsRef : bgVoxelsRef; + const addedVoxels = voxelsRef.current.slice(voxelStart); + const addedPreview = previewRef.current[pane][mode].slice(previewStart); + if (!addedVoxels.length) return; // clicked but no valid voxel under the cursor + + pushEditHistory({ + undo: () => { + voxelsRef.current = voxelsRef.current.slice(0, voxelStart); + updatePreview({ + ...previewRef.current, + [pane]: { ...previewRef.current[pane], [mode]: previewRef.current[pane][mode].slice(0, previewStart) }, + }); + }, + redo: () => { + voxelsRef.current = [...voxelsRef.current, ...addedVoxels]; + updatePreview({ + ...previewRef.current, + [pane]: { ...previewRef.current[pane], [mode]: [...previewRef.current[pane][mode], ...addedPreview] }, + }); + }, + }); }; return { @@ -104,5 +158,7 @@ export function useSmartFill({ enabled, sliceInfoRef, onLog }: UseSmartFillArgs) handleMouseUp, apply, clearScribbles, + hasForegroundMarks: fgVoxelsRef.current.length > 0, + hasBackgroundMarks: bgVoxelsRef.current.length > 0, }; } \ No newline at end of file diff --git a/PanTS-Demo/src/routes/VisualizationPage.css b/PanTS-Demo/src/routes/VisualizationPage.css index d609ec7..3211c98 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.css +++ b/PanTS-Demo/src/routes/VisualizationPage.css @@ -18,10 +18,12 @@ } @media (max-width: 899px) { + /* On narrow screens the sidebar covers the full width, so no content shift. */ .VisualizationPage.ai-panel-open { width: 100vw !important; } + .VisualizationPage.ai-panel-open .checkbox-bottom-bar { width: 100vw; } @@ -102,7 +104,7 @@ NiiVue 3D canvas still sized to fullscreen right after leaving 3D view) would force its grid row to expand and squeeze the others. Let cells shrink and clip instead, so the 2×2 layout stays correct the instant we return to MPR. */ -.visualization-container > div { +.visualization-container>div { min-width: 0; min-height: 0; width: 100%; @@ -113,23 +115,28 @@ position: relative; overflow: hidden; } -.vp-pane-wrap > div:first-child { + +.vp-pane-wrap>div:first-child { width: 100%; height: 100%; } .report-container button { margin: 5px; - background-color: #222; /* 深灰色背景 */ - color: white; /* 白色文字 */ - border: 1px solid white; /* 白色边框 */ + background-color: #222; + /* 深灰色背景 */ + color: white; + /* 白色文字 */ + border: 1px solid white; + /* 白色边框 */ border-radius: 4px; padding: 8px 16px; cursor: pointer; } .report-container button:hover { - background-color: #333; /* hover 时稍微亮一点的灰色 */ + background-color: #333; + /* hover 时稍微亮一点的灰色 */ } @@ -143,9 +150,12 @@ border-top: 1px solid #444; z-index: 1000; overflow-y: auto; - overflow-x: hidden; /* ✅ 防止横向滚动 */ - width: 100vw; /* ✅ 明确控制在视口范围 */ - box-sizing: border-box; /* ✅ 避免 padding 导致溢出 */ + overflow-x: hidden; + /* ✅ 防止横向滚动 */ + width: 100vw; + /* ✅ 明确控制在视口范围 */ + box-sizing: border-box; + /* ✅ 避免 padding 导致溢出 */ } .back-button { @@ -156,6 +166,7 @@ border-radius: 6px; cursor: pointer; } + .back-button:hover { background-color: #333; } @@ -172,7 +183,10 @@ box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.8); } -.vp-sidebar::-webkit-scrollbar { width: 8px; } +.vp-sidebar::-webkit-scrollbar { + width: 8px; +} + .vp-sidebar::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.12); border-radius: 99px; @@ -186,6 +200,7 @@ text-transform: uppercase; color: var(--vp-text-faint); } + .vp-case-id { font-family: var(--vp-font); font-size: 22px; @@ -205,6 +220,7 @@ flex-direction: column; gap: 10px; } + .vp-panel__title { font-family: var(--vp-mono); font-size: 10px; @@ -221,6 +237,7 @@ flex-wrap: wrap; gap: 4px; } + .vp-seg__btn { flex: 1 1 auto; padding: 7px 10px; @@ -235,10 +252,12 @@ transition: background 0.15s, color 0.15s, border-color 0.15s; white-space: nowrap; } + .vp-seg__btn:hover { background: var(--vp-hover); color: var(--vp-text); } + .vp-seg__btn--active { background: #ffffff; color: #08090b; @@ -251,12 +270,14 @@ align-items: center; justify-content: space-between; } + .vp-label { font-family: var(--vp-font); font-size: 13px; font-weight: 500; color: var(--vp-text); } + .vp-readout { font-family: var(--vp-mono); font-size: 12px; @@ -282,7 +303,10 @@ text-align: right; outline: none; } -.vp-input:focus { border-color: var(--vp-accent); } + +.vp-input:focus { + border-color: var(--vp-accent); +} /* ---- Range slider ---- */ .vp-range { @@ -295,6 +319,7 @@ outline: none; cursor: pointer; } + .vp-range::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; @@ -306,7 +331,11 @@ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); transition: transform 0.12s; } -.vp-range::-webkit-slider-thumb:hover { transform: scale(1.15); } + +.vp-range::-webkit-slider-thumb:hover { + transform: scale(1.15); +} + .vp-range::-moz-range-thumb { width: 15px; height: 15px; @@ -330,6 +359,7 @@ transition: background 0.15s, border-color 0.15s; white-space: nowrap; } + .vp-btn:hover { background: var(--vp-hover); border-color: var(--vp-border-strong); @@ -352,6 +382,7 @@ cursor: pointer; transition: background 0.15s, border-color 0.15s; } + .vp-iconbtn:hover { background: var(--vp-panel-strong); border-color: var(--vp-border-strong); @@ -411,6 +442,7 @@ -webkit-backdrop-filter: blur(10px); border: 1px solid var(--vp-border); } + .vp-3dbar__btn { padding: 4px 10px; border-radius: 999px; @@ -423,17 +455,23 @@ cursor: pointer; white-space: nowrap; } -.vp-3dbar__btn:hover { color: var(--vp-text); } + +.vp-3dbar__btn:hover { + color: var(--vp-text); +} + .vp-3dbar__btn.is-active { background: #ffffff; color: #08090b; } + .vp-3dbar__presets { display: flex; gap: 3px; padding-left: 6px; border-left: 1px solid var(--vp-border); } + .vp-3dbar__btn--preset { font-size: 10.5px; padding: 4px 8px; @@ -446,9 +484,17 @@ border-color: rgba(110, 168, 254, 0.55); animation: vp-hd-pulse 1.6s ease-in-out infinite; } + @keyframes vp-hd-pulse { - 0%, 100% { box-shadow: 0 0 0 0 rgba(110, 168, 254, 0.3); } - 50% { box-shadow: 0 0 0 6px rgba(110, 168, 254, 0); } + + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(110, 168, 254, 0.3); + } + + 50% { + box-shadow: 0 0 0 6px rgba(110, 168, 254, 0); + } } /* 3D pane placeholder for local DICOM (no segmentation → no meshes). */ @@ -463,6 +509,7 @@ font-size: 13px; color: var(--vp-text-dim); } + .vp-3d-empty span { font-size: 11.5px; color: var(--vp-text-faint); @@ -475,6 +522,7 @@ align-items: flex-start; line-height: 1.1; } + .vp-tb-id__eyebrow { font-family: var(--vp-mono); font-size: 8.5px; @@ -482,6 +530,7 @@ text-transform: uppercase; color: var(--vp-text-faint); } + .vp-tb-id__val { font-family: var(--vp-font); font-size: 15px; @@ -501,6 +550,7 @@ .vp-tb-seg { flex-wrap: nowrap; } + .vp-topbar .vp-seg__btn { flex: 0 0 auto; padding: 7px 12px; @@ -515,6 +565,7 @@ gap: 6px; cursor: pointer; } + .vp-tb-slider__label { font-family: var(--vp-mono); font-size: 9.5px; @@ -522,9 +573,11 @@ text-transform: uppercase; color: var(--vp-text-faint); } + .vp-tb-slider .vp-range { width: 74px; } + .vp-tb-slider__val { font-family: var(--vp-mono); font-size: 10.5px; @@ -533,6 +586,7 @@ text-align: right; font-variant-numeric: tabular-nums; } + .vp-tb-mini { font-family: var(--vp-font); font-size: 11px; @@ -545,21 +599,25 @@ cursor: pointer; white-space: nowrap; } + .vp-tb-mini:hover { color: var(--vp-text); border-color: var(--vp-border-strong); } + /* Flyout trigger variant (Layout ▾ / Window ▾): label + chevron, same pill shell. */ .vp-tb-mini--flyout { display: inline-flex; align-items: center; gap: 5px; } + .vp-tb-mini--active { background: #ffffff; color: #08090b; border-color: #ffffff; } + .vp-tb-mini--active:hover { color: #08090b; } @@ -570,15 +628,18 @@ justify-content: flex-start; gap: 6px; } + .vp-topbar .vp-tool { width: 36px; height: 36px; } + .vp-topbar .vp-tool__tip { top: calc(100% + 8px); bottom: auto; transform-origin: top center; } + .vp-topbar .vp-tool__caret { right: 3px; bottom: 3px; @@ -591,6 +652,7 @@ gap: 8px; justify-content: center; } + .vp-tool { position: relative; display: flex; @@ -606,15 +668,18 @@ cursor: pointer; transition: background 0.15s, border-color 0.15s; } + .vp-tool:hover { background: var(--vp-hover); border-color: var(--vp-border-strong); } + .vp-tool--active { background: #ffffff; border-color: #ffffff; color: #08090b; } + .vp-tool__tip { position: absolute; left: 50%; @@ -634,6 +699,7 @@ border-radius: 7px; transition: opacity 0.12s, transform 0.12s; } + .vp-tool:hover .vp-tool__tip { opacity: 1; transform: translateX(-50%) scale(1); @@ -657,6 +723,7 @@ border-top: 5px solid currentColor; opacity: 0.65; } + /* Rendered in a portal (position set inline) so the panel's overflow can't clip it. */ .vp-flyout { z-index: 100; @@ -670,6 +737,7 @@ border-radius: 12px; box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45); } + /* Cine: a single toolbar button opens this popup holding the actual Play/Pause button and the FPS slider (side by side, not stacked) — nothing extra sits in the main toolbar itself. Declared after the base .vp-flyout rule so this row layout actually @@ -681,34 +749,41 @@ min-width: 0; padding: 10px 14px; } + .vp-flyout--cine .vp-tb-slider { gap: 10px; cursor: default; } + .vp-flyout--cine .vp-range { width: 120px; } + /* Brighter than the default dim toolbar-slider labels — this popup sits on its own, away from the icon row, so FPS should read clearly rather than blend in. */ .vp-flyout--cine .vp-tb-slider__label { color: var(--vp-accent); font-size: 10px; } + .vp-flyout--cine .vp-tb-slider__val { min-width: 26px; color: var(--vp-text); font-size: 12px; font-weight: 600; } + .vp-flyout--cine .vp-tool--cine-play { background: var(--vp-panel); border-color: var(--vp-border); color: var(--vp-text); } + .vp-flyout--cine .vp-tool--cine-play:hover { background: var(--vp-hover); border-color: var(--vp-border-strong); } + .vp-flyout__item { display: flex; align-items: center; @@ -725,13 +800,16 @@ cursor: pointer; transition: background 0.12s, color 0.12s; } + .vp-flyout__item:hover { background: var(--vp-hover); } + .vp-flyout__item.is-active { background: #ffffff; color: #08090b; } + /* keyboard-shortcut hint at the right edge of a flyout item */ .vp-flyout__kbd { margin-left: auto; @@ -740,7 +818,10 @@ font-size: 10px; color: var(--vp-text-faint); } -.vp-flyout__item.is-active .vp-flyout__kbd { color: inherit; } + +.vp-flyout__item.is-active .vp-flyout__kbd { + color: inherit; +} /* "Config panel" flyouts (Layout ▾, Window ▾) — reuse the sidebar's segmented-control look (.vp-panel__title section labels + .vp-seg/.vp-seg__btn) inside the dropdown; @@ -758,15 +839,18 @@ padding: 12px 14px; gap: 12px; } + .vp-flyout--adjust .vp-tb-slider { width: 100%; justify-content: space-between; gap: 10px; } + .vp-flyout--adjust .vp-range { width: 100%; flex: 1 1 auto; } + .vp-flyout--adjust__actions { display: flex; gap: 6px; @@ -778,13 +862,22 @@ border-color: rgba(244, 63, 94, 0.55); animation: vp-rec-btn-pulse 1.6s ease-in-out infinite; } + .vp-tool--rec:hover { background: rgba(244, 63, 94, 0.3); border-color: rgba(244, 63, 94, 0.7); } + @keyframes vp-rec-btn-pulse { - 0%, 100% { box-shadow: 0 0 0 0 rgba(244, 63, 94, 0.35); } - 50% { box-shadow: 0 0 0 6px rgba(244, 63, 94, 0); } + + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(244, 63, 94, 0.35); + } + + 50% { + box-shadow: 0 0 0 6px rgba(244, 63, 94, 0); + } } /* ---- Viewports: subtle accent ring + corner orientation label ---- */ @@ -795,6 +888,7 @@ box-shadow: inset 0 0 0 1px var(--pane-accent, transparent); transition: box-shadow 0.15s; } + .vp-pane::after { content: attr(data-label); position: absolute; @@ -810,6 +904,7 @@ pointer-events: none; z-index: 5; } + .vp-pane::before { content: ""; position: absolute; @@ -821,17 +916,35 @@ border-radius: 0 2px 2px 0; z-index: 5; } -.vp-pane--axial { --pane-accent: #f43f5e; } -.vp-pane--sagittal { --pane-accent: #eab308; } -.vp-pane--coronal { --pane-accent: #22c55e; } -.vp-pane--render { --pane-accent: #6ea8fe; } + +.vp-pane--axial { + --pane-accent: #f43f5e; +} + +.vp-pane--sagittal { + --pane-accent: #eab308; +} + +.vp-pane--coronal { + --pane-accent: #22c55e; +} + +.vp-pane--render { + --pane-accent: #6ea8fe; +} + /* Solid fill so the fullscreen 3D overlay (viewMode "3d") never shows the MPR grid bleeding through while the mesh/volume view is still initializing. */ -.vp-pane--render, .vp-pane--render .canvas { background: var(--vp-bg, #08090b); } +.vp-pane--render, +.vp-pane--render .canvas { + background: var(--vp-bg, #08090b); +} /* "Hover to identify" active: a distinct cursor hints the pane now reports the organ under the pointer instead of just navigating. */ -.vp-pane--hover-identify { cursor: help; } +.vp-pane--hover-identify { + cursor: help; +} /* ---- Per-pane slice scrollbar + "current/total" caption (bottom right) ---- */ /* A horizontal rotated into a vertical scrollbar pinned to the @@ -862,6 +975,7 @@ outline: none; cursor: pointer; } + /* Thumb is elongated along the (pre-rotation) track axis — after the -90deg rotation that reads as a tall pill, like a native scrollbar thumb, instead of a round slider handle. */ @@ -875,6 +989,7 @@ border: 0; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); } + .vp-slice-scrollbar::-moz-range-thumb { width: 26px; height: 11px; @@ -883,6 +998,7 @@ border: 0; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); } + .vp-slice-caption { position: absolute; right: 10px; @@ -898,6 +1014,7 @@ padding: 3px 7px; pointer-events: none; } + .vp-slice-jump-input { box-sizing: border-box; position: absolute; @@ -915,8 +1032,6 @@ padding: 3px 6px; text-align: center; outline: none; - /* hide the number input's up/down spinner arrows so it doesn't get wider */ - -moz-appearance: textfield; } .vp-slice-jump-input::-webkit-outer-spin-button, @@ -944,6 +1059,7 @@ opacity: 0; transition: opacity 0.25s ease; } + .vp-window-readout--visible { opacity: 1; } @@ -953,7 +1069,8 @@ .vp-loading { position: fixed; inset: 0; - z-index: 45; /* above the empty viewports, below the gear/home chrome (z-50) */ + z-index: 45; + /* above the empty viewports, below the gear/home chrome (z-50) */ display: flex; flex-direction: column; align-items: center; @@ -962,6 +1079,7 @@ background: var(--vp-bg, #08090b); font-family: var(--vp-font); } + .vp-spinner { width: 34px; height: 34px; @@ -970,9 +1088,13 @@ border-top-color: rgba(255, 255, 255, 0.7); animation: vp-spin 0.8s linear infinite; } + @keyframes vp-spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } + .vp-loading__text { font-family: var(--vp-mono); font-size: 12px; @@ -991,11 +1113,13 @@ gap: 8px; font-family: var(--vp-font); } + .vp-progress__head { display: flex; align-items: baseline; justify-content: space-between; } + .vp-progress__label { font-family: var(--vp-mono); font-size: 10px; @@ -1003,12 +1127,14 @@ text-transform: uppercase; color: var(--vp-text-faint, rgba(255, 255, 255, 0.36)); } + .vp-progress__pct { font-family: var(--vp-mono); font-size: 12px; color: var(--vp-text-dim, rgba(255, 255, 255, 0.5)); font-variant-numeric: tabular-nums; } + .vp-progress__track { width: 100%; height: 6px; @@ -1016,19 +1142,29 @@ background: rgba(255, 255, 255, 0.1); overflow: hidden; } + .vp-progress__fill { height: 100%; border-radius: 99px; background: var(--vp-accent, #6ea8fe); transition: width 0.3s ease; } + .vp-progress__fill.is-finalizing { width: 100%; animation: vp-pulse 1s ease-in-out infinite; } + @keyframes vp-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.45; } + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.45; + } } /* ---- Organs / Class Map panel (left side) ---- */ @@ -1045,9 +1181,11 @@ border-right: 1px solid var(--vp-border); color: var(--vp-text); } + .vp-organs--open { display: flex; } + .vp-organs__title { font-family: var(--vp-font); font-size: 18px; @@ -1055,6 +1193,7 @@ letter-spacing: -0.01em; color: var(--vp-text); } + .vp-organs__back { width: 30px; height: 30px; @@ -1064,16 +1203,25 @@ cursor: pointer; transition: background 0.15s; } -.vp-organs__back:hover { background: var(--vp-hover); } + +.vp-organs__back:hover { + background: var(--vp-hover); +} + .vp-organs__list { margin-right: -8px; padding-right: 8px; } -.vp-organs__list::-webkit-scrollbar { width: 8px; } + +.vp-organs__list::-webkit-scrollbar { + width: 8px; +} + .vp-organs__list::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.12); border-radius: 99px; } + /* refined disclosure chevron */ .vp-organs__chevron { width: 18px; @@ -1084,10 +1232,12 @@ cursor: pointer; transition: transform 0.18s ease, color 0.15s, background 0.15s; } + .vp-organs__chevron:hover { color: var(--vp-text); background: var(--vp-hover); } + .vp-organs__chevron.is-open { transform: rotate(90deg); } @@ -1109,11 +1259,13 @@ opacity: 0; transition: opacity 0.12s, color 0.15s, background 0.15s; } + /* reveal only when hovering that organ's row (or when focused via keyboard) */ -.vp-organs__list .flex:hover > .vp-organs__jump, +.vp-organs__list .flex:hover>.vp-organs__jump, .vp-organs__jump:focus-visible { opacity: 1; } + .vp-organs__jump:hover { color: var(--vp-accent); background: var(--vp-hover); @@ -1135,12 +1287,15 @@ cursor: pointer; transition: background 0.15s, border-color 0.15s, transform 0.1s; } + .vp-checkbox:hover { border-color: rgba(255, 255, 255, 0.45); } + .vp-checkbox:active { transform: scale(0.92); } + .vp-checkbox--on { background: var(--vp-accent); border-color: var(--vp-accent); @@ -1160,6 +1315,7 @@ font-family: var(--vp-font); overflow: hidden; } + .vp-stats__head { display: flex; align-items: center; @@ -1167,11 +1323,13 @@ padding: 14px 16px; border-bottom: 1px solid var(--vp-border); } + .vp-stats__actions { display: flex; align-items: center; gap: 6px; } + .vp-stats__export { background: rgba(255, 255, 255, 0.06); border: 1px solid var(--vp-border); @@ -1183,10 +1341,12 @@ padding: 3px 7px; cursor: pointer; } + .vp-stats__export:hover { color: var(--vp-text); background: rgba(255, 255, 255, 0.12); } + .vp-stats__close { background: transparent; border: none; @@ -1196,7 +1356,11 @@ cursor: pointer; padding: 0 4px; } -.vp-stats__close:hover { color: var(--vp-text); } + +.vp-stats__close:hover { + color: var(--vp-text); +} + /* Out-of-range summary banner above the table. */ .vp-stats__summary { margin: 10px 12px 2px; @@ -1208,7 +1372,11 @@ font-size: 11px; line-height: 1.45; } -.vp-stats__summary strong { color: #ffb454; } + +.vp-stats__summary strong { + color: #ffb454; +} + .vp-stats__msg { padding: 24px 16px; font-family: var(--vp-mono); @@ -1216,15 +1384,21 @@ color: var(--vp-text-dim); text-align: center; } + .vp-stats__table { overflow-y: auto; padding: 6px 8px 12px; } -.vp-stats__table::-webkit-scrollbar { width: 8px; } + +.vp-stats__table::-webkit-scrollbar { + width: 8px; +} + .vp-stats__table::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.12); border-radius: 99px; } + .vp-stats__row { display: grid; grid-template-columns: 1.4fr 1fr 0.8fr; @@ -1236,11 +1410,13 @@ font-size: 12.5px; color: var(--vp-text); } + /* 4-column layout when the population percentile column is present. */ .vp-stats__table--pct .vp-stats__row { grid-template-columns: 1.15fr 0.8fr 0.55fr 0.85fr; gap: 6px; } + .vp-stats__row span:nth-child(2), .vp-stats__row span:nth-child(3), .vp-stats__row span:nth-child(4) { @@ -1249,6 +1425,7 @@ color: var(--vp-text-dim); text-align: right; } + /* Percentile cell: number stacked over a tiny distribution sparkline. */ .vp-stats__pct { display: flex; @@ -1256,15 +1433,18 @@ align-items: flex-end; gap: 3px; } + .vp-stats__pctnum { font-family: var(--vp-mono); font-size: 11px; } + /* Muted by default, highlighted when in the extreme tails (p95). */ .vp-stats__pct--flag { color: #ffb454 !important; font-weight: 600; } + /* Percentile sparkline: 0–100 track, p5–p95 band shaded, marker at the case value. */ .vp-spark { position: relative; @@ -1274,6 +1454,7 @@ border-radius: 99px; background: rgba(255, 255, 255, 0.08); } + .vp-spark__band { position: absolute; left: 5%; @@ -1283,6 +1464,7 @@ border-radius: 99px; background: rgba(255, 255, 255, 0.14); } + .vp-spark__marker { position: absolute; top: 50%; @@ -1292,17 +1474,23 @@ background: #cbd5e1; transform: translate(-50%, -50%); } -.vp-spark__marker--flag { background: #ffb454; } + +.vp-spark__marker--flag { + background: #ffb454; +} + /* Explicit index-based stripe (not :nth-child) since expanded detail rows are interleaved siblings and would otherwise shift which rows count as "odd". */ .vp-stats__row--odd { background: rgba(255, 255, 255, 0.03); } + .vp-stats__row--head { position: sticky; top: 0; background: rgba(14, 15, 18, 0.96); } + .vp-stats__row--head span { font-family: var(--vp-mono) !important; font-size: 9px !important; @@ -1318,9 +1506,11 @@ .vp-stats__row--expandable { cursor: pointer; } + .vp-stats__row--expandable:hover { background: var(--vp-hover); } + .vp-stats__chevron { display: inline-block; width: 10px; @@ -1329,15 +1519,18 @@ color: var(--vp-text-faint); transition: transform 0.15s ease; } + .vp-stats__chevron--open { transform: rotate(90deg); } + .vp-stats__truncated-flag { margin-left: 5px; font-size: 10px; color: #ffb454; cursor: help; } + .vp-stats__detail { display: grid; grid-template-columns: repeat(2, 1fr); @@ -1348,6 +1541,7 @@ background: rgba(255, 255, 255, 0.03); border: 1px solid var(--vp-border); } + .vp-stats__detail-item { display: flex; align-items: baseline; @@ -1355,12 +1549,14 @@ gap: 8px; font-family: var(--vp-mono); } + .vp-stats__detail-item span:first-child { font-size: 9px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--vp-text-faint); } + .vp-stats__detail-item span:last-child { font-size: 11px; color: var(--vp-text-dim); @@ -1373,11 +1569,16 @@ display: flex; flex-direction: column; } -.vp-meta__list::-webkit-scrollbar { width: 8px; } + +.vp-meta__list::-webkit-scrollbar { + width: 8px; +} + .vp-meta__list::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.12); border-radius: 99px; } + .vp-meta__row { display: flex; align-items: baseline; @@ -1386,11 +1587,13 @@ padding: 9px 10px; border-radius: 8px; } + /* Same alternating-row treatment as the organ statistics table, for a consistent feel between the two docked panels. */ .vp-meta__row--odd { background: rgba(255, 255, 255, 0.03); } + .vp-meta__label { font-family: var(--vp-mono); font-size: 10px; @@ -1399,6 +1602,7 @@ color: var(--vp-text-faint); white-space: nowrap; } + .vp-meta__value { font-family: var(--vp-font); font-size: 13px; @@ -1428,6 +1632,7 @@ pointer-events: none; z-index: 9999; } + /* Small color dot restating the same match, in case the border reads too subtly. */ .vp-organ-tip__swatch { width: 9px; @@ -1459,7 +1664,7 @@ .visualization-container.single-view-2d .coronal { border-radius: 14px; overflow: hidden; - box-shadow: 0 12px 50px rgba(0,0,0,0.55); + box-shadow: 0 12px 50px rgba(0, 0, 0, 0.55); } .vp-pane--edit-cursor { @@ -1511,4 +1716,16 @@ .vp-edit__method-card.is-active strong, .vp-edit__method-card.is-active span { color: #08090b; +} + +.vp-brush-preview { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border: 2px dashed rgba(255, 255, 255, 0.85); + border-radius: 50%; + pointer-events: none; + z-index: 35; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4); } \ No newline at end of file diff --git a/PanTS-Demo/src/routes/VisualizationPage.tsx b/PanTS-Demo/src/routes/VisualizationPage.tsx index 193a80e..f0b9556 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.tsx +++ b/PanTS-Demo/src/routes/VisualizationPage.tsx @@ -9,7 +9,8 @@ import { IconArrowForwardUp, IconArrowsCross, IconArrowUpRight, - IconBrush, + IconPencil, + IconStack2, IconCamera, IconChartBar, IconCheck, @@ -31,24 +32,42 @@ import { IconSettings, IconShare, IconSquareDashed, - IconStack2, IconTrash, IconZoomIn } from "@tabler/icons-react"; import React, { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import { createPortal } from "react-dom"; +import { buildMaskFilter } from "../helpers/CornerstoneNifti2"; import { useLocation, useParams } from "react-router-dom"; import AISidebar from "../components/AIAssistant/AISidebar"; import { buildViewerActions } from "../components/AIAssistant/assistantActions"; -import MaskEditPanel, { type MaskEditMode } from "../components/MaskEditPanel/MaskEditPanel"; import MeasurementPanel from "../components/MeasurementPanel/MeasurementPanel"; -import { SegmentationMeshViewer } from "../components/MeshViewer"; +import { SegmentationMeshViewer } from "../components/viewer/MeshViewer"; import OrganCheckbox from "../components/OrganCheckbox"; import PercentileBar from "../components/PercentileBar"; import SessionHUD from "../components/ReadingSession/SessionHUD"; import SessionSummary from "../components/ReadingSession/SessionSummary"; import ReportScreen from "../components/ReportScreen/ReportScreen"; import SliceJumpInput from "../components/SliceJumpInput"; +import SegmentsPopup from "../components/segmentation/SegmentsPopup"; +import MarginPanel from "../components/segmentation/MarginPanel"; +import IslandsPanel from "../components/segmentation/IslandsPanel"; +import LogicalOperatorsPanel from "../components/segmentation/LogicalOperatorsPanel"; +import { setBrushMaskingScope } from "../helpers/CornerstoneNifti2"; + +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 HollowFlyout from "../components/segmentation/HollowFlyout"; +import LevelTracingFlyout from "../components/segmentation/LevelTracingFlyout"; +import { useScissorsTool } from "../helpers/viewer/useScissorsTool"; +import { + applyMargin, getActualMarginMm, + applyIslandsOperation, applyLogicalOperator, applySmoothing, + deleteSegmentEverywhere, getSegmentAtVoxel, getActiveEditSegment, type LogicalOperation, + type LevelTraceOperation +} from "../helpers/CornerstoneNifti2"; import { API_BASE, APP_CONSTANTS, @@ -108,15 +127,28 @@ import { VOLUME_3D_PRESETS, VOLUME_3D_PRESETS_MR, zoomToFit, + registerNewSegmentColor, + isSegmentPresent, type CinePane, type PrimaryMouseToolName, - type SliceInfo + type SliceInfo, + setActiveEditSegment, + beginBrushMaskGuard, + endBrushMaskGuard, } from "../helpers/CornerstoneNifti2"; import { useSmartFill } from "../helpers/viewer/useSmartFill"; +import { hasSegmentationVolume } from "../helpers/CornerstoneNifti2"; +import { useLevelTracing } from "../helpers/viewer/useLevelTracing"; +import AnnotationToolbar, { + type PrimaryEditTool, + type ScissorsOptions, +} from "../components/viewer/AnnotationToolbar"; +import { setMaskBrushSize } from "../helpers/CornerstoneNifti2"; import { useMorphPicker } from "../helpers/viewer/useMorphPicker"; import { useLassoTool } from "../helpers/viewer/useLassoTool"; import { useFocusedPane } from "../helpers/viewer/useFocusedPane"; import { useKeyboardShortcuts } from "../helpers/viewer/useKeyboardShortcuts"; +import { type MaskingArea } from "../components/segmentation/MaskingSelect"; import { getLocalDicomFiles, loadLocalDicomSeries } from "../helpers/dicomLocal"; import { downloadUrlAsFile } from "../helpers/downloadFile"; import { loadLocalNiftiAsRawBlobUrl } from "../helpers/localNifti"; @@ -142,7 +174,7 @@ import { filenameToName } from "../helpers/utils.name"; import { decodeViewerState, encodeViewerState } from "../helpers/viewerShareState"; import { type CheckBoxData } from "../types"; import "./VisualizationPage.css"; -import LiveWireOverlay from "../components/LiveWireOverlay"; +import LiveWireOverlay from "../components/viewer/LiveWireOverlay"; type ViewMode = "mpr" | "axial" | "sagittal" | "coronal" | "3d"; @@ -189,6 +221,8 @@ const VIEW_MODE_SHORT_LABEL: Record = { "3d": "3D", }; +export type MaskEditMode = "brush" | "eraser" | "smartfill" | "lasso" | null; + // Case metadata fields pulled from PanTS/metadata.xlsx (via /api/search), in display // order — a curated subset of row_to_item's fields; spacing_sum/shape_sum/complete are // internal sort helpers, not meaningful to show a reader. @@ -277,6 +311,10 @@ const CT_PRESETS = [ { name: "Angio", width: 600, center: 150 }, // contrast-enhanced vessels (CTA) ] as const; +// Rough px/mm scale — Cornerstone panes don't expose a fixed px-per-mm ratio without +// reading viewport spacing per pane, so this is a visual approximation, not a +// pixel-exact brush footprint. Good enough for "see roughly how big this is." +const PX_PER_MM_APPROX = 2.2; // Measurement tools (+ the magnify loupe, which shares the same primary-mouse-tool slot) // shown inside the collapsible "Measure" flyout, so the toolbar isn't crowded with one // button per tool (matches the split-button pattern OHIF uses). `key` is the keyboard @@ -357,6 +395,7 @@ function VisualizationPage() { const isLocal = isDicom || isLocalNifti; const [dicomError, setDicomError] = useState(null); + // 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 // for big full-body scans than streaming the .nii.gz from HuggingFace). We probe @@ -371,6 +410,23 @@ function VisualizationPage() { const isHd = typeof window !== "undefined" && new URLSearchParams(window.location.search).get("hd") === "1"; + + const [showAnnotationToolbar, setShowAnnotationToolbar] = useState(false); + const [isEditRendering, setIsEditRendering] = useState(false); + useEffect(() => { + if (!showAnnotationToolbar) setEditMode((m) => (m === "brush" || m === "eraser" || m === "lasso" ? null : m)); + }, [showAnnotationToolbar]); + + // Refs into UI that lives outside AnnotationToolbar (the segments popup, + // the slice-jump overlay) so its Overview walkthrough can spotlight them + // anyway. The popup itself attaches these to its outer panel / drag + // header; SliceJumpInput is wrapped below since it doesn't take a ref + // prop of its own. See AnnotationToolbar's own doc-comment for details — + // the first-run "seen it once" logic now lives there too. + const annotationPopupRef = useRef(null); + const annotationPopupDragRef = useRef(null); + const annotationPopupMinRef = useRef(null); + const sliceJumpWrapRef = useRef(null); useEffect(() => { let cancelled = false; @@ -420,6 +476,7 @@ function VisualizationPage() { const qs = params.toString(); window.location.href = `${window.location.pathname}${qs ? `?${qs}` : ""}`; }; + const axial_ref = useRef(null); const sagittal_ref = useRef(null); @@ -442,6 +499,7 @@ function VisualizationPage() { const [opacityValue, setOpacityValue] = useState( APP_CONSTANTS.DEFAULT_SEGMENTATION_OPACITY * 100 ); + const [outlineOpacityValue, setOutlineOpacityValue] = useState(0); // Current/total slice per MPR pane, for the "245/519" caption + drag scrollbar. // Populated by subscribeToSliceChanges once the volume is ready; null until then. @@ -457,6 +515,32 @@ function VisualizationPage() { // should agree on first load instead of showing a level the preset never set. const [windowWidth, setWindowWidth] = useState(400); const [windowCenter, setWindowCenter] = useState(40); + const [maskingArea, setMaskingArea] = useState("everywhere"); + // Resolves the global masking selection into the concrete inputs the existing + // helper functions expect: which segment ids an "all/visible segments" operation + // should run over, and whether the current pick means "restrict to inside" a set + // of segments vs "restrict to outside" them. + const resolveMaskingTargets = (): { applyToVisible: boolean; ids: number[]; inside: boolean; invalid?: boolean } => { + const allIds = checkBoxData.map((o) => o.id); + switch (maskingArea) { + case "insideAllSegments": + case "outsideAllSegments": + return { applyToVisible: true, ids: allIds, inside: maskingArea.startsWith("inside") }; + case "insideVisibleSegments": + case "outsideVisibleSegments": + return { applyToVisible: true, ids: visibleSegmentIndices, inside: maskingArea.startsWith("inside") }; + case "insideSegment": + case "outsideSegment": + if (activeSegment == null) { + return { applyToVisible: false, ids: [], inside: true, invalid: true }; + } + return { applyToVisible: false, ids: [activeSegment], inside: maskingArea.startsWith("inside") }; + case "everywhere": + default: + return { applyToVisible: false, ids: [], inside: true }; + } + }; + // Brief W/L readout: shown only while the user is actively dragging the brightness/ // contrast sliders or picking a preset — not on the initial/deep-link window apply, and // not left on screen indefinitely. windowReadoutTimerRef holds the fade-out timeout so @@ -531,24 +615,440 @@ function VisualizationPage() { const [cinePlaying, setCinePlaying] = useState(false); const [cineFps, setCineFps] = useState(12); // Mask editing: right-side panel + which brush (paint/erase) owns the mouse. - const [showEditPanel, setShowEditPanel] = useState(false); const [editMode, setEditMode] = useState(null); + const [brushPreviewActive, setBrushPreviewActive] = useState(false); + const [activeToolbarTool, setActiveToolbarTool] = useState(null); + + + // Only paint/erase/scissors/growFromSeeds need the pane to behave differently + // (brush cursor, lasso clicks, smartfill scribbles). Everything else (margin, + // islands, logical ops, smoothing, slice tools, level tracing) just needs its + // flyout open — no separate pane interaction mode. + const TOOLBAR_TO_EDIT_MODE: Partial, MaskEditMode>> = { + paint: "brush", + erase: "eraser", + scissors: "lasso", + growFromSeeds: "smartfill", + }; + + + + + const handleToolbarToolChange = (tool: PrimaryEditTool) => { + if (tool && !hasActiveTarget) return; // no target picked — refuse to activate anything + setActiveToolbarTool(tool); + setEditMode(tool ? TOOLBAR_TO_EDIT_MODE[tool] ?? null : null); + }; + + + const handleDiameterChange = (mm: number) => { + setDiameterMm(mm); + setMaskBrushSize(mm); + }; + const [diameterMm, setDiameterMm] = useState(10); + const [scissorsOptions, setScissorsOptions] = useState({ + operation: "eraseInside", + }); + + const [activeSegment, setActiveSegmentState] = useState(null); + const [levelTraceTolerance, setLevelTraceTolerance] = useState(50); + const [levelTraceOperation, setLevelTraceOperation] = useState("fillInside"); + const [segmentColorsHex, setSegmentColorsHex] = useState>({}); + const [segmentVisibility, setSegmentVisibility] = useState>({}); + // Existing-organ dropdown in SegmentsPopup — lets the brush target one of the + // 32 static catalog organs without listing them all as rows. + const [activeCatalogOrganId, setActiveCatalogOrganId] = useState(null); + const hasSegments = checkBoxData.length > 0; + // Static catalog organs (the 32-organ PanTS set) live entirely in the local nifti's + // ground-truth labelmap. They're included in checkBoxData at load, so masking scope + // (inside/outside this/all/visible segments) resolves for them the same way it does + // for runtime-created custom classes — no special-casing needed. + + + // keep MaskBrush target in sync with the popup's active segment + useEffect(() => { + if (activeSegment != null) setActiveEditSegment(activeSegment); + }, [activeSegment]); + + // map paint/erase/scissors toolbar selection onto the existing Cornerstone tool wiring + useEffect(() => { + if (activeToolbarTool === "paint" || activeToolbarTool === "erase") { + setActiveMeasurementTool(null); + setActiveMaskEditTool(activeToolbarTool === "paint" ? EDIT_BRUSH : EDIT_ERASER); + } else if (activeToolbarTool === "growFromSeeds") { + setActiveMeasurementTool(null); + setActiveMaskEditTool(null); + releasePrimaryMouseTools(); + } else if (!activeMeasureTool) { + setActiveMaskEditTool(null); + toggleCrosshairTool(crosshairToolActive); + } + }, [activeToolbarTool]); + + const setActiveSegment = (id: number) => setActiveSegmentState(id); + + // 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) => { + setActiveCatalogOrganId(id); + if (id != null) setActiveSegmentState(id); + }; + const handleRenameSegment = (id: number, name: string): boolean => { + const dup = checkBoxData.some((s) => s.id !== id && s.label.toLowerCase() === name.toLowerCase()); + if (dup) return false; + setCheckBoxData((prev) => prev.map((s) => (s.id === id ? { ...s, label: name } : s))); + return true; + }; + + const handleSegmentColorChange = (id: number, hex: string) => { + setSegmentColorsHex((prev) => ({ ...prev, [id]: hex })); + registerNewSegmentColor(id, hexToColor(hex)); + }; + + const handleToggleSegmentVisibility = (id: number) => { + setSegmentVisibility((prev) => { + const next = { ...prev, [id]: prev[id] === false ? true : false }; + setCheckState((cs) => { + const arr = [...cs]; + arr[id] = next[id] !== false; + return arr; + }); + return next; + }); + }; + const hasAnySegments = checkBoxData.length > 0; + useEffect(() => { + // Catalog organs are already part of checkBoxData at load, so every + // scope option (insideSegment, insideAllSegments, insideVisibleSegments, + // and their outside equivalents) resolves for them exactly the way it + // does for custom classes — no special-casing needed here anymore. + const needsAnySegments = + maskingArea === "insideAllSegments" || + maskingArea === "outsideAllSegments" || + maskingArea === "insideVisibleSegments" || + maskingArea === "outsideVisibleSegments"; + const needsActiveSegment = maskingArea === "insideSegment" || maskingArea === "outsideSegment"; + if ((needsAnySegments && !hasAnySegments) || (needsActiveSegment && activeSegment == null)) { + setMaskingArea("everywhere"); + } + }, [hasAnySegments, activeSegment, maskingArea]); + + const handleDeleteSegment = (id: number) => { + // Clear every voxel belonging to this segment in the actual segmentation + // volume first — otherwise the label data survives (still paintable, still + // present in the 3D render, masking scopes, islands, etc) even though the + // row disappears from the popup. + const r = deleteSegmentEverywhere(id); + if (r) sessionRef.current?.log("edit", `Deleted segment (${r.changedVoxels.toLocaleString()} vox)`, 2000); + setCheckBoxData((prev) => prev.filter((s) => s.id !== id)); + setCheckState((prev) => { const n = [...prev]; n[id] = false; return n; }); + setSegmentColorsHex((prev) => { const { [id]: _drop, ...rest } = prev; return rest; }); + setSegmentVisibility((prev) => { const { [id]: _drop, ...rest } = prev; return rest; }); + if (activeCatalogOrganId === id) setActiveCatalogOrganId(null); + if (activeSegment === id) setActiveSegmentState(checkBoxData.find((s) => s.id !== id)?.id ?? null); + }; + + const renderAnnotationFlyout = (tool: Exclude) => { + switch (tool) { + case "margin": { + const marginInfo = activeSegment ? getActualMarginMm(3) : null; + return ( + { + const { applyToVisible, ids } = resolveMaskingTargets(); + const r = applyMargin(op, mm, applyToVisible, ids, maskFilter); + if (r) sessionRef.current?.log("edit", `Margin ${op} ${mm}mm (${r.changedVoxels.toLocaleString()} vox)`, 2000); + }} + actualMm={marginInfo?.mm ?? null} + actualVoxels={marginInfo?.voxels ?? null} + /> + ); + } + case "islands": + return ( + { + const r = applyIslandsOperation(op, min, islandSeedVoxel ?? undefined, maskFilter); + if (r) { + sessionRef.current?.log("edit", `Islands: ${op} (${r.changedVoxels.toLocaleString()} vox)`, 2000); + // "Split islands to segments" creates brand-new segment indices on + // the backend (with their own color already registered) — fold + // them into the same UI state a manually-created class would use, + // so they show up in the segments popup as real, functioning + // custom classes rather than invisible/unlabeled data. + if (r.createdSegments?.length) { + setCheckBoxData((prev) => [ + ...prev, + ...r.createdSegments!.map((s) => ({ id: s.id, label: s.label })), + ]); + setCheckState((prev) => { + const next = [...prev]; + for (const s of r.createdSegments!) next[s.id] = true; + return next; + }); + setLabelColorMap((prev) => { + const next = { ...prev }; + for (const s of r.createdSegments!) next[s.id] = s.color; + return next; + }); + setSegmentColorsHex((prev) => { + const next = { ...prev }; + for (const s of r.createdSegments!) next[s.id] = colorToHex(s.color); + return next; + }); + } + } + }} + pickingSelectedIsland={morphPicker.picking} + onPickSelectedIsland={morphPicker.startPicking} + onResetPick={resetIslandPick} + hasSelectedIsland={islandSeedVoxel != null && !islandPickInvalid} + pickedInvalid={islandPickInvalid} + targetKey={activeCatalogOrganId ?? activeSegment} + /> + ); + case "logicalOperators": + return ( + { + const target = activeSegment ?? checkBoxData[0]?.id ?? 1; + const r = applyLogicalOperator(op, target, src, bypass, maskFilter); + if (r) sessionRef.current?.log("edit", `Logical op ${op} (${r.changedVoxels.toLocaleString()} vox)`, 2000); + }} + /> + ); + case "growFromSeeds": + return ( + + ); + case "fillBetweenSlices": + return ( + sessionRef.current?.log("edit", d, 2000)} + /> + ); + case "copyAcrossSlices": + return ( + sessionRef.current?.log("edit", d, 2000)} + /> + ); + case "hollow": + return ( + sessionRef.current?.log("edit", d, 2000)} + /> + ); + case "smoothing": + return ( + { + const { applyToVisible, ids } = resolveMaskingTargets(); + const r = applySmoothing(kernelMm, applyToVisible, ids, maskFilter); + if (r) sessionRef.current?.log("edit", `Smoothing ${method} (${r.changedVoxels.toLocaleString()} vox)`, 2000); + }} + /> + ); + case "levelTracing": + return ( + + ); + } + }; + + + + + const morphPicker = useMorphPicker({ + panelOpen: showAnnotationToolbar, + onLog: (detail) => sessionRef.current?.log("edit", detail, 1500), + }); + + // The islands "keep/remove selected" picker shares morphPicker.seedVoxel with + // the other morphology tools, which has no concept of "this pick is stale." + // We track a "cleared" marker instead of needing the hook itself to forget the + // voxel: once cleared, the same seedVoxel value keeps reading as "nothing + // picked" until the user actually clicks a new voxel (which changes the + // value and naturally clears the marker again). + // NOTE: this has to be React state, not a ref — a ref mutation doesn't + // trigger a re-render, so the "picked" UI (and the Apply button's reset) + // wouldn't actually update on screen until some unrelated state change + // happened to force a re-render. + const [clearedIslandSeed, setClearedIslandSeed] = useState<[number, number, number] | null>(null); + const resetIslandPick = () => { + setClearedIslandSeed(morphPicker.seedVoxel ?? null); + // Switching operation (or target segment), or pressing Apply, should + // also cancel an in-progress pick — otherwise morphPicker.picking stays + // true and the viewport is left silently armed/waiting for a click for + // an operation that may no longer need one. + // If useMorphPicker doesn't expose a cancel method yet, add one there — + // this call is a no-op until it does. + (morphPicker as unknown as { stopPicking?: () => void; cancelPicking?: () => void }).stopPicking?.(); + (morphPicker as unknown as { stopPicking?: () => void; cancelPicking?: () => void }).cancelPicking?.(); + }; + const islandSeedVoxel = + morphPicker.seedVoxel && morphPicker.seedVoxel !== clearedIslandSeed + ? morphPicker.seedVoxel + : null; + // A pick only counts if the clicked voxel actually belongs to the segment + // the islands operation is about to run on — islands are just connected + // components *within* the active segment, so a click anywhere else can't + // be applied. + const islandPickInvalid = + islandSeedVoxel != null && getSegmentAtVoxel(islandSeedVoxel) !== getActiveEditSegment(); + + + const visibleSegmentIndices = useMemo( + () => checkBoxData.filter((o) => checkState[o.id]).map((o) => o.id), + [checkBoxData, checkState] + ); + + + + // Single source of truth for "what does the current masking selection + // actually resolve to" — shared by the live maskFilter (used by every + // non-brush tool) and by the brush's pointerup guard below, so the two + // can never disagree about which area/ids are in effect. + const resolvedMasking = useMemo(() => { + const { ids, invalid } = resolveMaskingTargets(); + const effectiveArea = invalid || (maskingArea !== "everywhere" && ids.length === 0) + ? "everywhere" + : maskingArea; + return { effectiveArea, ids }; + }, [maskingArea, checkBoxData, visibleSegmentIndices, activeSegment, renderingEngine, viewportIds, volumeId]); + + const maskFilter = useMemo( + () => buildMaskFilter(resolvedMasking.effectiveArea, resolvedMasking.ids), + [resolvedMasking] + ); + + + // Resolves what the BRUSH is allowed to overwrite, given the same maskingArea/ids + // every other tool's maskFilter already encodes. Brush locking is per-segment (not + // per-voxel), so "outside X" unlocks every segment except X; "inside X" unlocks + useEffect(() => { + const isLiveCommitTool = + editMode === "brush" || + editMode === "eraser" || + (editMode === "lasso" && activeToolbarTool === "scissors") || + activeToolbarTool === "levelTracing"; + if (!isLiveCommitTool) return; + + const isOnPane = (e: Event) => (e.target as HTMLElement)?.closest?.(".vp-pane"); + + const onDown = (e: Event) => { + if (!isOnPane(e)) return; + if (editMode === "brush" || editMode === "eraser") beginBrushMaskGuard(); + setIsEditRendering(true); + }; + const onUp = () => { + if (editMode === "brush" || editMode === "eraser") { + endBrushMaskGuard(resolvedMasking.effectiveArea, resolvedMasking.ids); + } + requestAnimationFrame(() => requestAnimationFrame(() => setIsEditRendering(false))); + }; + + window.addEventListener("pointerdown", onDown, true); + window.addEventListener("pointerup", onUp, true); + return () => { + window.removeEventListener("pointerdown", onDown, true); + window.removeEventListener("pointerup", onUp, true); + }; + }, [editMode, activeToolbarTool, resolvedMasking]); + // Resolves what the BRUSH is allowed to overwrite, given the same maskingArea/ids + // every other tool's maskFilter already encodes. Brush locking is per-segment (not + // per-voxel), so "outside X" unlocks every segment except X; "inside X" unlocks + // just X; "everywhere" unlocks all. The eraser additionally needs background (0) + // unlocked even under "inside" scopes, since ERASE_INSIDE_CIRCLE always writes 0 + // as its destination — if 0 stays locked the eraser silently does nothing. + useEffect(() => { + if (maskingArea === "everywhere") { + setBrushMaskingScope("all"); + return; + } + const { ids } = resolveMaskingTargets(); + const inside = maskingArea.startsWith("inside"); + const erasing = editMode === "eraser"; + + if (inside) { + setBrushMaskingScope(erasing ? [...ids, 0] : ids); + } else { + const allIds = checkBoxData.map((o) => o.id); + const complement = allIds.filter((id) => !ids.includes(id)); + setBrushMaskingScope([...complement, 0]); // "outside" already includes background + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [maskingArea, checkBoxData, visibleSegmentIndices, activeSegment, editMode, renderingEngine, viewportIds, volumeId]); const smartFill = useSmartFill({ enabled: editMode === "smartfill", sliceInfoRef, + maskFilter, onLog: (detail) => sessionRef.current?.log("edit", detail, 2000), }); - const morphPicker = useMorphPicker({ - panelOpen: showEditPanel, - onLog: (detail) => sessionRef.current?.log("edit", detail, 1500), - }); const lasso = useLassoTool({ - enabled: editMode === "lasso", + enabled: editMode === "lasso" && activeToolbarTool !== "scissors", + maskFilter, onLog: (detail) => sessionRef.current?.log("edit", detail, 2000), }); + const { applyToVisible, ids } = resolveMaskingTargets(); + const scissors = useScissorsTool({ + enabled: editMode === "lasso" && activeToolbarTool === "scissors", + operation: scissorsOptions.operation, + applyToVisibleSegments: applyToVisible, + visibleSegmentIndices: ids, + activeSegmentIndex: activeSegment, + maskFilter, // <-- add this + magnetEnabled: scissorsOptions.magnetEnabled, + onLog: (detail) => sessionRef.current?.log("edit", detail, 2000), + }); + const levelTracing = useLevelTracing({ + enabled: activeToolbarTool === "levelTracing", + toleranceHu: levelTraceTolerance, + operation: levelTraceOperation, + activeSegmentIndex: activeSegment, + maskFilter, + onLog: (detail) => sessionRef.current?.log("edit", detail, 1500), + }); + + + // 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; // 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 }); @@ -615,7 +1115,14 @@ function VisualizationPage() { text: "", color: "transparent", }); + const hasActiveTarget = activeSegment != null; + useEffect(() => { + if (!hasActiveTarget && activeToolbarTool) { + setActiveToolbarTool(null); + setEditMode(null); + } + }, [hasActiveTarget]); // const location = useLocation(); // Load and render visualization on first render @@ -639,6 +1146,8 @@ function VisualizationPage() { } }, [editMode, activeMeasureTool, crosshairToolActive]); + + useEffect(() => { if (editMode !== "lasso") lasso.reset(); }, [editMode]); @@ -855,14 +1364,13 @@ function VisualizationPage() { setCrosshairToolActive, setShowStats, setShowMetadata, - setShowEditPanel, + setShowAnnotationToolbar, // was setShowEditPanel setShowMeasurePanel, getFocusedPane: focusedPane.getFocusedPane, sliceInfoRef, editMode, setZoomLevel, }); - // 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. const handleCineFpsChange = (fps: number) => { @@ -1002,34 +1510,30 @@ function VisualizationPage() { // resolve after the second and clobber state with the wrong case's result. let cancelled = false; const setup = async () => { - // const state = location.state; - // if (!state) { - // alert('No Nifti Files Uploaded!'); - // navigate('/'); - // return; - // } - - const checkBoxData = segmentation_categories.map((filename, i) => ({ - label: filenameToName(filename), - id: i + 1, - })); - setCheckBoxData(checkBoxData); - const initialState = [true]; // background 永远可见 - checkBoxData.forEach((item) => { - initialState[item.id] = true; - }); - setCheckState(initialState); - const max = Math.max( - ...Object.keys(labelColorMap).map((key) => parseInt(key)) - ); - - const cmap: ColorLUT = Array.from({ length: max + 1 }, () => [ - 0, 0, 0, 0, - ]); + // Local DICOM/NIfTI have no server-side segmentation — don't seed the static + // 32-organ catalog for them; checkBoxData should only ever contain segments + // the user actually creates (via createNewAnnotationClass), so hasAnySegments + // reflects reality instead of always being true. + if (!isLocal) { + const checkBoxData = segmentation_categories.map((filename, i) => ({ + label: filenameToName(filename), + id: i + 1, + })); + setCheckBoxData(checkBoxData); + const initialState = [true]; + checkBoxData.forEach((item) => { initialState[item.id] = true; }); + setCheckState(initialState); + } else { + setCheckBoxData([]); + setCheckState([true]); + } + + const max = Math.max(...Object.keys(labelColorMap).map((key) => parseInt(key))); + const cmap: ColorLUT = Array.from({ length: max + 1 }, () => [0, 0, 0, 0]); for (const key in labelColorMap) { cmap[parseInt(key)] = labelColorMap[parseInt(key)]; } - + // Local DICOM: build imageIds from the picked files instead of NIfTI URLs. // No segmentation layer exists for these scans. if (isDicom) { @@ -1540,7 +2044,7 @@ function VisualizationPage() { onChange={(e) => setPaneSliceIndex(pane, Number(e.target.value))} aria-label={`${pane} slice`} /> - + )}
@@ -1662,8 +2166,9 @@ function VisualizationPage() { // The right-side slot is shared by stats / metadata / measurements / mask editing. setShowMetadata(false); setShowMeasurePanel(false); - setShowEditPanel(false); + setShowAnnotationToolbar(false); setEditMode(null); + setActiveToolbarTool(null); setShowStats((v) => !v); loadOrganStats(); loadPercentileContext(); @@ -1672,8 +2177,9 @@ function VisualizationPage() { const handleToggleMetadata = () => { setShowStats(false); setShowMeasurePanel(false); - setShowEditPanel(false); + setShowAnnotationToolbar(false); setEditMode(null); + setActiveToolbarTool(null); setShowMetadata((v) => !v); loadPercentileContext(); }; @@ -1687,9 +2193,9 @@ function VisualizationPage() { setShowStats(false); setShowMetadata(false); setShowMeasurePanel(false); - setShowEditPanel(false); + setShowAnnotationToolbar(false); setEditMode(null); - + setActiveToolbarTool(null); void loadOrganStats(); void loadPercentileContext(); } @@ -1719,15 +2225,42 @@ demographics?.age ?? null [organStats, organNorms, demographics] ); const flaggedOrgans = useMemo(() => summarizeOutOfRange(statRows), [statRows]); - -// Classes created at runtime via "New class" — anything in checkBoxData whose id -// falls outside the static 32-organ catalog. Fed to OrganCheckbox as a separate -// section, since the fixed OrganSystems map has no slot for them. const customOrgans = useMemo( () => checkBoxData.filter((o) => o.id > segmentation_categories.length), [checkBoxData] ); + +const organCatalog = useMemo(() => { + if (!hasSegmentationVolume()) { + // Segmentation not cached yet — show the full static list rather than + // spamming isSegmentPresent before there's anything to check. + return segmentation_categories.map((filename, i) => ({ id: i + 1, label: filenameToName(filename) })); + } + + const withPresence = segmentation_categories + .map((filename, i) => ({ id: i + 1, label: filenameToName(filename) })) + .filter((o) => isSegmentPresent(o.id)); + + if (withPresence.length === 0) { + return segmentation_categories.map((filename, i) => ({ id: i + 1, label: filenameToName(filename) })); + } + return withPresence; +}, [renderingEngine, viewportIds, volumeId, checkBoxData, loading]); + +// Logical Operators' "With segment" dropdown should only offer organs that +// actually exist in this scan (same presence check organCatalog already +// does), plus any custom classes the user created themselves (those are +// real by definition — no presence check needed). checkBoxData on its own +// is the raw 32-organ catalog seeded at load, not filtered by presence. +const logicalOpSegments = useMemo(() => { + const presentIds = new Set(organCatalog.map((o) => o.id)); + return checkBoxData.filter((s) => s.id > segmentation_categories.length || presentIds.has(s.id)); +}, [checkBoxData, organCatalog]); + +const [logicalOp, setLogicalOp] = useState("copy"); +const [logicalOpSourceId, setLogicalOpSourceId] = useState(null); +const [logicalOpBypassMasking, setLogicalOpBypassMasking] = useState(true); const aiAvailableOrgans = useMemo(() => { const measuredOrgans = (organStats ?? []) .filter((metric) => @@ -1761,11 +2294,23 @@ const aiAvailableOrgans = useMemo(() => { return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 255]; // Isolate red, blue, green, all values }; + // [r,g,b,a] Color back to "#rrggbb" hex — needed when a segment gets a + // color assigned on the backend (e.g. islands split creating new classes) + // and the UI's color state, which is keyed by hex, needs to pick it up. + const colorToHex = (color: Color): string => { + const [r, g, b] = color; + return `#${[r, g, b].map((c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, "0")).join("")}`; + }; + const handleCreateClass = (name: string, colorHex: string): CheckBoxData | null => { - const result = createNewAnnotationClass(name, hexToColor(colorHex)); + const trimmed = name.trim(); + const dup = checkBoxData.some((s) => s.label.toLowerCase() === trimmed.toLowerCase()); + if (dup) return null; // name collides with an existing organ (catalog or custom) + + const result = createNewAnnotationClass(trimmed, hexToColor(colorHex)); if (!result) return null; - const newOrgan: CheckBoxData = { id: result.segmentIndex, label: name }; + const newOrgan: CheckBoxData = { id: result.segmentIndex, label: trimmed }; setCheckBoxData((prev) => [...prev, newOrgan]); setCheckState((prev) => { const next = [...prev]; @@ -1773,11 +2318,11 @@ const aiAvailableOrgans = useMemo(() => { return next; }); setLabelColorMap((prev) => ({ ...prev, [result.segmentIndex]: result.color })); + setSegmentColorsHex((prev) => ({ ...prev, [result.segmentIndex]: colorHex })); - sessionRef.current?.log("edit", `Created new class "${name}"`, 2000); + sessionRef.current?.log("edit", `Created new class "${trimmed}"`, 2000); return newOrgan; }; - const handleMouseClick = async (e: MouseEvent) => { const idx = getOrganLabelOnClick(); if (idx === undefined || typeof idx !== "number") { @@ -1798,6 +2343,7 @@ const aiAvailableOrgans = useMemo(() => { }); }; + // Mousemove handler for the "hover to identify" tool — resolves the organ under the // cursor for one specific pane (via canvasToWorld, not the crosshair) and floats a // tooltip next to the pointer. No-ops entirely while the tool is off. @@ -2306,23 +2852,16 @@ const aiAvailableOrgans = useMemo(() => { Redo (⇧⌘Z) + {!isLocal && ( )} @@ -2474,8 +3013,9 @@ const aiAvailableOrgans = useMemo(() => { onClick={() => { setShowStats(false); setShowMetadata(false); - setShowEditPanel(false); + setShowAnnotationToolbar(false); setEditMode(null); + setActiveToolbarTool(null); setShowMeasurePanel((v) => !v); panelsFlyout.close(); }} @@ -2651,20 +3191,23 @@ const aiAvailableOrgans = useMemo(() => { style={{ ...panelStyle("axial"), ...paneGridStyle("axial") }} onMouseUp={smartFill.handleMouseUp}>
{ handleMouseClick(e); }} + onDoubleClick={activeDrawTool.handleDoubleClick("axial")} onMouseDown={(e) => { focusedPane.handleMouseDown("axial")(); smartFill.handleMouseDown("axial")(e); morphPicker.handlePaneClick("axial")(e); - lasso.handleClick("axial")(e); + activeDrawTool.handleClick("axial")(e); + levelTracing.handleClick("axial")(e); }} onMouseMove={(e) => { handlePaneHover("axial")(e); smartFill.handleMouseMove("axial")(e); - lasso.handleMouseMove("axial")(e); + activeDrawTool.handleMouseMove("axial")(e); + levelTracing.handleMouseMove("axial")(e); }} onMouseLeave={handlePaneHoverLeave} onWheel={focusedPane.handleWheel("axial")} @@ -2687,38 +3230,62 @@ const aiAvailableOrgans = useMemo(() => { ))} )} - {editMode === "lasso" && lasso.pane === "axial" && ( + {editMode === "lasso" && activeDrawTool.pane === "axial" && ( + )} + {activeToolbarTool === "levelTracing" && levelTracing.previewPane === "axial" && levelTracing.previewPath && ( + + `${p[0]},${p[1]}`).join(" ")} + fill="rgba(234, 179, 8, 0.22)" + stroke="#eab308" + strokeWidth={2} + /> + + )} + {brushPreviewActive && + (activeToolbarTool === "paint" || activeToolbarTool === "erase") && + focusedPane.getFocusedPane() === "axial" && ( +
)} -
+
{ handleMouseClick(e); }} + onDoubleClick={activeDrawTool.handleDoubleClick("sagittal")} onMouseDown={(e) => { focusedPane.handleMouseDown("sagittal")(); smartFill.handleMouseDown("sagittal")(e); morphPicker.handlePaneClick("sagittal")(e); - lasso.handleClick("sagittal")(e); + activeDrawTool.handleClick("sagittal")(e); + levelTracing.handleClick("sagittal")(e); }} onMouseMove={(e) => { handlePaneHover("sagittal")(e); smartFill.handleMouseMove("sagittal")(e); - lasso.handleMouseMove("sagittal")(e); + activeDrawTool.handleMouseMove("sagittal")(e); + levelTracing.handleMouseMove("sagittal")(e); }} onMouseLeave={handlePaneHoverLeave} onWheel={focusedPane.handleWheel("sagittal")} @@ -2741,16 +3308,37 @@ const aiAvailableOrgans = useMemo(() => { ))} )} - {editMode === "lasso" && lasso.pane === "sagittal" && ( + {editMode === "lasso" && activeDrawTool.pane === "sagittal" && ( + )} + {activeToolbarTool === "levelTracing" && levelTracing.previewPane === "sagittal" && levelTracing.previewPath && ( + + `${p[0]},${p[1]}`).join(" ")} + fill="rgba(234, 179, 8, 0.22)" + stroke="#eab308" + strokeWidth={2} + /> + + )} + {brushPreviewActive && + (activeToolbarTool === "paint" || activeToolbarTool === "erase") && + focusedPane.getFocusedPane() === "sagittal" && ( +
)}
@@ -2760,20 +3348,25 @@ const aiAvailableOrgans = useMemo(() => { style={{ ...panelStyle("coronal"), ...paneGridStyle("coronal") }} onMouseUp={smartFill.handleMouseUp}>
{ handleMouseClick(e); }} + onClick={(e) => { handleMouseClick(e); }} + onDoubleClick={activeDrawTool.handleDoubleClick("coronal")} onMouseDown={(e) => { focusedPane.handleMouseDown("coronal")(); smartFill.handleMouseDown("coronal")(e); morphPicker.handlePaneClick("coronal")(e); - lasso.handleClick("coronal")(e); + activeDrawTool.handleClick("coronal")(e); + levelTracing.handleClick("coronal")(e); + + }} onMouseMove={(e) => { handlePaneHover("coronal")(e); smartFill.handleMouseMove("coronal")(e); - lasso.handleMouseMove("coronal")(e); + activeDrawTool.handleMouseMove("coronal")(e); + levelTracing.handleMouseMove("coronal")(e); }} onMouseLeave={handlePaneHoverLeave} onWheel={focusedPane.handleWheel("coronal")} @@ -2796,16 +3389,38 @@ const aiAvailableOrgans = useMemo(() => { ))} )} - {editMode === "lasso" && lasso.pane === "coronal" && ( + {editMode === "lasso" && activeDrawTool.pane === "coronal" && ( + )} + + {activeToolbarTool === "levelTracing" && levelTracing.previewPane === "coronal" && levelTracing.previewPath && ( + + `${p[0]},${p[1]}`).join(" ")} + fill="rgba(234, 179, 8, 0.22)" + stroke="#eab308" + strokeWidth={2} + /> + + )} + {brushPreviewActive && + (activeToolbarTool === "paint" || activeToolbarTool === "erase") && + focusedPane.getFocusedPane() === "coronal" && ( +
)}
@@ -2870,7 +3485,7 @@ const aiAvailableOrgans = useMemo(() => {
- +
{hoverOrganTip.visible && (
{ /> )} - {showEditPanel && ( - { - setShowEditPanel(false); - setEditMode(null); - }} - onEdit={(detail) => sessionRef.current?.log("edit", detail, 2000)} - onCreateClass={handleCreateClass} - smartFillMarkMode={smartFill.markMode} - onSmartFillMarkModeChange={smartFill.setMarkMode} - smartFillScope={smartFill.scope} - onSmartFillScopeChange={smartFill.setScope} - onApplySmartFill={smartFill.apply} - onClearSmartFillScribbles={smartFill.clearScribbles} - lassoAnchorCount={lasso.anchorsCanvas.length} - onLassoUndo={lasso.undo} - onLassoClose={lasso.close} - onLassoCancel={lasso.cancel} - morphScope={morphPicker.scope} - onMorphScopeChange={morphPicker.setScope} - pickingMorphTarget={morphPicker.picking} - onPickIsland={morphPicker.startPicking} - morphSeedVoxel={morphPicker.seedVoxel} - focusedPane={focusedPane.getFocusedPane()} - totalSlices={sliceInfo[focusedPane.getFocusedPane()]?.total ?? 0} - /> - - )} {/* Kept mounted (display toggles) so the chat history survives open/close. */} { onResize={applyAiWidth} onResizeEnd={commitAiWidth} /> -
+ + { setActiveSegment(id); setActiveCatalogOrganId(null); }} + onRename={handleRenameSegment} + onColorChange={handleSegmentColorChange} + onToggleVisibility={handleToggleSegmentVisibility} + onDelete={handleDeleteSegment} + onCreate={handleCreateClass} + organCatalog={organCatalog} + activeCatalogOrganId={activeCatalogOrganId} + onSelectCatalogOrgan={handleSelectCatalogOrgan} + containerRef={annotationPopupRef} + dragHandleRef={annotationPopupDragRef} + minButtonRef={annotationPopupMinRef} + /> {/* Local-DICOM load failure: explain and offer the way back. */} {dicomError && ( @@ -3190,4 +3814,4 @@ const aiAvailableOrgans = useMemo(() => { ); } -export default VisualizationPage; +export default VisualizationPage; \ No newline at end of file diff --git a/PanTS-Demo/src/test/organStats.test.tsx b/PanTS-Demo/src/test/organStats.test.tsx index 91e2c3f..c16e4d9 100644 --- a/PanTS-Demo/src/test/organStats.test.tsx +++ b/PanTS-Demo/src/test/organStats.test.tsx @@ -16,79 +16,71 @@ vi.mock("@niivue/niivue", () => ({ }, })); -vi.mock("../helpers/CornerstoneNifti2", () => ({ - getOrganLabelOnClick: vi.fn(), - getOrganLabelAtPoint: vi.fn(() => undefined), - moveCornerstoneCrosshairToMm: vi.fn(), - // The page destructures { renderingEngine, viewportIds, volumeId } off the result, - // so resolve that shape (not undefined) to avoid a post-test unhandled rejection. - renderVisualization: vi.fn().mockResolvedValue({ - renderingEngine: {}, - viewportIds: [], - volumeId: "test-volume", - }), - setFillOpacity: vi.fn(), - setPaneSliceIndex: vi.fn(), - subscribeToSliceChanges: vi.fn(() => () => {}), - setOutlineOpacity: vi.fn(), - setVisibilities: vi.fn(), - subscribeToCrosshairChanges: vi.fn(), - subscribeToVolumeProgress: vi.fn(() => () => {}), - toggleCrosshairTool: vi.fn(), - setActiveMeasurementTool: vi.fn(), - clearMeasurements: vi.fn(), - getCrosshairMm: vi.fn(() => null), - getOrganCentroids: vi.fn(() => null), - centerOnCursor: vi.fn(), - setZoom: vi.fn(), - zoomToFit: vi.fn(), - getMeasurementSummaries: vi.fn(() => []), - subscribeToMeasurementChanges: vi.fn(() => () => {}), - captureViewportImages: vi.fn(async () => []), - renameMeasurement: vi.fn(), - removeMeasurement: vi.fn(), - jumpToMeasurement: vi.fn(() => null), - // Progressive full-res upgrade + shaded volume rendering (3D pane) - upgradeCtVolume: vi.fn(async () => null), - enableVolume3D: vi.fn(async () => false), - disableVolume3D: vi.fn(), - applyVolume3DPreset: vi.fn(), - VOLUME_3D_PRESETS: [{ name: "CT-Bone", label: "Bone" }], - VOLUME_3D_PRESETS_MR: [{ name: "MR-Default", label: "Default" }], - getCurrentVolumeModality: () => undefined, - // Mask editing (brush/eraser + labelmap export) - setActiveMaskEditTool: vi.fn(), - setActiveEditSegment: vi.fn(), - setMaskBrushSize: vi.fn(), - undoMaskEdit: vi.fn(), - redoMaskEdit: vi.fn(), - getMaskEditHistoryState: vi.fn(() => ({ canUndo: false, canRedo: false })), - subscribeToSegmentationEdits: vi.fn(() => () => {}), - getEditedSegments: vi.fn(() => new Set()), - getSegmentationExport: vi.fn(() => null), - hasSegmentation: vi.fn(() => false), - zoomToCursor: vi.fn(), - CINE_VIEWPORT_BY_PANE: { axial: "CT_NIFTI_AXIAL", sagittal: "CT_NIFTI_SAGITTAL", coronal: "CT_NIFTI_CORONAL" }, - EDIT_BRUSH: "MaskBrush", - EDIT_ERASER: "MaskEraser", - LENGTH_TOOL: "Length", - PROBE_TOOL: "Probe", - ROI_TOOL: "RectangleROI", - ANGLE_TOOL: "Angle", - ELLIPSE_TOOL: "EllipticalROI", - FREEHAND_ROI_TOOL: "PlanarFreehandROI", - BIDIRECTIONAL_TOOL: "Bidirectional", - ARROW_TOOL: "ArrowAnnotate", - MAGNIFY_TOOL: "AdvancedMagnify", - // Cine playback + oblique-MPR reset - startCine: vi.fn(() => false), - stopCine: vi.fn(), - setReferenceLinesEnabled: vi.fn(), - flipPaneHorizontal: vi.fn(), - rotatePane90Clockwise: vi.fn(), - resetMprOrientation: vi.fn(), -})); - +vi.mock("../helpers/CornerstoneNifti2", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getOrganLabelOnClick: vi.fn(), + getOrganLabelAtPoint: vi.fn(() => undefined), + moveCornerstoneCrosshairToMm: vi.fn(), + // The page destructures { renderingEngine, viewportIds, volumeId } off the result, + // so resolve that shape (not undefined) to avoid a post-test unhandled rejection. + renderVisualization: vi.fn().mockResolvedValue({ + renderingEngine: {}, + viewportIds: [], + volumeId: "test-volume", + }), + setFillOpacity: vi.fn(), + setPaneSliceIndex: vi.fn(), + subscribeToSliceChanges: vi.fn(() => () => {}), + setOutlineOpacity: vi.fn(), + setVisibilities: vi.fn(), + subscribeToCrosshairChanges: vi.fn(), + subscribeToVolumeProgress: vi.fn(() => () => {}), + toggleCrosshairTool: vi.fn(), + setActiveMeasurementTool: vi.fn(), + clearMeasurements: vi.fn(), + getCrosshairMm: vi.fn(() => null), + getOrganCentroids: vi.fn(() => null), + centerOnCursor: vi.fn(), + setZoom: vi.fn(), + zoomToFit: vi.fn(), + zoomToCursor: vi.fn(), + getMeasurementSummaries: vi.fn(() => []), + subscribeToMeasurementChanges: vi.fn(() => () => {}), + captureViewportImages: vi.fn(async () => []), + renameMeasurement: vi.fn(), + removeMeasurement: vi.fn(), + jumpToMeasurement: vi.fn(() => null), + // Progressive full-res upgrade + shaded volume rendering (3D pane) + upgradeCtVolume: vi.fn(async () => null), + enableVolume3D: vi.fn(async () => false), + disableVolume3D: vi.fn(), + applyVolume3DPreset: vi.fn(), + getCurrentVolumeModality: () => undefined, + // Mask editing (brush/eraser + labelmap export) + setActiveMaskEditTool: vi.fn(), + setActiveEditSegment: vi.fn(), + setMaskBrushSize: vi.fn(), + undoMaskEdit: vi.fn(), + redoMaskEdit: vi.fn(), + getMaskEditHistoryState: vi.fn(() => ({ canUndo: false, canRedo: false })), + subscribeToSegmentationEdits: vi.fn(() => () => {}), + getEditedSegments: vi.fn(() => new Set()), + getSegmentationExport: vi.fn(() => null), + hasSegmentation: vi.fn(() => false), + hasSegmentationVolume: vi.fn(() => false), + buildMaskFilter: vi.fn(() => () => true), + setBrushMaskingScope: vi.fn(), + // Cine playback + oblique-MPR reset + startCine: vi.fn(() => false), + stopCine: vi.fn(), + setReferenceLinesEnabled: vi.fn(), + flipPaneHorizontal: vi.fn(), + rotatePane90Clockwise: vi.fn(), + resetMprOrientation: vi.fn(), + }; +}); vi.mock("../helpers/NiiVueNifti", () => ({ create3DVolume: vi.fn().mockResolvedValue(undefined), moveNiiVueCrosshairToMm: vi.fn(), diff --git a/PanTS-Demo/src/test/viewer.smoke.test.tsx b/PanTS-Demo/src/test/viewer.smoke.test.tsx index e7629b4..e2b591e 100644 --- a/PanTS-Demo/src/test/viewer.smoke.test.tsx +++ b/PanTS-Demo/src/test/viewer.smoke.test.tsx @@ -17,74 +17,67 @@ vi.mock("@niivue/niivue", () => ({ }, })); -vi.mock("../helpers/CornerstoneNifti2", () => ({ - getOrganLabelOnClick: vi.fn(), - getOrganLabelAtPoint: vi.fn(() => undefined), - moveCornerstoneCrosshairToMm: vi.fn(), - renderVisualization: vi.fn().mockResolvedValue(undefined), - setFillOpacity: vi.fn(), - setPaneSliceIndex: vi.fn(), - subscribeToSliceChanges: vi.fn(() => () => {}), - setOutlineOpacity: vi.fn(), - setVisibilities: vi.fn(), - subscribeToCrosshairChanges: vi.fn(), - subscribeToVolumeProgress: vi.fn(() => () => {}), - toggleCrosshairTool: vi.fn(), - setActiveMeasurementTool: vi.fn(), - clearMeasurements: vi.fn(), - getCrosshairMm: vi.fn(() => null), - getOrganCentroids: vi.fn(() => null), - // Measurement inventory + reading-session capture APIs - getMeasurementSummaries: vi.fn(() => []), - subscribeToMeasurementChanges: vi.fn(() => () => {}), - captureViewportImages: vi.fn(async () => []), - renameMeasurement: vi.fn(), - removeMeasurement: vi.fn(), - jumpToMeasurement: vi.fn(() => null), - // Zoom controls now live in the top toolbar (previously ZoomHandle) - setZoom: vi.fn(), - centerOnCursor: vi.fn(), - zoomToFit: vi.fn(), - // Progressive full-res upgrade + shaded volume rendering (3D pane) - upgradeCtVolume: vi.fn(async () => null), - enableVolume3D: vi.fn(async () => false), - disableVolume3D: vi.fn(), - applyVolume3DPreset: vi.fn(), - VOLUME_3D_PRESETS: [{ name: "CT-Bone", label: "Bone" }], - VOLUME_3D_PRESETS_MR: [{ name: "MR-Default", label: "Default" }], - getCurrentVolumeModality: () => undefined, - // Mask editing (brush/eraser + labelmap export) - setActiveMaskEditTool: vi.fn(), - setActiveEditSegment: vi.fn(), - setMaskBrushSize: vi.fn(), - undoMaskEdit: vi.fn(), - redoMaskEdit: vi.fn(), - getMaskEditHistoryState: vi.fn(() => ({ canUndo: false, canRedo: false })), - subscribeToSegmentationEdits: vi.fn(() => () => {}), - getEditedSegments: vi.fn(() => new Set()), - getSegmentationExport: vi.fn(() => null), - hasSegmentation: vi.fn(() => false), - zoomToCursor: vi.fn(), - CINE_VIEWPORT_BY_PANE: { axial: "CT_NIFTI_AXIAL", sagittal: "CT_NIFTI_SAGITTAL", coronal: "CT_NIFTI_CORONAL" }, - EDIT_BRUSH: "MaskBrush", - EDIT_ERASER: "MaskEraser", - LENGTH_TOOL: "Length", - PROBE_TOOL: "Probe", - ROI_TOOL: "RectangleROI", - ANGLE_TOOL: "Angle", - ELLIPSE_TOOL: "EllipticalROI", - FREEHAND_ROI_TOOL: "PlanarFreehandROI", - BIDIRECTIONAL_TOOL: "Bidirectional", - ARROW_TOOL: "ArrowAnnotate", - MAGNIFY_TOOL: "AdvancedMagnify", - // Cine playback + oblique-MPR reset - startCine: vi.fn(() => false), - stopCine: vi.fn(), - setReferenceLinesEnabled: vi.fn(), - flipPaneHorizontal: vi.fn(), - rotatePane90Clockwise: vi.fn(), - resetMprOrientation: vi.fn(), -})); +vi.mock("../helpers/CornerstoneNifti2", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getOrganLabelOnClick: vi.fn(), + getOrganLabelAtPoint: vi.fn(() => undefined), + moveCornerstoneCrosshairToMm: vi.fn(), + renderVisualization: vi.fn().mockResolvedValue(undefined), + setFillOpacity: vi.fn(), + setPaneSliceIndex: vi.fn(), + subscribeToSliceChanges: vi.fn(() => () => {}), + setOutlineOpacity: vi.fn(), + setVisibilities: vi.fn(), + subscribeToCrosshairChanges: vi.fn(), + subscribeToVolumeProgress: vi.fn(() => () => {}), + toggleCrosshairTool: vi.fn(), + setActiveMeasurementTool: vi.fn(), + clearMeasurements: vi.fn(), + getCrosshairMm: vi.fn(() => null), + getOrganCentroids: vi.fn(() => null), + // Measurement inventory + reading-session capture APIs + getMeasurementSummaries: vi.fn(() => []), + subscribeToMeasurementChanges: vi.fn(() => () => {}), + captureViewportImages: vi.fn(async () => []), + renameMeasurement: vi.fn(), + removeMeasurement: vi.fn(), + jumpToMeasurement: vi.fn(() => null), + // Zoom controls now live in the top toolbar (previously ZoomHandle) + setZoom: vi.fn(), + centerOnCursor: vi.fn(), + zoomToFit: vi.fn(), + zoomToCursor: vi.fn(), + // Progressive full-res upgrade + shaded volume rendering (3D pane) + upgradeCtVolume: vi.fn(async () => null), + enableVolume3D: vi.fn(async () => false), + disableVolume3D: vi.fn(), + applyVolume3DPreset: vi.fn(), + getCurrentVolumeModality: () => undefined, + // Mask editing (brush/eraser + labelmap export) + setActiveMaskEditTool: vi.fn(), + setActiveEditSegment: vi.fn(), + setMaskBrushSize: vi.fn(), + undoMaskEdit: vi.fn(), + redoMaskEdit: vi.fn(), + getMaskEditHistoryState: vi.fn(() => ({ canUndo: false, canRedo: false })), + subscribeToSegmentationEdits: vi.fn(() => () => {}), + getEditedSegments: vi.fn(() => new Set()), + getSegmentationExport: vi.fn(() => null), + hasSegmentation: vi.fn(() => false), + hasSegmentationVolume: vi.fn(() => false), + buildMaskFilter: vi.fn(() => () => true), + setBrushMaskingScope: vi.fn(), + // Cine playback + oblique-MPR reset + startCine: vi.fn(() => false), + stopCine: vi.fn(), + setReferenceLinesEnabled: vi.fn(), + flipPaneHorizontal: vi.fn(), + rotatePane90Clockwise: vi.fn(), + resetMprOrientation: vi.fn(), + }; +}); vi.mock("../helpers/NiiVueNifti", () => ({ create3DVolume: vi.fn().mockResolvedValue(undefined),