NrrdTools Usage Guide
Copper3D
NrrdTools— Medical Image Segmentation Annotation Engine
NrrdTools is the core annotation engine of Copper3D. It manages multi-layer mask volumes, a layered canvas pipeline, drawing tools, undo/redo history, channel color customization, and keyboard shortcuts — all on top of a Three.js medical image viewer.
Internal Architecture:
NrrdToolsis a Facade class that uses composition (no inheritance). It composes:
CanvasState— pure state container (nrrd_states, gui_states, protectedData, cursorPage, annotationCallbacks, keyboardSettings)DrawToolCore— tool orchestration, event routing (composesCanvasState+RenderingUtils)RenderingUtils— rendering / slice-buffer helpers (compositeAllLayers, renderSliceToCanvas, etc.)LayerChannelManager— layer/channel/sphere-type management and channel color customizationSliceRenderPipeline— slice setup, canvas rendering, mask reload, canvas flipDataLoader— NRRD slice loading, legacy mask loading, NIfTI voxel loadingThe old 3-level inheritance chain (
NrrdTools → DrawToolCore → CommToolsData) has been fully replaced.CommToolsDatahas been deleted. All modules communicate viaToolContext(shared state) andPick<ToolHost, ...>type aliases (host method dependencies). The public API documented below is unchanged.
Table of Contents
- Quick Start
- Constructor & Initialization
- Data Loading
- Render Loop Integration
- Drawing Setup
- Layer & Channel Management
- Channel Color Customization
- Undo / Redo
- Keyboard Shortcuts
- Display & Canvas Control
- Reading State & Diagnostics
- Advanced Scenarios
- Vue 3 Integration Pattern
- Type Reference
1. Quick Start
import * as Copper from 'copper3d';
// 1. Mount the tool on a container div
const container = document.getElementById('viewer') as HTMLDivElement;
const nrrdTools = new Copper.NrrdTools(container);
// 2. After NRRD images are loaded via Copper scene:
nrrdTools.reset();
nrrdTools.setAllSlices(allSlices); // allSlices from Copper scene loader
// 3. Hook drawing callbacks
nrrdTools.drag({ getSliceNum: (index, contrastIndex) => {
console.log('Slice changed to:', index);
}});
nrrdTools.draw({
getMaskData: (sliceData, layerId, channelId, sliceIndex, axis, width, height, clearFlag) => {
// Called after every stroke, undo, redo — sync to backend here
console.log(`Layer ${layerId}, channel ${channelId}, slice ${sliceIndex} on ${axis}-axis`);
}
});
// 4. Register with the Copper render loop
scene.addPreRenderCallbackFunction(nrrdTools.start);import * as Copper from 'copper3d';
// 1. Mount the tool on a container div
const container = document.getElementById('viewer') as HTMLDivElement;
const nrrdTools = new Copper.NrrdTools(container);
// 2. After NRRD images are loaded via Copper scene:
nrrdTools.reset();
nrrdTools.setAllSlices(allSlices); // allSlices from Copper scene loader
// 3. Hook drawing callbacks
nrrdTools.drag({ getSliceNum: (index, contrastIndex) => {
console.log('Slice changed to:', index);
}});
nrrdTools.draw({
getMaskData: (sliceData, layerId, channelId, sliceIndex, axis, width, height, clearFlag) => {
// Called after every stroke, undo, redo — sync to backend here
console.log(`Layer ${layerId}, channel ${channelId}, slice ${sliceIndex} on ${axis}-axis`);
}
});
// 4. Register with the Copper render loop
scene.addPreRenderCallbackFunction(nrrdTools.start);2. Constructor & Initialization
Signature
new Copper.NrrdTools(container: HTMLDivElement, options?: { layers?: string[] })new Copper.NrrdTools(container: HTMLDivElement, options?: { layers?: string[] })| Parameter | Type | Default | Description |
|---|---|---|---|
container | HTMLDivElement | required | The DOM element that will host all annotation canvases |
options.layers | string[] | ["layer1","layer2","layer3"] | Named layers to create. Each layer gets its own MaskVolume and canvas |
Example: Single-layer (minimal)
const nrrdTools = new Copper.NrrdTools(document.getElementById('viewer') as HTMLDivElement);const nrrdTools = new Copper.NrrdTools(document.getElementById('viewer') as HTMLDivElement);Example: Custom layer set
// For a 4-layer segmentation workflow:
// layer1 = Tumour, layer2 = Edema, layer3 = Necrosis, layer4 = Enhancement
const nrrdTools = new Copper.NrrdTools(container, {
layers: ['layer1', 'layer2', 'layer3', 'layer4']
});// For a 4-layer segmentation workflow:
// layer1 = Tumour, layer2 = Edema, layer3 = Necrosis, layer4 = Enhancement
const nrrdTools = new Copper.NrrdTools(container, {
layers: ['layer1', 'layer2', 'layer3', 'layer4']
});Important: The layer list you pass here must match what your backend and UI expect. Adding or removing layers later requires re-instantiation.
Optional display panel
Attach a panel element to show current slice index in the viewer:
const slicePanel = document.getElementById('slice-index-panel') as HTMLDivElement;
nrrdTools.setDisplaySliceIndexPanel(slicePanel);const slicePanel = document.getElementById('slice-index-panel') as HTMLDivElement;
nrrdTools.setDisplaySliceIndexPanel(slicePanel);Optional GUI (dat.GUI)
If your project uses dat.GUI, wire up the built-in control panel:
import GUI from 'lil-gui'; // or 'dat.gui'
const gui = new GUI();
nrrdTools.setupGUI(gui as any);import GUI from 'lil-gui'; // or 'dat.gui'
const gui = new GUI();
nrrdTools.setupGUI(gui as any);3. Data Loading
3.1 Loading NRRD / NIfTI image slices
This is the entry point that initializes all MaskVolume instances to the correct voxel dimensions. Must be called after Copper has loaded and decoded the NRRD files.
// Reset state from previous case
nrrdTools.reset();
// allSlices is the array of decoded NRRD slice objects returned by Copper's loader
nrrdTools.setAllSlices(allSlices);// Reset state from previous case
nrrdTools.reset();
// allSlices is the array of decoded NRRD slice objects returned by Copper's loader
nrrdTools.setAllSlices(allSlices);After
setAllSlices()returns, you may safely call all layer/channel/color APIs. Calling color APIs beforesetAllSlices()will silently fail (no MaskVolume exists yet).
3.2 Loading existing mask data (NIfTI)
If the user has previously saved annotations, reload them into the volumes:
// layerVoxels maps each layer ID to a Uint8Array of raw NIfTI voxels
const layerVoxels = new Map<string, Uint8Array>([
['layer1', layer1Uint8Array],
['layer2', layer2Uint8Array],
]);
nrrdTools.setMasksFromNIfTI(layerVoxels);// layerVoxels maps each layer ID to a Uint8Array of raw NIfTI voxels
const layerVoxels = new Map<string, Uint8Array>([
['layer1', layer1Uint8Array],
['layer2', layer2Uint8Array],
]);
nrrdTools.setMasksFromNIfTI(layerVoxels);With a loading progress bar:
const loadingBar = { value: 0 }; // must be a reactive object with .value
nrrdTools.setMasksFromNIfTI(layerVoxels, loadingBar);const loadingBar = { value: 0 }; // must be a reactive object with .value
nrrdTools.setMasksFromNIfTI(layerVoxels, loadingBar);Scenario: Loading a saved case
async function loadCase(caseDetail: ICaseDetail) {
const layerVoxels = new Map<string, Uint8Array>();
// layer1 — always load from NIfTI if it exists
if (Number(caseDetail.output.mask_layer1_nii_size) > 0) {
const voxels = await fetchNiftiVoxels(caseDetail.output.mask_layer1_nii_path!);
if (voxels) layerVoxels.set('layer1', voxels);
}
// layer2 — same pattern
if (Number(caseDetail.output.mask_layer2_nii_size) > 0) {
const voxels = await fetchNiftiVoxels(caseDetail.output.mask_layer2_nii_path!);
if (voxels) layerVoxels.set('layer2', voxels);
}
if (layerVoxels.size > 0) {
nrrdTools.setMasksFromNIfTI(layerVoxels);
}
}async function loadCase(caseDetail: ICaseDetail) {
const layerVoxels = new Map<string, Uint8Array>();
// layer1 — always load from NIfTI if it exists
if (Number(caseDetail.output.mask_layer1_nii_size) > 0) {
const voxels = await fetchNiftiVoxels(caseDetail.output.mask_layer1_nii_path!);
if (voxels) layerVoxels.set('layer1', voxels);
}
// layer2 — same pattern
if (Number(caseDetail.output.mask_layer2_nii_size) > 0) {
const voxels = await fetchNiftiVoxels(caseDetail.output.mask_layer2_nii_path!);
if (voxels) layerVoxels.set('layer2', voxels);
}
if (layerVoxels.size > 0) {
nrrdTools.setMasksFromNIfTI(layerVoxels);
}
}4. Render Loop Integration
NrrdTools.start is a function property that must be called every frame to refresh the annotation overlay on top of the Three.js rendered CT/MRI slice.
With Copper scene
// Register once after initialization
const callbackId = scene.addPreRenderCallbackFunction(nrrdTools.start);
// Unregister on teardown (e.g., Vue component unmount)
scene.removePreRenderCallbackFunction(callbackId);// Register once after initialization
const callbackId = scene.addPreRenderCallbackFunction(nrrdTools.start);
// Unregister on teardown (e.g., Vue component unmount)
scene.removePreRenderCallbackFunction(callbackId);Manual render loop
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
nrrdTools.start();
}
animate();function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
nrrdTools.start();
}
animate();5. Drawing Setup
5.1 drag() — Slice navigation
Enables dragging to scroll through CT/MRI slices. Call once before adding to the render loop.
nrrdTools.drag({
showNumber: true, // optional: show slice number overlay
getSliceNum: (sliceIndex, contrastIndex) => { // optional: callback on slice change
console.log('Now viewing slice:', sliceIndex);
updateUISliceDisplay(sliceIndex);
}
});nrrdTools.drag({
showNumber: true, // optional: show slice number overlay
getSliceNum: (sliceIndex, contrastIndex) => { // optional: callback on slice change
console.log('Now viewing slice:', sliceIndex);
updateUISliceDisplay(sliceIndex);
}
});5.2 draw() — Annotation callbacks
Hooks into the drawing events. The getMaskData callback is the primary integration point for syncing annotations to a backend.
nrrdTools.draw({
// Called after every draw stroke, undo, redo, or clear
getMaskData: (
sliceData: Uint8Array, // Raw voxel data for this slice (label values 0-8)
layerId: string, // Which layer was modified, e.g. "layer1"
channelId: number, // Active channel (1-8)
sliceIndex: number, // Index along the current axis
axis: 'x' | 'y' | 'z', // Current viewing axis
width: number, // Slice width in voxels
height: number, // Slice height in voxels
clearFlag?: boolean // true if the user cleared the slice
) => {
sendSliceToBackend({ sliceData, layerId, channelId, sliceIndex, axis, width, height, clearFlag });
},
// Called when the user clears an entire layer volume
onClearLayerVolume: (layerId: string) => {
console.log(`Layer ${layerId} fully cleared`);
notifyBackendLayerCleared(layerId);
},
// Called when the 3D sphere annotation tool places a sphere (sphere mode)
//
// sphereOrigin: [mouseX, mouseY, sliceIndex] — center on z-axis view
// sphereRadius: radius in pixels (1-50)
//
// Note: Sphere data does NOT write to layer MaskVolume.
// It is rendered as a visual overlay via the dedicated sphereMaskVolume.
getSphereData: (sphereOrigin: number[], sphereRadius: number) => {
console.log('Sphere placed at', sphereOrigin, 'radius', sphereRadius);
sendSphereToBackend({ sphereOrigin, sphereRadius });
},
// Called when the calculator sphere positions are updated (calculator mode)
//
// Each sphere type maps to a specific channel on layer1:
// tumour → channel 1
// ribcage → channel 3
// skin → channel 4
// nipple → channel 2
//
// Origins are ICommXYZ: { x: [mx, my, slice], y: [...], z: [...] }
// representing sphere center coordinates on all 3 axis views.
// null if that sphere type has not been placed yet.
getCalculateSpherePositionsData: (
tumourOrigin, skinOrigin, ribOrigin, nippleOrigin, axis
) => {
runAISegmentation({ tumourOrigin, skinOrigin, ribOrigin, nippleOrigin, axis });
},
});nrrdTools.draw({
// Called after every draw stroke, undo, redo, or clear
getMaskData: (
sliceData: Uint8Array, // Raw voxel data for this slice (label values 0-8)
layerId: string, // Which layer was modified, e.g. "layer1"
channelId: number, // Active channel (1-8)
sliceIndex: number, // Index along the current axis
axis: 'x' | 'y' | 'z', // Current viewing axis
width: number, // Slice width in voxels
height: number, // Slice height in voxels
clearFlag?: boolean // true if the user cleared the slice
) => {
sendSliceToBackend({ sliceData, layerId, channelId, sliceIndex, axis, width, height, clearFlag });
},
// Called when the user clears an entire layer volume
onClearLayerVolume: (layerId: string) => {
console.log(`Layer ${layerId} fully cleared`);
notifyBackendLayerCleared(layerId);
},
// Called when the 3D sphere annotation tool places a sphere (sphere mode)
//
// sphereOrigin: [mouseX, mouseY, sliceIndex] — center on z-axis view
// sphereRadius: radius in pixels (1-50)
//
// Note: Sphere data does NOT write to layer MaskVolume.
// It is rendered as a visual overlay via the dedicated sphereMaskVolume.
getSphereData: (sphereOrigin: number[], sphereRadius: number) => {
console.log('Sphere placed at', sphereOrigin, 'radius', sphereRadius);
sendSphereToBackend({ sphereOrigin, sphereRadius });
},
// Called when the calculator sphere positions are updated (calculator mode)
//
// Each sphere type maps to a specific channel on layer1:
// tumour → channel 1
// ribcage → channel 3
// skin → channel 4
// nipple → channel 2
//
// Origins are ICommXYZ: { x: [mx, my, slice], y: [...], z: [...] }
// representing sphere center coordinates on all 3 axis views.
// null if that sphere type has not been placed yet.
getCalculateSpherePositionsData: (
tumourOrigin, skinOrigin, ribOrigin, nippleOrigin, axis
) => {
runAISegmentation({ tumourOrigin, skinOrigin, ribOrigin, nippleOrigin, axis });
},
});5.3 SphereTool — 3D Sphere Placement & Distance Calculator
The SphereTool provides unified sphere placement with 4 sphere types controlled by gui_states.mode.activeSphereType. The active type determines which origin and color are used. The old separate "Calculator" mode has been merged into SphereTool.
Sphere Types & Channel Mapping
Each sphere type maps to a specific channel on layer1. Switch between types using gui_states.mode.activeSphereType:
| Sphere Type | Channel | Default Color | activeSphereType value |
|---|---|---|---|
| tumour | 1 | #10b981 (Emerald) | "tumour" (default) |
| nipple | 2 | #f43f5e (Rose) | "nipple" |
| ribcage | 3 | #3b82f6 (Blue) | "ribcage" |
| skin | 4 | #fbbf24 (Amber) | "skin" |
These mappings are exported as SPHERE_CHANNEL_MAP and SPHERE_LABELS from tools/SphereTool.ts. (SPHERE_COLORS was removed — colors are now derived dynamically from each volume's colorMap.)
Switching Sphere Type
Use the public API — do NOT mutate gui_states.mode.activeSphereType directly, as setActiveSphereType() also updates brush/fill color as a side-effect:
// Set active sphere type (also updates fillColor / brushColor)
nrrdTools.setActiveSphereType('nipple'); // channel 2, Rose #f43f5e
nrrdTools.setActiveSphereType('tumour'); // channel 1, Emerald #10b981
nrrdTools.setActiveSphereType('skin'); // channel 4, Amber #fbbf24
nrrdTools.setActiveSphereType('ribcage'); // channel 3, Blue #3b82f6
// Read current type
const type = nrrdTools.getActiveSphereType();
// → 'tumour' | 'skin' | 'nipple' | 'ribcage'// Set active sphere type (also updates fillColor / brushColor)
nrrdTools.setActiveSphereType('nipple'); // channel 2, Rose #f43f5e
nrrdTools.setActiveSphereType('tumour'); // channel 1, Emerald #10b981
nrrdTools.setActiveSphereType('skin'); // channel 4, Amber #fbbf24
nrrdTools.setActiveSphereType('ribcage'); // channel 3, Blue #3b82f6
// Read current type
const type = nrrdTools.getActiveSphereType();
// → 'tumour' | 'skin' | 'nipple' | 'ribcage'Interaction Flow
Sphere mode activated (gui_states.mode.sphere = true, keyboard shortcut: 'q'):
├─ Shift key DISABLED (no draw mode)
├─ Crosshair toggle allowed (S key)
│
├─ Left-click DOWN → SphereTool.onSphereClick(e)
│ ├─ record origin for current activeSphereType
│ ├─ convert origin to all 3 axes (setUpSphereOrigins)
│ ├─ enable crosshair at click position
│ └─ draw calculator sphere preview
│ Then DrawToolCore sets activeWheelMode = 'sphere'
│ (EventRouter's permanent wheel handler dispatches to sphereWheel)
│
├─ Scroll wheel (while holding) → adjust radius [1, 50]
│
└─ Left-click UP → SphereTool.onSpherePointerUp()
├─ write all placed spheres to sphereMaskVolume
├─ refresh sphere canvas overlay
├─ fire onSphereChanged callback
└─ fire onCalculatorPositionsChanged callback
Then DrawToolCore sets activeWheelMode = 'zoom'Sphere mode activated (gui_states.mode.sphere = true, keyboard shortcut: 'q'):
├─ Shift key DISABLED (no draw mode)
├─ Crosshair toggle allowed (S key)
│
├─ Left-click DOWN → SphereTool.onSphereClick(e)
│ ├─ record origin for current activeSphereType
│ ├─ convert origin to all 3 axes (setUpSphereOrigins)
│ ├─ enable crosshair at click position
│ └─ draw calculator sphere preview
│ Then DrawToolCore sets activeWheelMode = 'sphere'
│ (EventRouter's permanent wheel handler dispatches to sphereWheel)
│
├─ Scroll wheel (while holding) → adjust radius [1, 50]
│
└─ Left-click UP → SphereTool.onSpherePointerUp()
├─ write all placed spheres to sphereMaskVolume
├─ refresh sphere canvas overlay
├─ fire onSphereChanged callback
└─ fire onCalculatorPositionsChanged callback
Then DrawToolCore sets activeWheelMode = 'zoom'SphereMaskVolume
Sphere 3D data is stored in a dedicated MaskVolume (nrrd_states.sphereMaskVolume), separate from the layer draw mask volumes. This prevents sphere overlay data from polluting layer1's annotations.
- Created in
setAllSlices()(same dimensions as CT volume) - Cleared in
reset()(when switching cases)
Note: Currently sphere data does NOT write to layer1's MaskVolume. The channel mapping and
sphereMaskVolumeare reserved for future integration.
Scenario: Sphere mode with AI backend (distance calculation)
nrrdTools.draw({
// Called on every sphere placement — receives all 4 sphere origins
getCalculateSpherePositionsData: (tumour, skin, rib, nipple, axis) => {
// Each origin is { x: [mx, my, slice], y: [...], z: [...] } or null
if (tumour && skin && rib && nipple) {
aiBackend.runSegmentation({ tumour, skin, rib, nipple, axis });
}
},
// Also called — receives the current sphere's origin and radius
getSphereData: (origin, radius) => {
console.log('Sphere placed at', origin, 'radius', radius);
},
});nrrdTools.draw({
// Called on every sphere placement — receives all 4 sphere origins
getCalculateSpherePositionsData: (tumour, skin, rib, nipple, axis) => {
// Each origin is { x: [mx, my, slice], y: [...], z: [...] } or null
if (tumour && skin && rib && nipple) {
aiBackend.runSegmentation({ tumour, skin, rib, nipple, axis });
}
},
// Also called — receives the current sphere's origin and radius
getSphereData: (origin, radius) => {
console.log('Sphere placed at', origin, 'radius', radius);
},
});Programmatic Sphere Placement (Backend → Frontend)
When the backend returns sphere coordinates (e.g., from AI detection), use setCalculateDistanceSphere() to place them without user interaction. This method replicates the full mouse-down → mouse-up flow internally:
// Backend returns sphere data for a case
interface BackendSphereData {
type: 'tumour' | 'skin' | 'nipple' | 'ribcage';
x: number; // X in unscaled image space
y: number; // Y in unscaled image space
sliceIndex: number; // Target slice index
}
// Place each sphere programmatically
function applySphereFromBackend(nrrdTools: Copper.NrrdTools, spheres: BackendSphereData[]) {
for (const s of spheres) {
nrrdTools.setCalculateDistanceSphere(s.x, s.y, s.sliceIndex, s.type);
}
}
// Example: place a tumour sphere at (120, 95) on slice 42
nrrdTools.setCalculateDistanceSphere(120, 95, 42, 'tumour');// Backend returns sphere data for a case
interface BackendSphereData {
type: 'tumour' | 'skin' | 'nipple' | 'ribcage';
x: number; // X in unscaled image space
y: number; // Y in unscaled image space
sliceIndex: number; // Target slice index
}
// Place each sphere programmatically
function applySphereFromBackend(nrrdTools: Copper.NrrdTools, spheres: BackendSphereData[]) {
for (const s of spheres) {
nrrdTools.setCalculateDistanceSphere(s.x, s.y, s.sliceIndex, s.type);
}
}
// Example: place a tumour sphere at (120, 95) on slice 42
nrrdTools.setCalculateDistanceSphere(120, 95, 42, 'tumour');Internally, setCalculateDistanceSphere performs:
- Sets
sphereRadius = 5and navigates to the target slice - Records
sphereOriginon all 3 axes (viacrosshairTool.setUpSphereOrigins) - Deep-copies the origin into the type-specific field (e.g.,
tumourSphereOrigin) - Draws the sphere preview on canvas (
drawCalculatorSphere) - Writes all placed spheres to
sphereMaskVolume(writeAllCalculatorSpheresToVolume) - Re-renders the sphere overlay (
refreshSphereCanvas)
Note: Coordinates (
x,y) are in unscaled image space. The method automatically appliessizeFactorscaling internally.
5.4 enableContrastDragEvents() — Windowing
Enable Ctrl+drag to adjust window/level (brightness/contrast):
nrrdTools.enableContrastDragEvents((step: number, towards: 'horizental' | 'vertical') => {
// step: magnitude of drag movement
// towards: direction of drag
console.log(`Contrast adjusted: ${towards} ${step}`);
});nrrdTools.enableContrastDragEvents((step: number, towards: 'horizental' | 'vertical') => {
// step: magnitude of drag movement
// towards: direction of drag
console.log(`Contrast adjusted: ${towards} ${step}`);
});6. Layer & Channel Management
NrrdTools supports multi-layer annotation (e.g., tumour, edema, necrosis) and multi-channel within each layer (e.g., different anatomical structures painted in different colors).
6.1 Active Layer & Channel
// Switch the active layer (where new strokes go)
nrrdTools.setActiveLayer('layer2');
// Switch the active channel within the current layer
nrrdTools.setActiveChannel(3); // channel 3 = blue by default
// Query current state
const currentLayer = nrrdTools.getActiveLayer(); // → 'layer2'
const currentChannel = nrrdTools.getActiveChannel(); // → 3// Switch the active layer (where new strokes go)
nrrdTools.setActiveLayer('layer2');
// Switch the active channel within the current layer
nrrdTools.setActiveChannel(3); // channel 3 = blue by default
// Query current state
const currentLayer = nrrdTools.getActiveLayer(); // → 'layer2'
const currentChannel = nrrdTools.getActiveChannel(); // → 36.2 Layer Visibility
Toggle layers on/off in the composite canvas:
// Hide layer2 (e.g., edema) while keeping layer1 visible
nrrdTools.setLayerVisible('layer2', false);
nrrdTools.setLayerVisible('layer1', true);
// Query visibility
const layer2Visible = nrrdTools.isLayerVisible('layer2'); // → false
// Get all at once
const visibilityMap = nrrdTools.getLayerVisibility();
// → { layer1: true, layer2: false, layer3: true, layer4: true }// Hide layer2 (e.g., edema) while keeping layer1 visible
nrrdTools.setLayerVisible('layer2', false);
nrrdTools.setLayerVisible('layer1', true);
// Query visibility
const layer2Visible = nrrdTools.isLayerVisible('layer2'); // → false
// Get all at once
const visibilityMap = nrrdTools.getLayerVisibility();
// → { layer1: true, layer2: false, layer3: true, layer4: true }Scenario: Eye-button toggle in UI
function onToggleLayerEye(layerId: string) {
const current = nrrdTools.isLayerVisible(layerId);
nrrdTools.setLayerVisible(layerId, !current);
}function onToggleLayerEye(layerId: string) {
const current = nrrdTools.isLayerVisible(layerId);
nrrdTools.setLayerVisible(layerId, !current);
}6.3 Channel Visibility
Each layer can independently show/hide its channels:
// Hide channel 2 in layer1 (e.g., secondary annotation color)
nrrdTools.setChannelVisible('layer1', 2, false);
// Show it again
nrrdTools.setChannelVisible('layer1', 2, true);
// Query
const ch2Visible = nrrdTools.isChannelVisible('layer1', 2); // → true
// Get all channel visibility for all layers
const allChannelVis = nrrdTools.getChannelVisibility();
// → { layer1: { 1: true, 2: false, 3: true, ... }, layer2: { 1: true, ... }, ... }// Hide channel 2 in layer1 (e.g., secondary annotation color)
nrrdTools.setChannelVisible('layer1', 2, false);
// Show it again
nrrdTools.setChannelVisible('layer1', 2, true);
// Query
const ch2Visible = nrrdTools.isChannelVisible('layer1', 2); // → true
// Get all channel visibility for all layers
const allChannelVis = nrrdTools.getChannelVisibility();
// → { layer1: { 1: true, 2: false, 3: true, ... }, layer2: { 1: true, ... }, ... }Scenario: Showing only the selected channel
function isolateChannel(layerId: string, targetChannel: number, totalChannels = 8) {
for (let ch = 1; ch <= totalChannels; ch++) {
nrrdTools.setChannelVisible(layerId, ch, ch === targetChannel);
}
}
// Show only channel 1 in layer1
isolateChannel('layer1', 1);
// Restore all
for (let ch = 1; ch <= 8; ch++) {
nrrdTools.setChannelVisible('layer1', ch, true);
}function isolateChannel(layerId: string, targetChannel: number, totalChannels = 8) {
for (let ch = 1; ch <= totalChannels; ch++) {
nrrdTools.setChannelVisible(layerId, ch, ch === targetChannel);
}
}
// Show only channel 1 in layer1
isolateChannel('layer1', 1);
// Restore all
for (let ch = 1; ch <= 8; ch++) {
nrrdTools.setChannelVisible('layer1', ch, true);
}6.4 Checking if a layer has annotations
if (nrrdTools.hasLayerData('layer2')) {
console.log('layer2 has annotated voxels — saving...');
await saveLayer('layer2');
} else {
console.log('layer2 is empty, skipping save');
}if (nrrdTools.hasLayerData('layer2')) {
console.log('layer2 has annotated voxels — saving...');
await saveLayer('layer2');
} else {
console.log('layer2 is empty, skipping save');
}6.5 Per-Layer Opacity
Each layer can have an independent opacity value (0.1–1.0), in addition to the existing global opacity (setOpacity()). The final rendering opacity is multiplicative: globalAlpha × layerOpacity[layerId].
// Set layer2's opacity to 50%
nrrdTools.setLayerOpacity('layer2', 0.5);
// Read a layer's opacity
const opacity = nrrdTools.getLayerOpacity('layer2'); // → 0.5
// Read all layer opacities
const opacityMap = nrrdTools.getLayerOpacityMap();
// → { layer1: 1.0, layer2: 0.5, layer3: 1.0, layer4: 1.0 }// Set layer2's opacity to 50%
nrrdTools.setLayerOpacity('layer2', 0.5);
// Read a layer's opacity
const opacity = nrrdTools.getLayerOpacity('layer2'); // → 0.5
// Read all layer opacities
const opacityMap = nrrdTools.getLayerOpacityMap();
// → { layer1: 1.0, layer2: 0.5, layer3: 1.0, layer4: 1.0 }Rendering Behavior: Per-layer opacity is applied during
compositeAllLayers()viamasterCtx.globalAlpha. It does not modify the voxel data — it only affects display compositing.Default: All layers start with opacity
1.0(fully opaque relative to globalAlpha).
Scenario: Dimming background layers while annotating
// Dim layer1 and layer3, keep layer2 fully visible
nrrdTools.setLayerOpacity('layer1', 0.3);
nrrdTools.setLayerOpacity('layer3', 0.3);
nrrdTools.setLayerOpacity('layer2', 1.0);// Dim layer1 and layer3, keep layer2 fully visible
nrrdTools.setLayerOpacity('layer1', 0.3);
nrrdTools.setLayerOpacity('layer3', 0.3);
nrrdTools.setLayerOpacity('layer2', 1.0);UI Slider Integration
The getSliderMeta("layerAlpha") method returns the slider metadata for the active layer’s opacity:
const meta = nrrdTools.getSliderMeta('layerAlpha');
// → { value: 0.5, min: 0.1, max: 1, step: 0.01 }const meta = nrrdTools.getSliderMeta('layerAlpha');
// → { value: 0.5, min: 0.1, max: 1, step: 0.01 }This is used by OperationCtl.vue’s "Layer Alpha" slider radio option.
7. Channel Color Customization
Each layer has its own independent color map. Changing channel 3's color in layer1 does not affect layer2.
⚠️ Timing Requirement — Must call AFTER setAllSlices()
Color APIs require MaskVolume instances to exist inside protectedData.maskData.volumes. Calling them before setAllSlices() completes results in a silent no-op — the method hits the guard check, emits console.warn, and returns without doing anything. There is no thrown exception and no visible error.
// ❌ WRONG — onFinishedCopperInit fires when the Copper3D renderer is ready,
// but no NRRD images have been loaded yet.
// protectedData.maskData.volumes["layer1"] is undefined at this point.
// setChannelColor silently returns with only a console.warn.
const onFinishedCopperInit = (copperInitData) => {
nrrdTools.value = copperInitData.nrrdTools;
nrrdTools.value.setChannelColor('layer1', 1, { r: 25, g: 0, b: 0, a: 255 }); // ← no-op
};
// ✅ CORRECT — call after images are fully loaded and setAllSlices() has run
const handleAllImagesLoaded = (res) => {
// setAllSlices() is called internally before this callback fires
nrrdTools.value.setChannelColor('layer1', 1, { r: 25, g: 0, b: 0, a: 255 }); // ← works
};// ❌ WRONG — onFinishedCopperInit fires when the Copper3D renderer is ready,
// but no NRRD images have been loaded yet.
// protectedData.maskData.volumes["layer1"] is undefined at this point.
// setChannelColor silently returns with only a console.warn.
const onFinishedCopperInit = (copperInitData) => {
nrrdTools.value = copperInitData.nrrdTools;
nrrdTools.value.setChannelColor('layer1', 1, { r: 25, g: 0, b: 0, a: 255 }); // ← no-op
};
// ✅ CORRECT — call after images are fully loaded and setAllSlices() has run
const handleAllImagesLoaded = (res) => {
// setAllSlices() is called internally before this callback fires
nrrdTools.value.setChannelColor('layer1', 1, { r: 25, g: 0, b: 0, a: 255 }); // ← works
};The internal guard inside LayerChannelManager.setChannelColor() (and all other color APIs) is:
const volume = this.protectedData.maskData.volumes[layerId];
if (!volume) {
console.warn(`setChannelColor: unknown layer "${layerId}"`); // fires silently
return; // exits — color is never applied
}const volume = this.protectedData.maskData.volumes[layerId];
if (!volume) {
console.warn(`setChannelColor: unknown layer "${layerId}"`); // fires silently
return; // exits — color is never applied
}MaskVolumes are created (with correct voxel dimensions) only inside DataLoader.setAllSlices(). Until that runs, the volumes map is empty.
Default Channel Colors
| Channel | Color | Hex |
|---|---|---|
| 1 | Emerald (Tumour) | #10b981 |
| 2 | Rose (Edema) | #f43f5e |
| 3 | Blue (Necrosis) | #3b82f6 |
| 4 | Amber (Enhancement) | #fbbf24 |
| 5 | Fuchsia (Vessel) | #d946ef |
| 6 | Cyan (Additional) | #06b6d4 |
| 7 | Orange (Auxiliary) | #f97316 |
| 8 | Violet (Extended) | #8b5cf6 |
7.1 Set a single channel color
// RGBAColor: { r, g, b, a } — all values 0-255
nrrdTools.setChannelColor('layer1', 3, { r: 255, g: 128, b: 0, a: 255 }); // → orange// RGBAColor: { r, g, b, a } — all values 0-255
nrrdTools.setChannelColor('layer1', 3, { r: 255, g: 128, b: 0, a: 255 }); // → orange7.2 Batch-set multiple channel colors (recommended)
One reloadMasksFromVolume() call instead of N calls — better performance:
nrrdTools.setChannelColors('layer1', {
1: { r: 255, g: 80, b: 80, a: 255 }, // Soft red
2: { r: 80, g: 180, b: 255, a: 255 }, // Sky blue
3: { r: 255, g: 230, b: 50, a: 255 }, // Golden yellow
});nrrdTools.setChannelColors('layer1', {
1: { r: 255, g: 80, b: 80, a: 255 }, // Soft red
2: { r: 80, g: 180, b: 255, a: 255 }, // Sky blue
3: { r: 255, g: 230, b: 50, a: 255 }, // Golden yellow
});7.3 Apply the same color to all layers
// Paint all layers' channel 1 in the same tumour color
nrrdTools.setAllLayersChannelColor(1, { r: 0, g: 220, b: 100, a: 255 });// Paint all layers' channel 1 in the same tumour color
nrrdTools.setAllLayersChannelColor(1, { r: 0, g: 220, b: 100, a: 255 });7.4 Read current colors
const rgba = nrrdTools.getChannelColor('layer2', 3);
// → { r: 255, g: 128, b: 0, a: 255 }
const hex = nrrdTools.getChannelHexColor('layer2', 3);
// → "#ff8000" — suitable for canvas fillStyle
const css = nrrdTools.getChannelCssColor('layer2', 3);
// → "rgba(255,128,0,1.00)" — suitable for Vue :style bindingconst rgba = nrrdTools.getChannelColor('layer2', 3);
// → { r: 255, g: 128, b: 0, a: 255 }
const hex = nrrdTools.getChannelHexColor('layer2', 3);
// → "#ff8000" — suitable for canvas fillStyle
const css = nrrdTools.getChannelCssColor('layer2', 3);
// → "rgba(255,128,0,1.00)" — suitable for Vue :style binding7.5 Reset colors
// Reset one channel in one layer
nrrdTools.resetChannelColors('layer1', 3);
// Reset all channels in one layer
nrrdTools.resetChannelColors('layer1');
// Reset everything across all layers
nrrdTools.resetChannelColors();// Reset one channel in one layer
nrrdTools.resetChannelColors('layer1', 3);
// Reset all channels in one layer
nrrdTools.resetChannelColors('layer1');
// Reset everything across all layers
nrrdTools.resetChannelColors();Scenario: Color picker integration
// User picks a new color in the UI color picker
function onColorPicked(hexColor: string) {
// Parse hex to RGBA (simple implementation)
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
const activeLayer = nrrdTools.getActiveLayer();
const activeChannel = nrrdTools.getActiveChannel();
nrrdTools.setChannelColor(activeLayer, activeChannel, { r, g, b, a: 255 });
}// User picks a new color in the UI color picker
function onColorPicked(hexColor: string) {
// Parse hex to RGBA (simple implementation)
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
const activeLayer = nrrdTools.getActiveLayer();
const activeChannel = nrrdTools.getActiveChannel();
nrrdTools.setChannelColor(activeLayer, activeChannel, { r, g, b, a: 255 });
}Scenario: Vue UI reactivity after color change
After calling setChannelColor(), the canvas re-renders automatically. But Vue computed properties that show the color need a manual nudge:
// In your composable or component
const colorVersion = ref(0);
const channelCssColor = computed(() => {
colorVersion.value; // subscribe to version
return nrrdTools.value?.getChannelCssColor(activeLayer.value, activeChannel.value) ?? '#00ff00';
});
function refreshColors() {
colorVersion.value++; // forces recompute
}
// After setting color:
nrrdTools.value.setChannelColor('layer1', 2, { r: 255, g: 0, b: 0, a: 255 });
refreshColors();// In your composable or component
const colorVersion = ref(0);
const channelCssColor = computed(() => {
colorVersion.value; // subscribe to version
return nrrdTools.value?.getChannelCssColor(activeLayer.value, activeChannel.value) ?? '#00ff00';
});
function refreshColors() {
colorVersion.value++; // forces recompute
}
// After setting color:
nrrdTools.value.setChannelColor('layer1', 2, { r: 255, g: 0, b: 0, a: 255 });
refreshColors();8. Undo / Redo
NrrdTools maintains a per-layer undo stack (max 50 entries). Every completed brush stroke pushes a delta snapshot.
// Undo the last stroke on the active layer
nrrdTools.undo();
// Redo the last undone stroke
nrrdTools.redo();// Undo the last stroke on the active layer
nrrdTools.undo();
// Redo the last undone stroke
nrrdTools.redo();Scenario: Keyboard shortcut binding
window.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'z') nrrdTools.undo();
if (e.ctrlKey && e.key === 'y') nrrdTools.redo();
});window.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'z') nrrdTools.undo();
if (e.ctrlKey && e.key === 'y') nrrdTools.redo();
});The built-in keyboard system also handles undo/redo (default:
zandywithout Ctrl). See Keyboard Shortcuts for configuring this.
9. Keyboard Shortcuts
Default bindings
| Action | Default Key |
|---|---|
| Draw mode | Shift (hold) |
| Undo | z |
| Redo | y |
| Contrast adjust | Ctrl / Meta (hold) |
| Crosshair | s |
| Sphere mode | q |
| Mouse wheel | Zoom |
Reading current settings
const settings = nrrdTools.getKeyboardSettings();
console.log(settings);
// {
// draw: 'Shift',
// undo: 'z',
// redo: 'y',
// contrast: ['Control', 'Meta'],
// crosshair: 's',
// mouseWheel: 'Scroll:Zoom'
// }const settings = nrrdTools.getKeyboardSettings();
console.log(settings);
// {
// draw: 'Shift',
// undo: 'z',
// redo: 'y',
// contrast: ['Control', 'Meta'],
// crosshair: 's',
// mouseWheel: 'Scroll:Zoom'
// }Customizing bindings
nrrdTools.setKeyboardSettings({
undo: 'u', // Remap undo to 'u'
mouseWheel: 'Scroll:Slice', // Mouse wheel scrolls slices instead of zooming
});nrrdTools.setKeyboardSettings({
undo: 'u', // Remap undo to 'u'
mouseWheel: 'Scroll:Slice', // Mouse wheel scrolls slices instead of zooming
});Scenario: Form input focus — suppressing shortcuts
When a user types in an input field, you don't want tool shortcuts firing:
inputElement.addEventListener('focus', () => {
nrrdTools.enterKeyboardConfig(); // Suppress all tool shortcuts
});
inputElement.addEventListener('blur', () => {
nrrdTools.exitKeyboardConfig(); // Restore shortcuts
});inputElement.addEventListener('focus', () => {
nrrdTools.enterKeyboardConfig(); // Suppress all tool shortcuts
});
inputElement.addEventListener('blur', () => {
nrrdTools.exitKeyboardConfig(); // Restore shortcuts
});Enabling / disabling contrast shortcut independently
// Disable only the contrast (window/level) keyboard shortcut
nrrdTools.setContrastShortcutEnabled(false);
// Check status
const isEnabled = nrrdTools.isContrastShortcutEnabled(); // → false
// Re-enable
nrrdTools.setContrastShortcutEnabled(true);// Disable only the contrast (window/level) keyboard shortcut
nrrdTools.setContrastShortcutEnabled(false);
// Check status
const isEnabled = nrrdTools.isContrastShortcutEnabled(); // → false
// Re-enable
nrrdTools.setContrastShortcutEnabled(true);10. Display & Canvas Control
Canvas size scaling
Set the base display size multiplier (1–8). Larger values use more GPU memory but give sharper annotations:
nrrdTools.setBaseDrawDisplayCanvasesSize(2); // 2× base resolutionnrrdTools.setBaseDrawDisplayCanvasesSize(2); // 2× base resolutionReading image metadata
// Voxel dimensions [width, height, depth]
const dims = nrrdTools.getCurrentImageDimension();
// → [512, 512, 256]
// Physical spacing (mm per voxel, from NRRD header)
const spacing = nrrdTools.getVoxelSpacing();
// → [0.488, 0.488, 1.0]
// World-space origin
const origin = nrrdTools.getSpaceOrigin();
// → [-125.0, -125.0, -127.5]
// Max slice count per axis
const maxSlices = nrrdTools.getMaxSliceNum();
// → [512, 512, 256] (one per axis)
// Current viewing state
const { currentSliceIndex, contrastIndex } = nrrdTools.getCurrentSlicesNumAndContrastNum();// Voxel dimensions [width, height, depth]
const dims = nrrdTools.getCurrentImageDimension();
// → [512, 512, 256]
// Physical spacing (mm per voxel, from NRRD header)
const spacing = nrrdTools.getVoxelSpacing();
// → [0.488, 0.488, 1.0]
// World-space origin
const origin = nrrdTools.getSpaceOrigin();
// → [-125.0, -125.0, -127.5]
// Max slice count per axis
const maxSlices = nrrdTools.getMaxSliceNum();
// → [512, 512, 256] (one per axis)
// Current viewing state
const { currentSliceIndex, contrastIndex } = nrrdTools.getCurrentSlicesNumAndContrastNum();Accessing internal canvases
// The topmost interactive canvas (mouse/pen events target here)
const drawingCanvas = nrrdTools.getDrawingCanvas();
// The inner main-area container div (dynamically created by NrrdTools, NOT the original container passed to constructor)
const container = nrrdTools.getContainer();
// Raw NrrdState (all internal state fields, grouped by semantic sub-objects)
const states = nrrdTools.getNrrdToolsSettings();
console.log(states.image.layers); // ["layer1", "layer2", ...]
console.log(states.image.dimensions); // [512, 512, 256]// The topmost interactive canvas (mouse/pen events target here)
const drawingCanvas = nrrdTools.getDrawingCanvas();
// The inner main-area container div (dynamically created by NrrdTools, NOT the original container passed to constructor)
const container = nrrdTools.getContainer();
// Raw NrrdState (all internal state fields, grouped by semantic sub-objects)
const states = nrrdTools.getNrrdToolsSettings();
console.log(states.image.layers); // ["layer1", "layer2", ...]
console.log(states.image.dimensions); // [512, 512, 256]Accessing full mask data
// All MaskVolume data structures
const maskData = nrrdTools.getMaskData();
// maskData.volumes is Record<string, MaskVolume>// All MaskVolume data structures
const maskData = nrrdTools.getMaskData();
// maskData.volumes is Record<string, MaskVolume>11. Reading State & Diagnostics
// Full snapshot of current state
const state = nrrdTools.getNrrdToolsSettings();
// Active layer / channel
const layer = nrrdTools.getActiveLayer();
const channel = nrrdTools.getActiveChannel();
// Visibility maps
const layerVis = nrrdTools.getLayerVisibility();
const channelVis = nrrdTools.getChannelVisibility();
// Check if any real annotation exists in a layer
const hasTumour = nrrdTools.hasLayerData('layer1');
// Get current channel color as hex
const hex = nrrdTools.getChannelHexColor('layer1', 1); // → "#10b981"
// Keyboard settings
const keys = nrrdTools.getKeyboardSettings();// Full snapshot of current state
const state = nrrdTools.getNrrdToolsSettings();
// Active layer / channel
const layer = nrrdTools.getActiveLayer();
const channel = nrrdTools.getActiveChannel();
// Visibility maps
const layerVis = nrrdTools.getLayerVisibility();
const channelVis = nrrdTools.getChannelVisibility();
// Check if any real annotation exists in a layer
const hasTumour = nrrdTools.hasLayerData('layer1');
// Get current channel color as hex
const hex = nrrdTools.getChannelHexColor('layer1', 1); // → "#10b981"
// Keyboard settings
const keys = nrrdTools.getKeyboardSettings();12. Advanced Scenarios
Scenario A: Complete multi-layer initialization from scratch
async function initAnnotationTool(container: HTMLDivElement, allSlices: any[]) {
// Create tool with 4 layers
const nrrdTools = new Copper.NrrdTools(container, {
layers: ['layer1', 'layer2', 'layer3', 'layer4']
});
// Attach GUI
nrrdTools.setupGUI(gui as any);
// Attach contrast drag
nrrdTools.enableContrastDragEvents((step, towards) => {
console.log('Windowing:', towards, step);
});
// Load image volume
nrrdTools.reset();
nrrdTools.setAllSlices(allSlices);
// Register callbacks
nrrdTools.drag({ getSliceNum: (idx) => updateSliceUI(idx) });
nrrdTools.draw({
getMaskData: (sliceData, layerId, channelId, sliceIndex, axis, w, h, clearFlag) => {
syncSliceToBackend({ sliceData, layerId, channelId, sliceIndex, axis, clearFlag });
},
onClearLayerVolume: (layerId) => {
notifyBackendCleared(layerId);
},
});
// Connect to render loop
scene.addPreRenderCallbackFunction(nrrdTools.start);
return nrrdTools;
}async function initAnnotationTool(container: HTMLDivElement, allSlices: any[]) {
// Create tool with 4 layers
const nrrdTools = new Copper.NrrdTools(container, {
layers: ['layer1', 'layer2', 'layer3', 'layer4']
});
// Attach GUI
nrrdTools.setupGUI(gui as any);
// Attach contrast drag
nrrdTools.enableContrastDragEvents((step, towards) => {
console.log('Windowing:', towards, step);
});
// Load image volume
nrrdTools.reset();
nrrdTools.setAllSlices(allSlices);
// Register callbacks
nrrdTools.drag({ getSliceNum: (idx) => updateSliceUI(idx) });
nrrdTools.draw({
getMaskData: (sliceData, layerId, channelId, sliceIndex, axis, w, h, clearFlag) => {
syncSliceToBackend({ sliceData, layerId, channelId, sliceIndex, axis, clearFlag });
},
onClearLayerVolume: (layerId) => {
notifyBackendCleared(layerId);
},
});
// Connect to render loop
scene.addPreRenderCallbackFunction(nrrdTools.start);
return nrrdTools;
}Scenario B: Load existing annotations and apply custom color scheme
async function loadAndColorCase(nrrdTools: Copper.NrrdTools, caseId: string) {
// 1. Load existing NIfTI masks
const masks = await fetchCaseMasks(caseId);
const layerVoxels = new Map<string, Uint8Array>();
for (const [layerId, data] of Object.entries(masks)) {
layerVoxels.set(layerId, data);
}
nrrdTools.setMasksFromNIfTI(layerVoxels);
// 2. Apply per-layer color schemes
nrrdTools.setChannelColors('layer1', {
1: { r: 0, g: 200, b: 80, a: 255 }, // Tumour core — green
2: { r: 255, g: 200, b: 0, a: 255 }, // Tumour ring — yellow
});
nrrdTools.setChannelColors('layer2', {
1: { r: 255, g: 60, b: 60, a: 200 }, // Edema — semi-transparent red
});
// 3. Set initial active state
nrrdTools.setActiveLayer('layer1');
nrrdTools.setActiveChannel(1);
}async function loadAndColorCase(nrrdTools: Copper.NrrdTools, caseId: string) {
// 1. Load existing NIfTI masks
const masks = await fetchCaseMasks(caseId);
const layerVoxels = new Map<string, Uint8Array>();
for (const [layerId, data] of Object.entries(masks)) {
layerVoxels.set(layerId, data);
}
nrrdTools.setMasksFromNIfTI(layerVoxels);
// 2. Apply per-layer color schemes
nrrdTools.setChannelColors('layer1', {
1: { r: 0, g: 200, b: 80, a: 255 }, // Tumour core — green
2: { r: 255, g: 200, b: 0, a: 255 }, // Tumour ring — yellow
});
nrrdTools.setChannelColors('layer2', {
1: { r: 255, g: 60, b: 60, a: 200 }, // Edema — semi-transparent red
});
// 3. Set initial active state
nrrdTools.setActiveLayer('layer1');
nrrdTools.setActiveChannel(1);
}Scenario C: Switching cases — full reset
async function switchCase(nrrdTools: Copper.NrrdTools, newCaseData: ICaseData) {
// Reset annotation volumes
nrrdTools.reset();
// Load new image slices (from Copper's scene loader result)
nrrdTools.setAllSlices(newCaseData.slices);
// Reset colors to defaults
nrrdTools.resetChannelColors();
// Make all layers and channels visible
for (const layerId of ['layer1', 'layer2', 'layer3', 'layer4']) {
nrrdTools.setLayerVisible(layerId, true);
for (let ch = 1; ch <= 8; ch++) {
nrrdTools.setChannelVisible(layerId, ch, true);
}
}
// Start fresh on layer1, channel1
nrrdTools.setActiveLayer('layer1');
nrrdTools.setActiveChannel(1);
// Load existing masks if available
if (newCaseData.hasExistingMasks) {
nrrdTools.setMasksFromNIfTI(newCaseData.layerVoxels);
}
}async function switchCase(nrrdTools: Copper.NrrdTools, newCaseData: ICaseData) {
// Reset annotation volumes
nrrdTools.reset();
// Load new image slices (from Copper's scene loader result)
nrrdTools.setAllSlices(newCaseData.slices);
// Reset colors to defaults
nrrdTools.resetChannelColors();
// Make all layers and channels visible
for (const layerId of ['layer1', 'layer2', 'layer3', 'layer4']) {
nrrdTools.setLayerVisible(layerId, true);
for (let ch = 1; ch <= 8; ch++) {
nrrdTools.setChannelVisible(layerId, ch, true);
}
}
// Start fresh on layer1, channel1
nrrdTools.setActiveLayer('layer1');
nrrdTools.setActiveChannel(1);
// Load existing masks if available
if (newCaseData.hasExistingMasks) {
nrrdTools.setMasksFromNIfTI(newCaseData.layerVoxels);
}
}Scenario D: Save workflow with dirty-layer detection
async function onSave(nrrdTools: Copper.NrrdTools, caseId: string) {
const layers = ['layer1', 'layer2', 'layer3', 'layer4'];
for (const layerId of layers) {
if (!nrrdTools.hasLayerData(layerId)) {
console.log(`${layerId} is empty — initializing blank NIfTI on backend`);
await initBlankLayerOnBackend(caseId, layerId);
} else {
console.log(`${layerId} has data — saving...`);
await saveLayerToBackend(caseId, layerId);
}
}
}async function onSave(nrrdTools: Copper.NrrdTools, caseId: string) {
const layers = ['layer1', 'layer2', 'layer3', 'layer4'];
for (const layerId of layers) {
if (!nrrdTools.hasLayerData(layerId)) {
console.log(`${layerId} is empty — initializing blank NIfTI on backend`);
await initBlankLayerOnBackend(caseId, layerId);
} else {
console.log(`${layerId} has data — saving...`);
await saveLayerToBackend(caseId, layerId);
}
}
}Scenario E: AI segmentation result — write back to volume
After receiving a segmentation result from a backend AI model, write it directly into a layer:
async function applyAIResult(nrrdTools: Copper.NrrdTools, layerId: string) {
// Fetch AI-generated NIfTI from backend
const response = await fetch(`/api/ai-result/${layerId}`);
const buffer = await response.arrayBuffer();
const voxels = new Uint8Array(buffer);
// Write directly into the target layer volume
const layerVoxels = new Map<string, Uint8Array>([[layerId, voxels]]);
nrrdTools.setMasksFromNIfTI(layerVoxels);
// Optionally switch to that layer so user sees the result
nrrdTools.setActiveLayer(layerId);
}async function applyAIResult(nrrdTools: Copper.NrrdTools, layerId: string) {
// Fetch AI-generated NIfTI from backend
const response = await fetch(`/api/ai-result/${layerId}`);
const buffer = await response.arrayBuffer();
const voxels = new Uint8Array(buffer);
// Write directly into the target layer volume
const layerVoxels = new Map<string, Uint8Array>([[layerId, voxels]]);
nrrdTools.setMasksFromNIfTI(layerVoxels);
// Optionally switch to that layer so user sees the result
nrrdTools.setActiveLayer(layerId);
}Scenario F: Dynamic keyboard remap from user settings
interface UserPreferences {
undoKey: string;
redoKey: string;
sliceScrollMode: 'zoom' | 'slice';
}
function applyUserKeyboardPreferences(nrrdTools: Copper.NrrdTools, prefs: UserPreferences) {
nrrdTools.setKeyboardSettings({
undo: prefs.undoKey,
redo: prefs.redoKey,
mouseWheel: prefs.sliceScrollMode === 'slice' ? 'Scroll:Slice' : 'Scroll:Zoom',
});
}
// Example: Apply on preference change
applyUserKeyboardPreferences(nrrdTools, {
undoKey: 'u',
redoKey: 'r',
sliceScrollMode: 'slice',
});interface UserPreferences {
undoKey: string;
redoKey: string;
sliceScrollMode: 'zoom' | 'slice';
}
function applyUserKeyboardPreferences(nrrdTools: Copper.NrrdTools, prefs: UserPreferences) {
nrrdTools.setKeyboardSettings({
undo: prefs.undoKey,
redo: prefs.redoKey,
mouseWheel: prefs.sliceScrollMode === 'slice' ? 'Scroll:Slice' : 'Scroll:Zoom',
});
}
// Example: Apply on preference change
applyUserKeyboardPreferences(nrrdTools, {
undoKey: 'u',
redoKey: 'r',
sliceScrollMode: 'slice',
});Scenario G: Read layer colors to build a color legend
function buildColorLegend(nrrdTools: Copper.NrrdTools, layerId: string) {
const legend = [];
for (let ch = 1; ch <= 8; ch++) {
legend.push({
channel: ch,
cssColor: nrrdTools.getChannelCssColor(layerId, ch),
hexColor: nrrdTools.getChannelHexColor(layerId, ch),
rgba: nrrdTools.getChannelColor(layerId, ch),
});
}
return legend;
}
// → [
// { channel: 1, cssColor: 'rgba(16,185,129,1.00)', hexColor: '#10b981', rgba: { r:16, g:185, b:129, a:255 } },
// { channel: 2, cssColor: 'rgba(244,63,94,1.00)', hexColor: '#f43f5e', rgba: { r:244, g:63, b:94, a:255 } },
// ...
// ]function buildColorLegend(nrrdTools: Copper.NrrdTools, layerId: string) {
const legend = [];
for (let ch = 1; ch <= 8; ch++) {
legend.push({
channel: ch,
cssColor: nrrdTools.getChannelCssColor(layerId, ch),
hexColor: nrrdTools.getChannelHexColor(layerId, ch),
rgba: nrrdTools.getChannelColor(layerId, ch),
});
}
return legend;
}
// → [
// { channel: 1, cssColor: 'rgba(16,185,129,1.00)', hexColor: '#10b981', rgba: { r:16, g:185, b:129, a:255 } },
// { channel: 2, cssColor: 'rgba(244,63,94,1.00)', hexColor: '#f43f5e', rgba: { r:244, g:63, b:94, a:255 } },
// ...
// ]Scenario H: Clearing annotations
There are different levels of clearing data depending on the use case:
// 1. Clear ALL data across ALL layers comprehensively (typically used when switching cases)
// This clears all volumes, undo histories, UI canvases, index parameters, and sphere data globally.
nrrdTools.reset();
// 2. Clear all annotations on the currently active layer across its entire 3D volume
// This clears the active layer's MaskVolume, clears its undo/redo history, and re-renders the canvas.
// It also triggers the `onClearLayerVolume` callback so you can notify the backend.
nrrdTools.clearActiveLayer();
// 3. Clear the mask ONLY on the currently viewed 2D slice for the active layer
// This removes annotations specifically on the current slice index and orientation.
// It records an undo operation to allow rollback.
nrrdTools.clearActiveSlice();// 1. Clear ALL data across ALL layers comprehensively (typically used when switching cases)
// This clears all volumes, undo histories, UI canvases, index parameters, and sphere data globally.
nrrdTools.reset();
// 2. Clear all annotations on the currently active layer across its entire 3D volume
// This clears the active layer's MaskVolume, clears its undo/redo history, and re-renders the canvas.
// It also triggers the `onClearLayerVolume` callback so you can notify the backend.
nrrdTools.clearActiveLayer();
// 3. Clear the mask ONLY on the currently viewed 2D slice for the active layer
// This removes annotations specifically on the current slice index and orientation.
// It records an undo operation to allow rollback.
nrrdTools.clearActiveSlice();13. Vue 3 Integration Pattern
The recommended pattern in Vue 3 is to distribute the NrrdTools instance via a Vue event emitter after NRRD loading completes, so any descendant component can access it.
LeftPanel (creator)
<script setup lang="ts">
import * as Copper from 'copper3d';
import emitter from '@/plugins/custom-emitter';
let nrrdTools: Copper.NrrdTools | undefined;
onMounted(() => {
nrrdTools = new Copper.NrrdTools(canvasContainer.value as HTMLDivElement, {
layers: ['layer1', 'layer2', 'layer3', 'layer4']
});
});
// After all NRRD files are loaded by the Copper scene:
function onAllImagesLoaded(allSlices: any[]) {
nrrdTools!.reset();
nrrdTools!.setAllSlices(allSlices);
nrrdTools!.drag({ getSliceNum: (idx) => emit('sliceChanged', idx) });
nrrdTools!.draw({
getMaskData: (sliceData, layerId, channelId, sliceIndex, axis, w, h, clearFlag) => {
emit('maskDataUpdated', { sliceData, layerId, channelId, sliceIndex, axis, w, h, clearFlag });
},
onClearLayerVolume: (layerId) => emit('layerCleared', layerId),
});
scene!.addPreRenderCallbackFunction(nrrdTools!.start);
// Broadcast to all other components
emitter.emit('Core:NrrdTools', nrrdTools!);
emitter.emit('Segmentation:FinishLoadAllCaseImages');
}
</script><script setup lang="ts">
import * as Copper from 'copper3d';
import emitter from '@/plugins/custom-emitter';
let nrrdTools: Copper.NrrdTools | undefined;
onMounted(() => {
nrrdTools = new Copper.NrrdTools(canvasContainer.value as HTMLDivElement, {
layers: ['layer1', 'layer2', 'layer3', 'layer4']
});
});
// After all NRRD files are loaded by the Copper scene:
function onAllImagesLoaded(allSlices: any[]) {
nrrdTools!.reset();
nrrdTools!.setAllSlices(allSlices);
nrrdTools!.drag({ getSliceNum: (idx) => emit('sliceChanged', idx) });
nrrdTools!.draw({
getMaskData: (sliceData, layerId, channelId, sliceIndex, axis, w, h, clearFlag) => {
emit('maskDataUpdated', { sliceData, layerId, channelId, sliceIndex, axis, w, h, clearFlag });
},
onClearLayerVolume: (layerId) => emit('layerCleared', layerId),
});
scene!.addPreRenderCallbackFunction(nrrdTools!.start);
// Broadcast to all other components
emitter.emit('Core:NrrdTools', nrrdTools!);
emitter.emit('Segmentation:FinishLoadAllCaseImages');
}
</script>Annotation Control Panel (consumer)
<script setup lang="ts">
import * as Copper from 'copper3d';
import emitter from '@/plugins/custom-emitter';
const nrrdTools = ref<Copper.NrrdTools>();
onMounted(() => {
emitter.on('Core:NrrdTools', (tools: Copper.NrrdTools) => {
nrrdTools.value = tools;
});
emitter.on('Segmentation:FinishLoadAllCaseImages', () => {
// Safe to call all APIs now — MaskVolume is initialized
syncStateFromTools();
});
});
function syncStateFromTools() {
if (!nrrdTools.value) return;
activeLayer.value = nrrdTools.value.getActiveLayer();
activeChannel.value = nrrdTools.value.getActiveChannel();
layerVisibility.value = nrrdTools.value.getLayerVisibility();
channelVisibility.value = nrrdTools.value.getChannelVisibility();
}
function onChannelColorPicked(hex: string) {
if (!nrrdTools.value) return;
const r = parseInt(hex.slice(1,3), 16);
const g = parseInt(hex.slice(3,5), 16);
const b = parseInt(hex.slice(5,7), 16);
nrrdTools.value.setChannelColor(activeLayer.value, activeChannel.value, { r, g, b, a: 255 });
refreshColors(); // bump colorVersion to re-trigger computed
}
</script><script setup lang="ts">
import * as Copper from 'copper3d';
import emitter from '@/plugins/custom-emitter';
const nrrdTools = ref<Copper.NrrdTools>();
onMounted(() => {
emitter.on('Core:NrrdTools', (tools: Copper.NrrdTools) => {
nrrdTools.value = tools;
});
emitter.on('Segmentation:FinishLoadAllCaseImages', () => {
// Safe to call all APIs now — MaskVolume is initialized
syncStateFromTools();
});
});
function syncStateFromTools() {
if (!nrrdTools.value) return;
activeLayer.value = nrrdTools.value.getActiveLayer();
activeChannel.value = nrrdTools.value.getActiveChannel();
layerVisibility.value = nrrdTools.value.getLayerVisibility();
channelVisibility.value = nrrdTools.value.getChannelVisibility();
}
function onChannelColorPicked(hex: string) {
if (!nrrdTools.value) return;
const r = parseInt(hex.slice(1,3), 16);
const g = parseInt(hex.slice(3,5), 16);
const b = parseInt(hex.slice(5,7), 16);
nrrdTools.value.setChannelColor(activeLayer.value, activeChannel.value, { r, g, b, a: 255 });
refreshColors(); // bump colorVersion to re-trigger computed
}
</script>14. Type Reference
// Color type used by all channel color APIs
interface RGBAColor {
r: number; // 0-255
g: number; // 0-255
b: number; // 0-255
a: number; // 0-255 (255 = fully opaque)
}
// Batch color map for setChannelColors()
type ChannelColorMap = Record<number, RGBAColor>; // key = channel number 1-8
// draw() options
interface IDrawOpts {
getMaskData?: (
sliceData: Uint8Array,
layerId: string,
channelId: number,
sliceIndex: number,
axis: 'x' | 'y' | 'z',
width: number,
height: number,
clearFlag?: boolean
) => void;
onClearLayerVolume?: (layerId: string) => void;
getSphereData?: (sphereOrigin: number[], sphereRadius: number) => void;
getCalculateSpherePositionsData?: (
tumourOrigin: ICommXYZ | null,
skinOrigin: ICommXYZ | null,
ribOrigin: ICommXYZ | null,
nippleOrigin: ICommXYZ | null,
axis: 'x' | 'y' | 'z'
) => void;
}
// drag() options
interface IDragOpts {
showNumber?: boolean;
getSliceNum?: (sliceIndex: number, contrastIndex: number) => void;
}
// Keyboard settings
interface IKeyBoardSettings {
draw: string;
undo: string;
redo: string;
contrast: string[]; // always an array, e.g. ["Control", "Meta"]
crosshair: string;
sphere: string;
mouseWheel: 'Scroll:Zoom' | 'Scroll:Slice';
}
// 3D coordinate (used in sphere position callbacks)
interface ICommXYZ {
x: number;
y: number;
z: number;
}// Color type used by all channel color APIs
interface RGBAColor {
r: number; // 0-255
g: number; // 0-255
b: number; // 0-255
a: number; // 0-255 (255 = fully opaque)
}
// Batch color map for setChannelColors()
type ChannelColorMap = Record<number, RGBAColor>; // key = channel number 1-8
// draw() options
interface IDrawOpts {
getMaskData?: (
sliceData: Uint8Array,
layerId: string,
channelId: number,
sliceIndex: number,
axis: 'x' | 'y' | 'z',
width: number,
height: number,
clearFlag?: boolean
) => void;
onClearLayerVolume?: (layerId: string) => void;
getSphereData?: (sphereOrigin: number[], sphereRadius: number) => void;
getCalculateSpherePositionsData?: (
tumourOrigin: ICommXYZ | null,
skinOrigin: ICommXYZ | null,
ribOrigin: ICommXYZ | null,
nippleOrigin: ICommXYZ | null,
axis: 'x' | 'y' | 'z'
) => void;
}
// drag() options
interface IDragOpts {
showNumber?: boolean;
getSliceNum?: (sliceIndex: number, contrastIndex: number) => void;
}
// Keyboard settings
interface IKeyBoardSettings {
draw: string;
undo: string;
redo: string;
contrast: string[]; // always an array, e.g. ["Control", "Meta"]
crosshair: string;
sphere: string;
mouseWheel: 'Scroll:Zoom' | 'Scroll:Slice';
}
// 3D coordinate (used in sphere position callbacks)
interface ICommXYZ {
x: number;
y: number;
z: number;
}Common type aliases
type LayerId = 'layer1' | 'layer2' | 'layer3' | 'layer4'; // or any string
type ChannelValue = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;type LayerId = 'layer1' | 'layer2' | 'layer3' | 'layer4'; // or any string
type ChannelValue = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;API Summary
| Category | Method | Description |
|---|---|---|
| Constructor | new NrrdTools(container, { layers }) | Create instance with optional layer config |
| Setup | drag(opts?) | Enable slice-drag navigation |
draw(opts?) | Bind annotation callbacks | |
setupGUI(gui) | Connect dat.GUI panel | |
enableContrastDragEvents(cb) | Enable Ctrl+drag windowing | |
setDisplaySliceIndexPanel(el) | Show slice index in a panel | |
setBaseDrawDisplayCanvasesSize(n) | Set canvas resolution multiplier (1-8) | |
| Data | reset() | Reset all volumes, undo histories, canvases, and sphere data |
clearActiveLayer() | Clear annotations and undo history for the currently active layer | |
clearActiveSlice() | Clear annotations exclusively on the currently viewed slice of the active layer | |
setAllSlices(slices) | Load NRRD slices, init MaskVolumes | |
setMasksFromNIfTI(map) | Load saved NIfTI voxel data | |
| Render | start | Frame callback — pass to render loop |
| Layer | setActiveLayer(id) | Switch drawing target layer |
getActiveLayer() | Read current layer | |
setLayerVisible(id, bool) | Toggle layer in composite view | |
isLayerVisible(id) | Query layer visibility | |
getLayerVisibility() | All layer visibility map | |
hasLayerData(id) | Check if layer has non-zero voxels | |
setLayerOpacity(id, opacity) | Set per-layer opacity (0.1–1.0), triggers re-render | |
getLayerOpacity(id) | Get opacity for a specific layer | |
getLayerOpacityMap() | Get all per-layer opacity values | |
| Sphere | setActiveSphereType(type) | Set active sphere type ('tumour'/'skin'/'nipple'/'ribcage'), updates brush color |
getActiveSphereType() | Read current sphere type | |
| Channel | setActiveChannel(ch) | Switch drawing target channel |
getActiveChannel() | Read current channel | |
setChannelVisible(id, ch, bool) | Toggle channel visibility in a layer | |
isChannelVisible(id, ch) | Query channel visibility | |
getChannelVisibility() | All channel visibility map | |
| Color | setChannelColor(id, ch, rgba) | Set one channel color in one layer |
setChannelColors(id, map) | Batch-set colors in one layer | |
setAllLayersChannelColor(ch, rgba) | Set one channel color across all layers | |
getChannelColor(id, ch) | Read RGBA | |
getChannelHexColor(id, ch) | Read Hex string | |
getChannelCssColor(id, ch) | Read CSS rgba() string | |
resetChannelColors(id?, ch?) | Reset to defaults | |
| Tool Mode | setMode(mode) | Switch tool mode: "pencil" / "brush" / "eraser" / "sphere" / "calculator" |
getMode() | Read current tool mode | |
isCalculatorActive() | Check if calculator (distance) mode is active | |
| Drawing | setOpacity(value) | Set mask overlay opacity [0.1, 1] |
getOpacity() | Read current opacity | |
setBrushSize(size) | Set brush/eraser size [5, 50], updates cursor | |
getBrushSize() | Read current brush size | |
setPencilColor(hex) | Set pencil stroke color (hex string) | |
getPencilColor() | Read current pencil color | |
| Contrast | setWindowHigh(value) | Set window high (image contrast), call finishWindowAdjustment() after drag |
setWindowLow(value) | Set window low (image center) | |
finishWindowAdjustment() | Repaint all contrast slices after drag ends | |
getSliderMeta(key) | Get slider min/max/step/value for UI config ("globalAlpha", "layerAlpha", "brushAndEraserSize", etc.) | |
| Actions | executeAction(action) | Run named action: "undo", "redo", "clearActiveSliceMask", "clearActiveLayerMask", "resetZoom", "downloadCurrentMask" |
| Navigation | setSliceOrientation(axis) | Switch viewing axis "x" / "y" / "z" |
setCalculateDistanceSphere(x, y, slice, type) | Programmatically place a calculator sphere (simulates full click flow: record origin → draw → write to volume) | |
| History | undo() | Undo last stroke |
redo() | Redo last undone stroke | |
| Keyboard | setKeyboardSettings(partial) | Remap shortcuts |
getKeyboardSettings() | Read current bindings | |
enterKeyboardConfig() | Suppress all shortcuts | |
exitKeyboardConfig() | Restore shortcuts | |
setContrastShortcutEnabled(bool) | Enable/disable contrast key | |
isContrastShortcutEnabled() | Query contrast key state | |
| Inspect | getCurrentImageDimension() | [w, h, d] voxel dims |
getVoxelSpacing() | Physical mm spacing | |
getSpaceOrigin() | World-space origin | |
getMaxSliceNum() | Max slice index per axis | |
getCurrentSlicesNumAndContrastNum() | Current slice & contrast index | |
getMaskData() | Raw IMaskData object | |
getNrrdToolsSettings() | Full NrrdState snapshot (grouped sub-objects: image, view, interaction, sphere, flags) | |
getContainer() | Host HTMLElement | |
getDrawingCanvas() | Top-layer HTMLCanvasElement |