Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions PanTS-Demo/src/helpers/CornerstoneNifti2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,49 @@
ToolGroupManager,
Enums: csToolsEnums,
segmentation,
annotation,
PanTool,
ZoomTool,
StackScrollTool,
CrosshairsTool,
LengthTool,
ProbeTool,
RectangleROITool,
} = cornerstoneTools;

// Measurement tools the toolbar can switch the primary mouse button to. Length =
// distance in mm, Probe = HU readout at a point, RectangleROI = area + mean/max/min HU.
export const LENGTH_TOOL = LengthTool.toolName;
export const PROBE_TOOL = ProbeTool.toolName;
export const ROI_TOOL = RectangleROITool.toolName;
export const MEASUREMENT_TOOL_NAMES = [LENGTH_TOOL, PROBE_TOOL, ROI_TOOL] as const;
export type MeasurementToolName = (typeof MEASUREMENT_TOOL_NAMES)[number];

// Cornerstone's defaults draw measurements in yellow (resting) / green (selected) — the
// standard radiology-viewer convention for a plain grayscale background. BodyMaps overlays
// colored organ masks (reds/pinks/purples/teal), so yellow/green collide with them. Cyan
// gives the strongest contrast over the warm masks while still reading on grayscale CT;
// selected annotations go white for clear edit feedback. The dashed leader line that
// tethers each label to its measurement is recolored to match.
const MEASURE_COLOR = "#22d3ee"; // cyan — resting
const MEASURE_COLOR_HI = "#67e8f9"; // lighter cyan — hover
const MEASUREMENT_ANNOTATION_STYLE = {
color: MEASURE_COLOR,
colorHighlighted: MEASURE_COLOR_HI,
colorSelected: "#ffffff",
colorLocked: MEASURE_COLOR,
lineWidth: "2",
textBoxColor: MEASURE_COLOR,
textBoxColorHighlighted: MEASURE_COLOR_HI,
textBoxColorSelected: "#ffffff",
textBoxLinkLineColor: MEASURE_COLOR,
// Pin the font/shadow too: if a prior (partial) style ever persisted in module state,
// the merge base could be missing these and the value labels wouldn't render.
textBoxFontFamily: "Helvetica Neue, Helvetica, Arial, sans-serif",
textBoxFontSize: "14px",
shadow: true,
};

const renderingEngineId = "rendering_engine";
const toolGroupId = "myToolGroup";
const DEFAULT_SEGMENTATION_CONFIG = {
Expand Down Expand Up @@ -60,7 +97,7 @@
export function moveCornerstoneCrosshairToMm(mm: [number, number, number]) {
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
if (!toolGroup) return;
const tool = toolGroup.getToolInstance(CrosshairsTool.toolName) as any;

Check failure on line 100 in PanTS-Demo/src/helpers/CornerstoneNifti2.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 22)

Unexpected any. Specify a different type

Check failure on line 100 in PanTS-Demo/src/helpers/CornerstoneNifti2.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 20)

