diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index 3eb26a2..3b9fa5d 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -1152,11 +1152,119 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat } }; + const handleLoadStackOrthogonal = async (stackIdx: number) => { + const stackObj = availableStacks[stackIdx]; + if (!stackObj) return; + + setLoadingState("displayset"); + try { + const hasDeclaredGroups = stackObj.groupingSource === "declared" && (stackObj.seriesGroups || []).length > 0; + const shouldExpandMultiframe = !(hasDeclaredGroups && (stackObj.imageIds || []).length > 1); + const stack = shouldExpandMultiframe + ? await expandMultiframeImageIds(stackObj.imageIds || []) + : dedupeImageIds((stackObj.imageIds || []).map(normalizeToWadouriOrExternal)); + + await setLoadedImageIdsAndCacheMeta(stack, { force: true }); + const seriesGroups = hasDeclaredGroups + ? (stackObj.seriesGroups || []) + : await deriveSeriesGroupsFromImageIds(stack); + const primarySeries = seriesGroups[0]?.imageIds || stack; + + // 1. Set grid layout to 1x3 (1 row, 3 columns) + setViewportGrid({ rows: 1, cols: 3 }); + + // 2. Set viewport modes: first 3 viewports as 'volume', others default to 'stack' + setViewportModes(prev => { + const next = [...prev]; + next[0] = 'volume'; + next[1] = 'volume'; + next[2] = 'volume'; + return next; + }); + + // 3. Set viewport image IDs for first 3 viewports + setViewportImageIds(prev => { + const next = [...prev]; + next[0] = primarySeries; + next[1] = primarySeries; + next[2] = primarySeries; + return next; + }); + + // 4. Set selected stack index for first 3 viewports + setSelectedStackIdx(prev => { + const next = [...prev]; + next[0] = stackIdx; + next[1] = stackIdx; + next[2] = stackIdx; + return next; + }); + + // 5. Set series groups for first 3 viewports + setSeriesGroupsByViewport(prev => { + const next = [...prev]; + next[0] = seriesGroups || []; + next[1] = seriesGroups || []; + next[2] = seriesGroups || []; + return next; + }); + + // 6. Set selected series index for first 3 viewports + setSelectedSeriesIdxByViewport(prev => { + const next = [...prev]; + next[0] = 0; + next[1] = 0; + next[2] = 0; + return next; + }); + + // 7. Enable crosshairs + setCrossHairsEnabled(true); + + // 8. Cache imageIds and load metadata for this stack + await setLoadedImageIdsAndCacheMeta(primarySeries, { force: true }); + + // 9. After a short delay, set orientations to Axial, Sagittal, Coronal + setTimeout(() => { + const renderingEngine = renderingEngineRef.current; + if (renderingEngine) { + const orientations = ["axial", "sagittal", "coronal"]; + for (let i = 0; i < 3; i++) { + const vp = renderingEngine.getViewport(`CT_${i}`); + if (vp && typeof vp.setOrientation === "function") { + try { + vp.setOrientation(orientations[i]); + vp.render(); + } catch (e) { + appLogger.warn("Failed to set orthogonal orientation for viewport", i, e); + } + } + } + } + }, 500); + + } catch (err) { + appLogger.error("Failed to load stack orthogonally:", err); + } finally { + setLoadingState(null); + } + }; + const [crossHairsEnabled, setCrossHairsEnabled] = useState(false); const [crosshairsProjectionType, setCrosshairsProjectionType] = useState("None"); const [syncSlabThickness, setSyncSlabThickness] = useState(true); const [showCenterPoint, setShowCenterPoint] = useState(true); const slabThicknessSynchronizerRef = useRef(null); + const setupEpochRef = useRef(0); + const [setupInProgress, setSetupInProgress] = useState(false); + const setupInProgressRef = useRef(setupInProgress); + useEffect(() => { + setupInProgressRef.current = setupInProgress; + }, [setupInProgress]); + const syncSlabThicknessRef = useRef(syncSlabThickness); + useEffect(() => { + syncSlabThicknessRef.current = syncSlabThickness; + }, [syncSlabThickness]); const [referenceLinesEnabled, setReferenceLinesEnabled] = useState(true); const [showAdvancedMouseBindings, setShowAdvancedMouseBindings] = useState(false); @@ -2510,25 +2618,30 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat useEffect(() => { const mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID); - if (!mainToolGroup) return; + const STACK_TOOL_GROUP_ID = "TOOL_GROUP_stack_" + container_id; + const stackToolGroup = ToolGroupManager.getToolGroup(STACK_TOOL_GROUP_ID); - // List all annotation tools you use - [ - 'ArrowAnnotate', - 'NoLabelArrowAnnotate', - 'Length', - 'RectangleROI', - 'EllipticalROI', - 'PlanarFreehandROI', - 'Sculptor', - // Add more if needed - ].forEach(toolName => { - mainToolGroup.setToolConfiguration(toolName, { - color: '#00e676', // normal color - selectedColor: '#ffeb3b', // yellow for selected + [mainToolGroup, stackToolGroup].forEach(toolGroup => { + if (!toolGroup) return; + + // List all annotation tools you use + [ + 'ArrowAnnotate', + 'NoLabelArrowAnnotate', + 'Length', + 'RectangleROI', + 'EllipticalROI', + 'PlanarFreehandROI', + 'Sculptor', + // Add more if needed + ].forEach(toolName => { + toolGroup.setToolConfiguration(toolName, { + color: '#00e676', // normal color + selectedColor: '#ffeb3b', // yellow for selected + }); }); }); - }, [MAIN_TOOL_GROUP_ID]); + }, [MAIN_TOOL_GROUP_ID, container_id]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -2555,6 +2668,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat useEffect(() => { const mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID); + const STACK_TOOL_GROUP_ID = "TOOL_GROUP_stack_" + container_id; + const stackToolGroup = ToolGroupManager.getToolGroup(STACK_TOOL_GROUP_ID); if (!mainToolGroup) return; const modifierActive = shiftPressed || (altEnablesReferenceCursors && altPressed); @@ -2572,15 +2687,21 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat }); }); - if (!mainToolGroup.hasTool(ReferenceCursors.toolName)) { - mainToolGroup.addTool(ReferenceCursors.toolName); - } - mainToolGroup.setToolEnabled(ReferenceCursors.toolName); - mainToolGroup.setToolActive(ReferenceCursors.toolName); + [mainToolGroup, stackToolGroup].forEach(toolGroup => { + if (!toolGroup) return; + if (!toolGroup.hasTool(ReferenceCursors.toolName)) { + toolGroup.addTool(ReferenceCursors.toolName); + } + toolGroup.setToolEnabled(ReferenceCursors.toolName); + toolGroup.setToolActive(ReferenceCursors.toolName); + }); } else { - if (mainToolGroup.hasTool(ReferenceCursors.toolName)) { - mainToolGroup.setToolDisabled(ReferenceCursors.toolName); - } + [mainToolGroup, stackToolGroup].forEach(toolGroup => { + if (!toolGroup) return; + if (toolGroup.hasTool(ReferenceCursors.toolName)) { + toolGroup.setToolDisabled(ReferenceCursors.toolName); + } + }); } }, [ altPressed, @@ -2591,14 +2712,17 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat viewportImageIds, viewportModes, setLoadedImageIdsAndCacheMeta, + container_id, ]); // Helper to apply all mouse tool bindings (normal or ctrl) const applyMouseToolBindings = useCallback(() => { + const STACK_TOOL_GROUP_ID = "TOOL_GROUP_stack_" + container_id; // Apply to both stack and volume tool groups const mainToolGroup = cornerstoneTools.ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID); + const stackToolGroup = cornerstoneTools.ToolGroupManager.getToolGroup(STACK_TOOL_GROUP_ID); - [mainToolGroup].forEach(toolGroup => { + [mainToolGroup, stackToolGroup].forEach(toolGroup => { if (toolGroup) { // Set all tools to passive first, removing all bindings TOOL_OPTIONS.forEach(opt => { @@ -2699,7 +2823,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat } }); - }, [mouseToolBindings, ctrlMouseToolBindings, windowLevelMouseSensitivity]); + }, [mouseToolBindings, ctrlMouseToolBindings, windowLevelMouseSensitivity, container_id]); useEffect(() => { applyMouseToolBindings(); @@ -3579,16 +3703,24 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat }, [applyStackDrop, getDropZoneFromClientPoint, isPhoneLayout, mobileInteractionActive, resolveDropZone, viewportGrid]); useEffect(() => { - const setup = async () => { - appLogger.debug("setup") + setupEpochRef.current++; + const currentEpoch = setupEpochRef.current; + setupInProgressRef.current = true; + setSetupInProgress(true); - // 1. Initialize Cornerstone3D and tools ONCE - if (!running.current) { - running.current = true; - await csRenderInit(); - await csToolsInit( - ); - dicomImageLoaderInit(); + const setup = async () => { + appLogger.debug("setup", currentEpoch); + if (currentEpoch !== setupEpochRef.current) return; + try { + // 1. Initialize Cornerstone3D and tools ONCE + if (!running.current) { + running.current = true; + await csRenderInit(); + if (currentEpoch !== setupEpochRef.current) return; + await csToolsInit( + ); + if (currentEpoch !== setupEpochRef.current) return; + dicomImageLoaderInit(); // Register client-side image loader for external images (JPEG/PNG/blob/data) try { imageLoader.registerImageLoader('external', (imageId: string) => { @@ -3653,19 +3785,40 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat // --- In your setup function, use the correct tool group per viewport --- let mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID); if (!mainToolGroup) { - mainToolGroup = setupTools(MAIN_TOOL_GROUP_ID) + mainToolGroup = setupTools(MAIN_TOOL_GROUP_ID); } + const STACK_TOOL_GROUP_ID = "TOOL_GROUP_stack_" + container_id; + let stackToolGroup = ToolGroupManager.getToolGroup(STACK_TOOL_GROUP_ID); + if (!stackToolGroup) { + stackToolGroup = ToolGroupManager.createToolGroup(STACK_TOOL_GROUP_ID); + stackToolGroup.addTool(WindowLevelTool.toolName); + stackToolGroup.addTool(PanTool.toolName); + stackToolGroup.addTool(ZoomTool.toolName); + stackToolGroup.addTool(StackScrollTool.toolName, { loop: false }); + stackToolGroup.addTool(PlanarRotateTool.toolName); + stackToolGroup.addTool(LengthTool.toolName); + stackToolGroup.addTool(NoLabelArrowAnnotateTool.toolName, { getTextCallback: () => '' }); + stackToolGroup.addTool(ArrowAnnotateTool.toolName); + stackToolGroup.addTool(ProbeTool.toolName, { statsCalculator: () => null }); + stackToolGroup.addTool(RectangleROITool.toolName, { calculateStats: false, getTextCallback: () => '' }); + stackToolGroup.addTool(EllipticalROITool.toolName, { getTextCallback: () => '' }); + stackToolGroup.addTool(PlanarFreehandROITool.toolName, { calculateStats: false }); + stackToolGroup.addTool(SculptorTool.toolName, {}); + stackToolGroup.addTool(ReferenceLinesTool.toolName); + stackToolGroup.addTool(ReferenceCursors.toolName); + } const visibleVolumeViewports = viewportModes .slice(0, viewportGrid.rows * viewportGrid.cols) .filter(mode => mode === 'volume').length; // --- In your setup function, after tool group creation, enable/disable ReferenceLinesTool for each tool group --- - updateActiveReferenceLineViewport(mainToolGroup) + updateActiveReferenceLineViewport(mainToolGroup, stackToolGroup); // 6. Enable and configure each viewport for (let i = 0; i < MAX_VIEWPORTS; i++) { + if (currentEpoch !== setupEpochRef.current) return; let imageIds = viewportImageIds[i]; if (!imageIds || imageIds.length === 0) continue; @@ -3703,8 +3856,44 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const viewport = renderingEngine.getViewport(viewportId); if (!viewport) continue; + // Patch setSlabThickness to support robust synchronization + if (viewportModes[i] === 'volume' && typeof (viewport as any).setSlabThickness === 'function' && !(viewport as any)._setSlabThicknessPatched) { + const originalSetSlabThickness = (viewport as any).setSlabThickness; + (viewport as any).setSlabThickness = function (slabThickness: any, actorUIDs: any) { + originalSetSlabThickness.call(this, slabThickness, actorUIDs); + if (syncSlabThicknessRef.current && !setupInProgressRef.current) { + if ((this as any)._isSyncingSlabThickness) return; + (this as any)._isSyncingSlabThickness = true; + try { + const engine = renderingEngineRef.current; + if (engine) { + const otherViewports = engine.getViewports().filter(v => v.id !== this.id && (v as any).setSlabThickness); + otherViewports.forEach(otherVp => { + if ((otherVp as any).getSlabThickness() !== slabThickness) { + (otherVp as any)._isSyncingSlabThickness = true; + try { + (otherVp as any).setSlabThickness(slabThickness); + otherVp.render(); + } catch (err) { /* ignore */ } + finally { + (otherVp as any)._isSyncingSlabThickness = false; + } + } + }); + } + } finally { + (this as any)._isSyncingSlabThickness = false; + } + } + }; + (viewport as any)._setSlabThicknessPatched = true; + } + if (!element._resizeObserver) { const resizeObserver = new window.ResizeObserver(() => { + if (setupInProgressRef.current) { + return; + } if (element.offsetWidth > 0 && element.offsetHeight > 0) { renderingEngine.resize(); // Always get the latest viewport instance @@ -3751,12 +3940,16 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat // Do not attach DICOM-specific tools to external images mainToolGroup.removeViewports(renderingEngineId, viewportId); + if (stackToolGroup) { + stackToolGroup.removeViewports(renderingEngineId, viewportId); + } } catch (err) { appLogger.warn('Failed to render external image in viewport', err); } } else { try { - viewport.setStack(imageIds); + await viewport.setStack(imageIds); + if (currentEpoch !== setupEpochRef.current) return; } catch (err) { appLogger.warn('viewport.setStack failed', err); } @@ -3765,13 +3958,22 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat // CrosshairsTool is not applicable for stack viewports if (!crossHairsEnabled) { mainToolGroup.addViewport(viewportId, renderingEngineId); + if (stackToolGroup) { + stackToolGroup.removeViewports(renderingEngineId, viewportId); + } } else { mainToolGroup.removeViewports(renderingEngineId, viewportId); + if (stackToolGroup) { + stackToolGroup.addViewport(viewportId, renderingEngineId); + } } // Restore scroll, properties, etc. } else if (viewportModes[i] === 'volume') { mainToolGroup.addViewport(viewportId, renderingEngineId); + if (stackToolGroup) { + stackToolGroup.removeViewports(renderingEngineId, viewportId); + } //let sortedImageIds = imageIds; //if (imageIds && imageIds.length > 1) { @@ -3784,11 +3986,25 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat // }); //} + // Force cache metadata for the volume to prevent undefined extent errors + await setLoadedImageIdsAndCacheMeta(imageIds, { force: true }); + if (currentEpoch !== setupEpochRef.current) return; + // Use a unique volumeId per viewport const volumeId = `myVolumeId_${i}`; + try { + if (viewport.getActorUIDs().includes(volumeId)) { + viewport.removeActors([volumeId]); + } + } catch (e) { /* ignore */ } + try { + cornerstone3d.cache.removeVolumeLoadObject(volumeId); + } catch (e) { /* ignore */ } const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: imageIds }); + if (currentEpoch !== setupEpochRef.current) return; volume.load(); - viewport.setVolumes([{ volumeId }]); + await viewport.setVolumes([{ volumeId }]); + if (currentEpoch !== setupEpochRef.current) return; if (typeof viewport.getNumberOfSlices === "function") { const totalSlices = viewport.getNumberOfSlices(); const pending = pendingSliceIndexByViewportRef.current[i] || 0; @@ -3913,12 +4129,29 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat // 7. Apply mouse tool bindings applyMouseToolBindings(); - }; - // Use requestAnimationFrame to ensure DOM is painted before setup - const raf = requestAnimationFrame(() => { - setup(); - }); + if (currentEpoch === setupEpochRef.current) { + setupInProgressRef.current = false; + setSetupInProgress(false); + if (renderingEngineRef.current) { + renderingEngineRef.current.resize(); + renderingEngineRef.current.render(); + } + } + } catch (err) { + appLogger.error("setup failed", err); + } finally { + if (currentEpoch === setupEpochRef.current) { + setupInProgressRef.current = false; + setSetupInProgress(false); + } + } + }; + + // Use requestAnimationFrame to ensure DOM is painted before setup + const raf = requestAnimationFrame(() => { + setup(); + }); // Cleanup: disconnect ResizeObservers return () => { @@ -3990,15 +4223,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat // Manage Slab Thickness Synchronizer useEffect(() => { // Clean up previous synchronizer - if (slabThicknessSynchronizerRef.current) { - try { - slabThicknessSynchronizerRef.current.destroy(); - } catch (e) { - appLogger.warn("Failed to destroy slab thickness synchronizer", e); - } - slabThicknessSynchronizerRef.current = null; - } + try { + cornerstoneTools.SynchronizerManager.destroySynchronizer("slabThicknessSync"); + } catch (e) { /* ignore */ } + slabThicknessSynchronizerRef.current = null; + if (setupInProgress) return; if (!crossHairsEnabled || !syncSlabThickness) return; const renderingEngine = renderingEngineRef.current; @@ -4029,14 +4259,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat } return () => { - if (slabThicknessSynchronizerRef.current) { - try { - slabThicknessSynchronizerRef.current.destroy(); - } catch (e) { /* ignore */ } - slabThicknessSynchronizerRef.current = null; - } + try { + cornerstoneTools.SynchronizerManager.destroySynchronizer("slabThicknessSync"); + } catch (e) { /* ignore */ } + slabThicknessSynchronizerRef.current = null; }; - }, [syncSlabThickness, crossHairsEnabled, viewportGrid, viewportModes]); + }, [syncSlabThickness, crossHairsEnabled, viewportGrid, viewportModes, setupInProgress]); // Handler to apply a preset for a specific viewport const handleApplyPreset = useCallback((viewportIdx: number, center: number, width: number) => { @@ -4657,17 +4885,28 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat }); }, [orderBySliceLocation, availableStacks]); - function updateActiveReferenceLineViewport(mainToolGroup: cornerstoneTools.Types.IToolGroup) { + function updateActiveReferenceLineViewport( + mainToolGroup: cornerstoneTools.Types.IToolGroup, + stackToolGroup?: cornerstoneTools.Types.IToolGroup + ) { // Ensure the main tool group is defined (it won't be during setup) if (!mainToolGroup) return; + const STACK_TOOL_GROUP_ID = "TOOL_GROUP_stack_" + container_id; + const targetStackToolGroup = stackToolGroup || ToolGroupManager.getToolGroup(STACK_TOOL_GROUP_ID); const sourceViewportId = `CT_${activeViewport}` if (referenceLinesEnabled) { - mainToolGroup.setToolEnabled(ReferenceLinesTool.toolName) - mainToolGroup.setToolConfiguration(ReferenceLinesTool.toolName, { - sourceViewportId, - }) + [mainToolGroup, targetStackToolGroup].forEach(toolGroup => { + if (!toolGroup) return; + toolGroup.setToolEnabled(ReferenceLinesTool.toolName) + toolGroup.setToolConfiguration(ReferenceLinesTool.toolName, { + sourceViewportId, + }) + }); } else { - mainToolGroup.setToolDisabled(ReferenceLinesTool.toolName) + [mainToolGroup, targetStackToolGroup].forEach(toolGroup => { + if (!toolGroup) return; + toolGroup.setToolDisabled(ReferenceLinesTool.toolName) + }); } } @@ -4754,7 +4993,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat setActiveViewport(0); } - function truncateStack(lower: number, upper: number) { + async function truncateStack(lower: number, upper: number) { // Only allow when 1 viewport and 1 stack is loaded appLogger.debug("Truncating stack from", lower, "to", upper); appLogger.debug(stackOrderModified) @@ -4795,7 +5034,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat if (renderingEngine) { const viewport = renderingEngine.getViewport("CT_0"); if (viewport && typeof viewport.setStack === "function") { - viewport.setStack(truncatedImageIds); + await viewport.setStack(truncatedImageIds); viewport.setImageIdIndex?.(0); viewport.render?.(); } @@ -5127,10 +5366,21 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat if (state.modes?.[i] === "volume") { try { + // Force cache metadata for the volume to prevent undefined extent errors + await setLoadedImageIdsAndCacheMeta(importedImageIds, { force: true }); + const volumeId = `myVolumeId_${i}`; + try { + if (viewport.getActorUIDs().includes(volumeId)) { + viewport.removeActors([volumeId]); + } + } catch (e) { /* ignore */ } + try { + cornerstone3d.cache.removeVolumeLoadObject(volumeId); + } catch (e) { /* ignore */ } const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: importedImageIds }); volume.load(); - viewport.setVolumes([{ volumeId }]); + await viewport.setVolumes([{ volumeId }]); viewport.render(); } catch (e) { appLogger.warn("Failed to create/load volume for viewport", i, e); @@ -5143,7 +5393,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat setTimeout(() => { try { csUtilities.jumpToSlice(viewport.element, { imageIndex: state.volumeSliceIndices[i] }); } catch (e) { } }, 100); } } else { - try { if (viewport.setStack) { viewport.setStack(importedImageIds); viewport.render(); } } catch (e) { appLogger.warn('Failed to set stack for viewport', i, e); } + try { if (viewport.setStack) { await viewport.setStack(importedImageIds); viewport.render(); } } catch (e) { appLogger.warn('Failed to set stack for viewport', i, e); } if (state.stackPos && typeof state.stackPos[i] === "number" && typeof viewport.setImageIdIndex === "function") { try { if (csUtilities && csUtilities.jumpToSlice) csUtilities.jumpToSlice(viewport.element, { imageIndex: state.stackPos[i] }); else viewport.setImageIdIndex(state.stackPos[i]); } catch (e) { /* ignore */ } } @@ -7925,6 +8175,30 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat {isActiveViewportStack && (
ACTIVE
)} + {!invalid && ( + + )} {hideChromeForMobileInteraction && !invalid && (