basic reference line functions
This commit is contained in:
+255
-86
@@ -60,6 +60,7 @@ const MOUSE_BUTTONS = [
|
||||
|
||||
|
||||
|
||||
// App definintion
|
||||
function App() {
|
||||
const elementRef = useRef<HTMLDivElement>(null)
|
||||
const viewportRef = useRef<any>(null) // Store the viewport instance
|
||||
@@ -82,28 +83,106 @@ function App() {
|
||||
const renderingEngineId = "mainRenderingEngine";
|
||||
const renderingEngineRef = useRef<any>(null);
|
||||
|
||||
// Store imageIds globally after loading so they can be reused
|
||||
const [loadedImageIds, setLoadedImageIds] = useState<string[] | null>(null);
|
||||
const [availableStacks, setAvailableStacks] = useState<string[][]>([]);
|
||||
const [activeViewport, setActiveViewport] = useState<number | null>(0);
|
||||
const [viewportImageIds, setViewportImageIds] = useState<string[][]>(
|
||||
() => Array(MAX_VIEWPORTS).fill([])
|
||||
);
|
||||
const [selectedStackIdx, setSelectedStackIdx] = useState<number[]>(
|
||||
() => 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<any>[] = [];
|
||||
|
||||
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)}
|
||||
>
|
||||
<button
|
||||
@@ -997,7 +1101,7 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Position bar inside each viewport */}
|
||||
{viewportModes[i] === "stack" && loadedImageIds && (
|
||||
{viewportModes[i] === "stack" && viewportImageIds[i] && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -1008,7 +1112,7 @@ function App() {
|
||||
maxHeight: "100%",
|
||||
minHeight: 0,
|
||||
zIndex: 30,
|
||||
display: loadedImageIds.length > 0 ? "flex" : "none",
|
||||
display: viewportImageIds[i].length > 0 ? "flex" : "none",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
@@ -1032,7 +1136,7 @@ function App() {
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{loadedImageIds.map((id, idx) => {
|
||||
{viewportImageIds[i].map((id, idx) => {
|
||||
const loaded = !!metaData.get("imagePlaneModule", id);
|
||||
const renderingEngine = renderingEngineRef.current;
|
||||
let currentIdx = -1;
|
||||
@@ -1051,7 +1155,7 @@ function App() {
|
||||
key={id}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: `${100 / loadedImageIds.length}%`,
|
||||
height: `${100 / viewportImageIds[i].length}%`,
|
||||
background: isCurrent
|
||||
? "linear-gradient(to right, #ffeb3b 60%, #ff9800 100%)"
|
||||
: loaded
|
||||
@@ -1060,7 +1164,7 @@ function App() {
|
||||
opacity: loaded ? 0.95 : 0.4,
|
||||
transition: "background 0.3s, opacity 0.3s",
|
||||
borderBottom:
|
||||
idx < loadedImageIds.length - 1
|
||||
idx < viewportImageIds[i].length - 1
|
||||
? "1px solid #222"
|
||||
: "none",
|
||||
cursor: loaded ? "pointer" : "default",
|
||||
@@ -1092,6 +1196,57 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
zIndex: 20,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: "8px",
|
||||
background: "rgba(34,34,34,0.85)",
|
||||
borderRadius: "4px",
|
||||
padding: "2px 8px",
|
||||
}}
|
||||
>
|
||||
<select
|
||||
value={selectedStackIdx[i] || 0}
|
||||
onChange={e => handleLoadStack(i, Number(e.target.value))}
|
||||
style={{
|
||||
background: "#222",
|
||||
color: "#fff",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
padding: "2px 8px",
|
||||
fontSize: "13px",
|
||||
marginRight: "6px",
|
||||
}}
|
||||
>
|
||||
{availableStacks.map((stack, idx) => (
|
||||
<option key={idx} value={idx}>
|
||||
Stack {idx + 1} ({stack.length} images)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
style={{
|
||||
background: "#1976d2",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "3px",
|
||||
padding: "2px 12px",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
onClick={() => handleLoadStack(i, selectedStackIdx[i] || 0)}
|
||||
disabled={!availableStacks.length}
|
||||
>
|
||||
Load Stack
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1100,11 +1255,16 @@ function App() {
|
||||
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: "relative", width: "99vw", height: "99vh", background: "#111",
|
||||
margin: 0, padding: 0,
|
||||
overflow: "hidden", // Add this line
|
||||
}}>
|
||||
<div style={{
|
||||
position: "relative",
|
||||
width: "99vw",
|
||||
height: "99vh",
|
||||
background: "#111",
|
||||
padding: "12px", // <-- Add this line
|
||||
boxSizing: "border-box", // <-- Ensure padding doesn't shrink content
|
||||
background: "#222",
|
||||
// overflow: "hidden",
|
||||
}}>
|
||||
<div>
|
||||
{/* Side menu toggle button */}
|
||||
<button
|
||||
@@ -1264,6 +1424,12 @@ function App() {
|
||||
>
|
||||
{referenceLinesEnabled ? "Disable" : "Enable"} Reference Lines
|
||||
</button>
|
||||
<button
|
||||
onClick={sortViewportImageIdsBySliceLocation}
|
||||
style={{ margin: 8, padding: 8 }}
|
||||
>
|
||||
Sort All Viewports by Slice Location
|
||||
</button>
|
||||
{/* Add more menu items here if needed */}
|
||||
|
||||
</div>
|
||||
@@ -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, {});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user