From f25f68ca3d7eb8f04c469811a242d19a1bfb7f58 Mon Sep 17 00:00:00 2001 From: Aditya Sanjeev Date: Sun, 21 Jun 2026 18:30:31 -0700 Subject: [PATCH] Add distance, HU probe, and ROI measurement tools to the viewer - Register Cornerstone LengthTool / ProbeTool / RectangleROITool and add toolbar buttons for distance (mm), HU-at-point, and rectangle ROI (HU + area). - setActiveMeasurementTool() hands the primary mouse button to the chosen measure tool and disables crosshair/pan while measuring; clearMeasurements() removes only measurement annotations, leaving the crosshair intact. - Recolor measurement annotations to cyan (resting) / white (selected) so they read cleanly over the colored organ masks instead of the default yellow/green. - Update the viewer smoke-test mock with the new exports. --- PanTS-Demo/src/helpers/CornerstoneNifti2.tsx | 88 ++++++++++++++++++++ PanTS-Demo/src/routes/VisualizationPage.tsx | 73 ++++++++++++++-- PanTS-Demo/src/test/viewer.smoke.test.tsx | 5 ++ 3 files changed, 161 insertions(+), 5 deletions(-) diff --git a/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx b/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx index f4473e4..1676725 100644 --- a/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx +++ b/PanTS-Demo/src/helpers/CornerstoneNifti2.tsx @@ -11,12 +11,49 @@ const { 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 = { @@ -130,9 +167,22 @@ export async function renderVisualization(ref1: HTMLDivElement, ref2: HTMLDivEle 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, @@ -200,6 +250,11 @@ export async function renderVisualization(ref1: HTMLDivElement, ref2: HTMLDivEle 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); @@ -314,6 +369,39 @@ export function toggleCrosshairTool(enable: boolean) { } } +// 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) => { diff --git a/PanTS-Demo/src/routes/VisualizationPage.tsx b/PanTS-Demo/src/routes/VisualizationPage.tsx index dd4cabf..8f6fd2f 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.tsx +++ b/PanTS-Demo/src/routes/VisualizationPage.tsx @@ -4,8 +4,12 @@ import type { vtkVolumeProperty } from '@kitware/vtk.js/Rendering/Core/VolumePro 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"; @@ -17,9 +21,15 @@ import SnakeGame from "../components/SnakeGame/SnakeGame"; 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, @@ -156,6 +166,8 @@ function VisualizationPage() { 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(null); const [viewMode, setViewMode] = useState("mpr"); const [activePreset, setActivePreset] = useState("Soft Tissue"); const [tooltip, setToolTip] = useState({ @@ -169,8 +181,24 @@ function VisualizationPage() { // 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 @@ -634,13 +662,48 @@ function VisualizationPage() {
+ + + + {/*
{!zoomMode ? ( <> diff --git a/PanTS-Demo/src/test/viewer.smoke.test.tsx b/PanTS-Demo/src/test/viewer.smoke.test.tsx index 376f21b..0983320 100644 --- a/PanTS-Demo/src/test/viewer.smoke.test.tsx +++ b/PanTS-Demo/src/test/viewer.smoke.test.tsx @@ -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", () => ({