From 81edcea5ea947e64a237101c95dc0af1e84828ad Mon Sep 17 00:00:00 2001 From: Ross Date: Sun, 25 May 2025 21:13:45 +0100 Subject: [PATCH] basic reference line functions --- dicom-viewer/src/App.tsx | 341 +++++++++++++++++++++++++++++---------- 1 file changed, 255 insertions(+), 86 deletions(-) diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index ec58e5f..dc7f8ea 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -60,6 +60,7 @@ const MOUSE_BUTTONS = [ +// App definintion function App() { const elementRef = useRef(null) const viewportRef = useRef(null) // Store the viewport instance @@ -82,28 +83,106 @@ function App() { const renderingEngineId = "mainRenderingEngine"; const renderingEngineRef = useRef(null); - // Store imageIds globally after loading so they can be reused - const [loadedImageIds, setLoadedImageIds] = useState(null); + const [availableStacks, setAvailableStacks] = useState([]); + const [activeViewport, setActiveViewport] = useState(0); + const [viewportImageIds, setViewportImageIds] = useState( + () => Array(MAX_VIEWPORTS).fill([]) + ); + const [selectedStackIdx, setSelectedStackIdx] = useState( + () => Array(MAX_VIEWPORTS).fill(0) + ); + + // Load available stacks on mount + useEffect(() => { + availableImageIds().then(stacks => { + setAvailableStacks(stacks); + // Optionally initialize each viewport with the first stack + setLoadedImageIdsAndCacheMeta(stacks[0]); + setViewportImageIds(Array(MAX_VIEWPORTS).fill(stacks[0] || [])); + }); + }, []); + + const handleLoadStack = async (viewportIdx: number, stackIdx: number) => { + const stack = availableStacks[stackIdx]; + + if (!stack) return; + setViewportImageIds(prev => { + const next = [...prev]; + next[viewportIdx] = stack; + return next; + }); + setSelectedStackIdx(prev => { + const next = [...prev]; + next[viewportIdx] = stackIdx; + return next; + }); + + // Cache imageIds and load metadata for this stack + await setLoadedImageIdsAndCacheMeta(stack); + }; const [crossReferenceEnabled, setCrossReferenceEnabled] = useState(true); - const [referenceLinesEnabled, setReferenceLinesEnabled] = useState(false); + const [referenceLinesEnabled, setReferenceLinesEnabled] = useState(true); + + const sortViewportImageIdsBySliceLocation = () => { + setViewportImageIds(prev => { + const next = prev.map((imageIds, idx) => { + if (!imageIds || imageIds.length === 0) return imageIds; + + const withSortKey = imageIds.map(id => { + const plane = metaData.get('imagePlaneModule', id); + let sortKey = null; + if (plane) { + if (plane.sliceLocation !== undefined) { + sortKey = Number(plane.sliceLocation); + } else if ( + plane.imagePositionPatient && + Array.isArray(plane.imagePositionPatient) && + plane.imagePositionPatient.length === 3 + ) { + sortKey = Number(plane.imagePositionPatient[2]); + } + } + return { imageId: id, sortKey }; + }); + + const sorted = [...withSortKey].sort((a, b) => { + if (a.sortKey !== null && b.sortKey !== null) { + return a.sortKey - b.sortKey; + } + return a.imageId.localeCompare(b.imageId); + }); + + const sortedIds = sorted.map(obj => obj.imageId); + + // Log the sorted result for this viewport + console.log(`Viewport ${idx} sorted imageIds:`, sortedIds); + + if (JSON.stringify(sortedIds) === JSON.stringify(imageIds)) { + return imageIds; + } + return sortedIds; + }); + return next; + }); + }; // Wrap setLoadedImageIds to also cache DICOM metadata const setLoadedImageIdsAndCacheMeta = async (imageIds: string[]) => { + console.log("Caching loaded image IDs:", imageIds); + const loadPromises: Promise[] = []; + for (const imageId of imageIds) { - // Only cache if it's a local file (wadouri:blob:) or a file URL if (imageId.startsWith("wadouri:")) { try { - // Extract the URL after "wadouri:" const url = imageId.slice(8); - // Only cache if it's a blob or file URL if (url.startsWith("blob:") || url.startsWith("/")) { - //const response = await fetch(url); - //const arrayBuffer = await response.arrayBuffer(); - //const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer)); if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) { - cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load(url); - + // If load returns a promise, collect it + const result = cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load(url); + if (result && typeof result.then === "function") { + loadPromises.push(result); + } } else { console.warn("MetaDataManager not found. Unable to cache metadata."); } @@ -113,7 +192,15 @@ function App() { } } } - setLoadedImageIds(imageIds); + + // Wait for all metadata loads to finish + await Promise.all(loadPromises); + console.log("Image IDs cached:", imageIds); + + // Trigger sort if enabled + if (orderBySliceLocation) { + sortViewportImageIdsBySliceLocation(); + } }; @@ -196,12 +283,14 @@ function App() { // Add this function to gather and format metadata: const handleShowMetadata = () => { - if (!loadedImageIds || loadedImageIds.length === 0) { + // Find the first visible viewport with images + const firstIdx = viewportImageIds.findIndex(ids => ids && ids.length > 0); + if (firstIdx === -1) { setMetaModalContent("No images loaded."); setMetaModalOpen(true); return; } - const imageId = loadedImageIds[0]; + const imageId = viewportImageIds[firstIdx][0]; // Gather all available metadata modules for this imageId const modules = [ @@ -378,6 +467,7 @@ function App() { useEffect(() => { const setup = async () => { + console.log("Setting up.."); // 1. Initialize Cornerstone3D and tools ONCE if (!running.current) { running.current = true; @@ -388,12 +478,6 @@ function App() { dicomImageLoaderInit({ maxWebWorkers: 1 }); } - // 2. Load image IDs if not already loaded - let imageIds = loadedImageIds; - if (!imageIds) { - imageIds = await createImageIds(); - setLoadedImageIdsAndCacheMeta(imageIds); - } // 3. Get or create the rendering engine let renderingEngine = renderingEngineRef.current; @@ -420,6 +504,14 @@ function App() { if (!volumeToolGroup.hasTool(ReferenceLinesTool.toolName)) { volumeToolGroup.addTool(ReferenceLinesTool.toolName); } + // Set the active viewport as the source for reference lines + const sourceViewportId = `CT_${activeViewport}`; + stackToolGroup.setToolConfiguration(ReferenceLinesTool.toolName, { + sourceViewportId, + }); + volumeToolGroup.setToolConfiguration(ReferenceLinesTool.toolName, { + sourceViewportId, + }); stackToolGroup.setToolEnabled(ReferenceLinesTool.toolName); volumeToolGroup.setToolEnabled(ReferenceLinesTool.toolName); } else { @@ -433,6 +525,10 @@ function App() { // 6. Enable and configure each viewport for (let i = 0; i < MAX_VIEWPORTS; i++) { + + let imageIds = viewportImageIds[i]; + if (!imageIds || imageIds.length === 0) continue; + const viewportId = `CT_${i}`; const element = viewportRefs.current[i]; if (!element) continue; @@ -494,20 +590,20 @@ function App() { // Restore scroll, properties, etc. } else if (viewportModes[i] === 'volume') { - let sortedImageIds = imageIds; - if (imageIds && imageIds.length > 1) { - sortedImageIds = [...imageIds].sort((a, b) => { - const metaA = metaData.get('imagePlaneModule', a); - const metaB = metaData.get('imagePlaneModule', b); - const sliceA = metaA && metaA.sliceLocation !== undefined ? Number(metaA.sliceLocation) : 0; - const sliceB = metaB && metaB.sliceLocation !== undefined ? Number(metaB.sliceLocation) : 0; - return sliceA - sliceB; - }); - } + //let sortedImageIds = imageIds; + //if (imageIds && imageIds.length > 1) { + // sortedImageIds = [...imageIds].sort((a, b) => { + // const metaA = metaData.get('imagePlaneModule', a); + // const metaB = metaData.get('imagePlaneModule', b); + // const sliceA = metaA && metaA.sliceLocation !== undefined ? Number(metaA.sliceLocation) : 0; + // const sliceB = metaB && metaB.sliceLocation !== undefined ? Number(metaB.sliceLocation) : 0; + // return sliceA - sliceB; + // }); + //} // Use a unique volumeId per viewport const volumeId = `myVolumeId_${i}`; - const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: sortedImageIds }); + const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: imageIds }); volume.load(); viewport.setVolumes([{ volumeId }]); viewport.render(); @@ -611,11 +707,11 @@ function App() { }); cancelAnimationFrame(raf); }; - }, [elementRef, updateOverlay, viewportGrid, loadedImageIds, viewportModes]); + }, [elementRef, updateOverlay, viewportGrid, viewportModes, viewportImageIds]); // Handler to reset the view (camera and window/level) - const handleDoubleClick = useCallback((viewportIdx: numebr) => { + const handleDoubleClick = useCallback((viewportIdx: number) => { const viewportId = `CT_${viewportIdx}`; const renderingEngine = renderingEngineRef.current; const viewport = renderingEngine.getViewport(viewportId); @@ -690,56 +786,58 @@ function App() { }; }, []); - // Add this effect to re-order images by Slice Location after images are loaded and when the option changes: useEffect(() => { - if (!loadedImageIds || loadedImageIds.length === 0) return; + if (!referenceLinesEnabled) return; + const stackToolGroup = ToolGroupManager.getToolGroup(STACK_TOOL_GROUP_ID); + const volumeToolGroup = ToolGroupManager.getToolGroup(VOLUME_TOOL_GROUP_ID); + const sourceViewportId = `CT_${activeViewport}`; + if (stackToolGroup?.hasTool(ReferenceLinesTool.toolName)) { + stackToolGroup.setToolConfiguration(ReferenceLinesTool.toolName, { sourceViewportId }); + } + if (volumeToolGroup?.hasTool(ReferenceLinesTool.toolName)) { + volumeToolGroup.setToolConfiguration(ReferenceLinesTool.toolName, { sourceViewportId }); + } + }, [activeViewport, referenceLinesEnabled]); + + useEffect(() => { if (!orderBySliceLocation) return; - let cancelled = false; + // Only sort if all metadata is loaded for all viewports + const allLoaded = viewportImageIds.every(imageIds => + !imageIds || imageIds.length === 0 || + imageIds.every(id => !!metaData.get("imagePlaneModule", id)) + ); + if (!allLoaded) return; - const checkAndSort = () => { - // Check if all metadata is loaded - const allLoaded = loadedImageIds.every( - id => !!metaData.get("imagePlaneModule", id) - ); - if (!allLoaded) { - // Try again soon - setTimeout(checkAndSort, 200); - return; - } + setViewportImageIds(prev => { + const next = prev.map(imageIds => { + if (!imageIds || imageIds.length === 0) return imageIds; - // All metadata loaded, sort by slice location - const getSliceLocation = (imageId: string) => { - const meta = metaData.get('imagePlaneModule', imageId); - return meta && meta.sliceLocation !== undefined ? Number(meta.sliceLocation) : null; - }; + const withSliceLoc = imageIds.map(id => ({ + imageId: id, + sliceLoc: (() => { + const meta = metaData.get('imagePlaneModule', id); + return meta && meta.sliceLocation !== undefined ? Number(meta.sliceLocation) : null; + })(), + })); - const withSliceLoc = loadedImageIds.map(id => ({ - imageId: id, - sliceLoc: getSliceLocation(id), - })); + const sorted = [...withSliceLoc].sort((a, b) => { + if (a.sliceLoc !== null && b.sliceLoc !== null) { + return a.sliceLoc - b.sliceLoc; + } + return a.imageId.localeCompare(b.imageId); + }); - const sorted = [...withSliceLoc].sort((a, b) => { - if (a.sliceLoc !== null && b.sliceLoc !== null) { - return a.sliceLoc - b.sliceLoc; + const sortedIds = sorted.map(obj => obj.imageId); + if (JSON.stringify(sortedIds) === JSON.stringify(imageIds)) { + return imageIds; } - return a.imageId.localeCompare(b.imageId); + return sortedIds; }); + return next; + }); + }, [orderBySliceLocation, availableStacks]); - const sortedIds = sorted.map(obj => obj.imageId); - const isSame = - loadedImageIds.length === sortedIds.length && - loadedImageIds.every((id, idx) => id === sortedIds[idx]); - - if (!isSame && !cancelled) { - setLoadedImageIds(sortedIds); - } - }; - - checkAndSort(); - - return () => { cancelled = true; }; - }, [loadedImageIds, orderBySliceLocation]); const viewports = []; @@ -762,11 +860,17 @@ function App() { flexDirection: "column", gridRow: Math.floor(i / viewportGrid.cols) + 1, gridColumn: (i % viewportGrid.cols) + 1, - height: "100%", // <-- Add this line - // Use visibility instead of display to hide but keep size/layout + height: "100%", visibility: isVisible ? "visible" : "hidden", pointerEvents: isVisible ? "auto" : "none", + // Remove outline, use only boxShadow for indicator +boxShadow: activeViewport === i + ? "0 0 0 4px #1976d2, 0 0 16px 4px #1976d2cc" + : undefined, +cursor: "pointer", +zIndex: activeViewport === i ? 10 : 1, }} + onClick={() => setActiveViewport(i)} onDoubleClick={() => handleDoubleClick(i)} > + ); } @@ -1100,11 +1255,16 @@ function App() { return ( -
+
{/* Side menu toggle button */} + {/* Add more menu items here if needed */}
@@ -1387,9 +1553,10 @@ function App() { width: "100%", height: "100%", display: "grid", + margin: 3, gridTemplateRows: `repeat(${viewportGrid.rows}, 1fr)`, gridTemplateColumns: `repeat(${viewportGrid.cols}, 1fr)`, - gap: "2px", + gap: "5px", // Increase gap for better visibility zIndex: 1, }} > @@ -1620,6 +1787,7 @@ function setupTools() { stackToolGroup.addTool(PlanarFreehandROITool.toolName, { calculateStats: false }); stackToolGroup.addTool(PlanarFreehandContourSegmentationTool.toolName, {}); stackToolGroup.addTool(SculptorTool.toolName, {}); + stackToolGroup.addTool(ReferenceLinesTool.toolName); // StackScrollTool mouse wheel binding stackToolGroup.setToolActive(StackScrollTool.toolName, { bindings: [{ mouseButton: MouseBindings.Wheel }], @@ -1643,6 +1811,7 @@ function setupTools() { volumeToolGroup.addTool(PlanarFreehandROITool.toolName, { calculateStats: false }); volumeToolGroup.addTool(PlanarFreehandContourSegmentationTool.toolName, {}); volumeToolGroup.addTool(SculptorTool.toolName, {}); + volumeToolGroup.addTool(ReferenceLinesTool.toolName); //volumeToolGroup.addTool(CrosshairsTool.toolName, {}); }