Unexpected any. Specify a different type
if (!tool?.setToolCenter) return;
_isSyncing = true;
try {
Expand Down Expand Up @@ -130,9 +167,22 @@
cornerstoneTools.addTool(ZoomTool);
cornerstoneTools.addTool(StackScrollTool);
cornerstoneTools.addTool(CrosshairsTool);
cornerstoneTools.addTool(LengthTool);
cornerstoneTools.addTool(ProbeTool);
cornerstoneTools.addTool(RectangleROITool);
toolGroup.addTool(PanTool.toolName);
toolGroup.addTool(ZoomTool.toolName);
toolGroup.addTool(StackScrollTool.toolName);
toolGroup.addTool(LengthTool.toolName);
toolGroup.addTool(ProbeTool.toolName);
toolGroup.addTool(RectangleROITool.toolName);
// Merge our color overrides onto the existing defaults — replacing wholesale would
// drop font/background/shadow defaults and the value labels would stop rendering.
const defaultStyles = annotation.config.style.getDefaultToolStyles();
annotation.config.style.setDefaultToolStyles({
...defaultStyles,
global: { ...(defaultStyles.global ?? {}), ...MEASUREMENT_ANNOTATION_STYLE },
});
toolGroup.addTool(CrosshairsTool.toolName, {
getReferenceLineColor,
getReferenceLineControllable,
Expand Down Expand Up @@ -200,6 +250,11 @@
toolGroup.setToolActive(StackScrollTool.toolName, {
bindings: [{ mouseButton: csToolsEnums.MouseBindings.Wheel }]
})
// Measurement tools start passive: their annotations stay selectable/editable, but
// the primary button keeps driving the crosshair until the user picks a measure tool.
for (const toolName of MEASUREMENT_TOOL_NAMES) {
toolGroup.setToolPassive(toolName);
}

renderingEngine.setViewports(viewportInputArray);

Expand Down Expand Up @@ -314,6 +369,39 @@
}
}

// Activate a measurement tool on the primary mouse button, or pass `null` to hand the
// primary button back to navigation (the caller restores crosshair/pan afterwards).
// While a measure tool is active we disable crosshair + pan so clicks draw, not navigate.
export function setActiveMeasurementTool(toolName: MeasurementToolName | null) {
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
if (!toolGroup) return;
// Reset every measure tool to passive first (keeps existing annotations editable).
for (const name of MEASUREMENT_TOOL_NAMES) toolGroup.setToolPassive(name);
if (!toolName) return;
toolGroup.setToolDisabled(CrosshairsTool.toolName);
toolGroup.setToolDisabled(PanTool.toolName);
toolGroup.setToolActive(toolName, {
bindings: [{ mouseButton: csToolsEnums.MouseBindings.Primary }],
});
}

// Remove only measurement annotations (Length/Probe/ROI), leaving the crosshair intact.
export function clearMeasurements() {
try {
const all = annotation.state.getAllAnnotations() ?? [];
const names = MEASUREMENT_TOOL_NAMES as readonly string[];
for (const a of [...all]) {
const toolName = a?.metadata?.toolName;
if (toolName && names.includes(toolName) && a.annotationUID) {
annotation.state.removeAnnotation(a.annotationUID);
}
}
} catch {
/* annotation state may not be ready (e.g. before first render) — no-op */
}
currentRenderingEngine?.render();
}

export function setZoom(zoomValue: number){
const engine = getRenderingEngine(renderingEngineId);
[viewportId1, viewportId2, viewportId3].forEach((viewportId) => {
Expand Down
73 changes: 68 additions & 5 deletions PanTS-Demo/src/routes/VisualizationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@
import { Niivue } from "@niivue/niivue";
import {
IconChartBar,
IconClick,
IconDownload, IconHome, IconPointer, IconReport,
IconSettings
IconRuler2,
IconSettings,
IconSquareDashed,
IconTrash
} from "@tabler/icons-react";
import React, { lazy, Suspense, useEffect, useRef, useState, type MouseEvent } from "react";
import { useParams } from "react-router-dom";
Expand All @@ -17,9 +21,15 @@
import WindowingSlider from "../components/WindowingSlider/WindowingSlider";
import ZoomHandle from "../components/zoomHandle";
import {
clearMeasurements,
getOrganLabelOnClick,
LENGTH_TOOL,
type MeasurementToolName,
moveCornerstoneCrosshairToMm,
PROBE_TOOL,
renderVisualization,
ROI_TOOL,
setActiveMeasurementTool,
setToolGroupOpacity,
setVisibilities,
subscribeToCrosshairChanges,
Expand Down Expand Up @@ -156,6 +166,8 @@
const [zoomMode, setZoomMode] = useState(false);
const [zoomLevel, setZoomLevel] = useState(1);
const [crosshairToolActive, setCrosshairToolActive] = useState(true);
// Which measurement tool owns the primary mouse button (null = navigation/crosshair).
const [activeMeasureTool, setActiveMeasureTool] = useState<MeasurementToolName | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>("mpr");
const [activePreset, setActivePreset] = useState<string>("Soft Tissue");
const [tooltip, setToolTip] = useState({
Expand All @@ -169,8 +181,24 @@
// Load and render visualization on first render

useEffect(() => {
// A measurement tool, when active, owns the primary button — don't let the
// crosshair/pan toggle fight it for control.
if (activeMeasureTool) return;
toggleCrosshairTool(crosshairToolActive);
}, [crosshairToolActive]);
}, [crosshairToolActive, activeMeasureTool]);

// Hand the primary mouse button to the chosen measure tool, or back to navigation.
useEffect(() => {
if (activeMeasureTool) {
setActiveMeasurementTool(activeMeasureTool);
} else {
setActiveMeasurementTool(null);
toggleCrosshairTool(crosshairToolActive);
}
// crosshairToolActive intentionally omitted: the effect above re-applies nav when
// the crosshair/pan toggle changes; here we only react to the measure-tool switch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeMeasureTool]);

// Track the CT download to show an accurate ETA while the case loads. We follow the
// largest-total stream (the CT volume, not the smaller segmentation) and derive the
Expand Down Expand Up @@ -347,7 +375,7 @@
if (renderingEngine && viewportIds.length && volumeId) {
handleWindowChange(windowWidth, windowCenter);
}
}, [renderingEngine, viewportIds, volumeId]);

Check warning on line 378 in PanTS-Demo/src/routes/VisualizationPage.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 22)

React Hook useEffect has missing dependencies: 'handleWindowChange', 'windowCenter', and 'windowWidth'. Either include them or remove the dependency array

Check warning on line 378 in PanTS-Demo/src/routes/VisualizationPage.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 20)

React Hook useEffect has missing dependencies: 'handleWindowChange', 'windowCenter', and 'windowWidth'. Either include them or remove the dependency array

// Resize Cornerstone + NiiVue when view mode changes. resize(immediate, keepCamera):
// keepCamera defaults to true, which preserved the zoom/pan from a single (fullscreen)
Expand Down Expand Up @@ -634,13 +662,48 @@

<div className="vp-toolrow">
<button
className={`vp-tool ${crosshairToolActive ? "vp-tool--active" : ""}`}
onClick={() => setCrosshairToolActive((prev) => !prev)}
className={`vp-tool ${crosshairToolActive && !activeMeasureTool ? "vp-tool--active" : ""}`}
onClick={() => {
setActiveMeasureTool(null);
setCrosshairToolActive((prev) => !prev);
}}
aria-label="Crosshair mode"
>
<IconPointer size={20} color={crosshairToolActive ? "#08090b" : "white"} />
<IconPointer size={20} color={crosshairToolActive && !activeMeasureTool ? "#08090b" : "white"} />
<span className="vp-tool__tip">Crosshair</span>
</button>
<button
className={`vp-tool ${activeMeasureTool === LENGTH_TOOL ? "vp-tool--active" : ""}`}
onClick={() => setActiveMeasureTool((p) => (p === LENGTH_TOOL ? null : LENGTH_TOOL))}
aria-label="Measure distance"
>
<IconRuler2 size={20} color={activeMeasureTool === LENGTH_TOOL ? "#08090b" : "white"} />
<span className="vp-tool__tip">Distance (mm)</span>
</button>
<button
className={`vp-tool ${activeMeasureTool === PROBE_TOOL ? "vp-tool--active" : ""}`}
onClick={() => setActiveMeasureTool((p) => (p === PROBE_TOOL ? null : PROBE_TOOL))}
aria-label="HU probe"
>
<IconClick size={20} color={activeMeasureTool === PROBE_TOOL ? "#08090b" : "white"} />
<span className="vp-tool__tip">HU at point</span>
</button>
<button
className={`vp-tool ${activeMeasureTool === ROI_TOOL ? "vp-tool--active" : ""}`}
onClick={() => setActiveMeasureTool((p) => (p === ROI_TOOL ? null : ROI_TOOL))}
aria-label="Rectangle ROI"
>
<IconSquareDashed size={20} color={activeMeasureTool === ROI_TOOL ? "#08090b" : "white"} />
<span className="vp-tool__tip">ROI · HU &amp; area</span>
</button>
<button
className="vp-tool"
onClick={() => clearMeasurements()}
aria-label="Clear measurements"
>
<IconTrash size={20} color="white" />
<span className="vp-tool__tip">Clear measurements</span>
</button>
{/* <div className="group cursor-pointer rounded-md relative">
{!zoomMode ? (
<>
Expand Down
5 changes: 5 additions & 0 deletions PanTS-Demo/src/test/viewer.smoke.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ vi.mock("../helpers/CornerstoneNifti2", () => ({
subscribeToCrosshairChanges: vi.fn(),
subscribeToVolumeProgress: vi.fn(() => () => {}),
toggleCrosshairTool: vi.fn(),
setActiveMeasurementTool: vi.fn(),
clearMeasurements: vi.fn(),
LENGTH_TOOL: "Length",
PROBE_TOOL: "Probe",
ROI_TOOL: "RectangleROI",
}));

vi.mock("../helpers/NiiVueNifti", () => ({
Expand Down
Loading