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.
Layer ids are arbitrary strings 3.10.4
They do not have to be layerN:
const nrrdTools = new Copper.NrrdTools(container, {
layers: ['tumour', 'edema', 'necrosis', 'vessel']
});const nrrdTools = new Copper.NrrdTools(container, {
layers: ['tumour', 'edema', 'necrosis', 'vessel']
});Before 3.10.4 the undo stacks were pre-created for layer1 / layer2 / layer3 only, so a custom id either silently pushed onto another layer's history or threw on undo. Stacks are now created on demand, keyed by whatever id you pass.
Example: read-only reference viewer 3.11.1
Pass an empty list for a viewer that shows the image but has nothing to annotate into — a side-by-side reference panel, a prior study, a QA view:
const reference = new Copper.NrrdTools(container, { layers: [] });const reference = new Copper.NrrdTools(container, { layers: [] });Slice scrubbing, zoom, pan, window/level and the crosshair all work as usual. The drawing tools have no layer to target and skip their work rather than failing: setCurrentLayer() returns null and every caller checks it.
The rest of the pipeline follows the same rule 3.11.6: getVolumeForLayer() returns undefined without the "unknown layer, falling back to…" warning — there is no first layer to fall back to, and the warning was noise on a viewer that is working as designed — and reloadMasksFromVolume() returns early, since there is nothing to reload. An unknown layer id on a viewer that does have layers still warns and falls back, as before.
Pairs naturally with setMaskMirror (§6.7): a layerless viewer still composites, so it can show another viewer's masks over its own image.
Which one to reach for
layers: [] is a structural choice — this instance will never annotate. setAnnotationSuspended(true) (§5.6) is a temporary one on an instance that will, such as during loading. Use the flag if the user gets to draw later; use the empty list if they never do.
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).
Narrowed-axes loads are supported 3.10.0
setAllSlices reads its geometry (dimensions, spacing, space origin) from the shared Volume behind whichever plane is present, so slices loaded with axes: ["z"] work here too. The missing planes are extracted on demand the first time setSliceOrientation() asks for them.
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);Grid validation — registerNiftiMaskGrid() 3.10.0
setMasksFromNIfTI used to make any buffer fit: a longer one was truncated, a shorter one zero-padded. Either way a mask from the wrong grid rendered silently offset onto the wrong voxels, with no error anywhere. A refused mask is recoverable; a misplaced one is not.
A buffer is now checked against the loaded image's voxel grid, and refused when it does not match. The check needs the buffer's own NIfTI grid, which setMasksFromNIfTI has no second parameter to receive — so register it out of band, keyed by the buffer itself:
import { registerNiftiMaskGrid } from 'copper3d';
const { voxels, dims } = await fetchNiftiVoxels(path); // dims = header [x, y, z]
registerNiftiMaskGrid(voxels, dims); // before setMasksFromNIfTI
layerVoxels.set('layer1', voxels);
nrrdTools.setMasksFromNIfTI(layerVoxels);import { registerNiftiMaskGrid } from 'copper3d';
const { voxels, dims } = await fetchNiftiVoxels(path); // dims = header [x, y, z]
registerNiftiMaskGrid(voxels, dims); // before setMasksFromNIfTI
layerVoxels.set('layer1', voxels);
nrrdTools.setMasksFromNIfTI(layerVoxels);A buffer with no registered grid is refused
Not assumed to match. Every buffer you pass to setMasksFromNIfTI needs a registerNiftiMaskGrid call first, or that layer is skipped. Register each buffer as you decode it — the registry is keyed by buffer identity, so you can decode several layers before setMasksFromNIfTI is ever called.
A refused layer logs to console.error with both grids, calls notifyUser (see below), and is skipped; the other layers in the same Map still load.
notifyUser — surfacing engine messages in your UI 3.10.0
This package ships without a UI of its own, so it will not reach into your application's. NrrdTools.notifyUser is the seam: it defaults to a console write, and a host with a toast or a banner assigns its own after construction.
nrrdTools.notifyUser = (message, level) => {
// level: 'error' | 'warning' | 'info'
toast[level](message);
};nrrdTools.notifyUser = (message, level) => {
// level: 'error' | 'warning' | 'info'
toast[level](message);
};A refused mask is currently the only thing that uses it.
Scenario: Loading a saved case
import { registerNiftiMaskGrid } from 'copper3d';
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, dims } = await fetchNiftiVoxels(caseDetail.output.mask_layer1_nii_path!);
if (voxels) {
registerNiftiMaskGrid(voxels, dims);
layerVoxels.set('layer1', voxels);
}
}
// layer2 — same pattern
if (Number(caseDetail.output.mask_layer2_nii_size) > 0) {
const { voxels, dims } = await fetchNiftiVoxels(caseDetail.output.mask_layer2_nii_path!);
if (voxels) {
registerNiftiMaskGrid(voxels, dims);
layerVoxels.set('layer2', voxels);
}
}
if (layerVoxels.size > 0) {
nrrdTools.setMasksFromNIfTI(layerVoxels);
}
}import { registerNiftiMaskGrid } from 'copper3d';
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, dims } = await fetchNiftiVoxels(caseDetail.output.mask_layer1_nii_path!);
if (voxels) {
registerNiftiMaskGrid(voxels, dims);
layerVoxels.set('layer1', voxels);
}
}
// layer2 — same pattern
if (Number(caseDetail.output.mask_layer2_nii_size) > 0) {
const { voxels, dims } = await fetchNiftiVoxels(caseDetail.output.mask_layer2_nii_path!);
if (voxels) {
registerNiftiMaskGrid(voxels, dims);
layerVoxels.set('layer2', voxels);
}
}
if (layerVoxels.size > 0) {
nrrdTools.setMasksFromNIfTI(layerVoxels);
}
}3.3 Contrast series: skips, contrast index, and batched commits
A loaded case is a series of contrasts (phases). allSlicesArray holds every contrast; displaySlices holds the subset actually shown, built from the skip list. Two index spaces follow from that, and mixing them up is the usual bug here:
| Term | Addresses | Note |
|---|---|---|
skip index | position in the full contrast list | what addSkip / removeSkip / setSkips take |
contrastIndex | position in displaySlices — the selected subset | what setContrastIndex takes |
With three of five phases selected, valid contrastIndex values are 0..2. Callers that think in phase names must convert first.
nrrdTools.addSkip(2); // hide contrast 2
nrrdTools.removeSkip(2); // show it again
nrrdTools.setContrastIndex(1); // jump to the 2nd *displayed* contrastnrrdTools.addSkip(2); // hide contrast 2
nrrdTools.removeSkip(2); // show it again
nrrdTools.setContrastIndex(1); // jump to the 2nd *displayed* contrastBatched forms 3.10.0
Each of the calls above ends in a full display/canvas/mask refresh. In a loop — reconciling a whole phase selection at case-load time — every refresh but the last is immediately superseded, and the cost is real (measured at over a second of main-thread time for a five-phase series). The batched forms do the same work with one refresh:
// Many skip changes, one refresh
nrrdTools.setSkips([
{ index: 0, skip: false },
{ index: 1, skip: true },
{ index: 2, skip: true },
]);
// Skip changes + land on a contrast, one refresh
nrrdTools.commitSkipsAndContrast(
[{ index: 1, skip: true }, { index: 3, skip: true }],
0
);
// Whole case-load completion: swap the series, reconcile skips, land on a contrast
nrrdTools.commitSeriesLoad(allSlices, skipEntries, 0);// Many skip changes, one refresh
nrrdTools.setSkips([
{ index: 0, skip: false },
{ index: 1, skip: true },
{ index: 2, skip: true },
]);
// Skip changes + land on a contrast, one refresh
nrrdTools.commitSkipsAndContrast(
[{ index: 1, skip: true }, { index: 3, skip: true }],
0
);
// Whole case-load completion: swap the series, reconcile skips, land on a contrast
nrrdTools.commitSeriesLoad(allSlices, skipEntries, 0);commitSeriesLoad replaces the switchAllSlicesArrayData + setSkips + setContrastIndex sequence, which used to pay three full refreshes where one is enough. contrastIndex is clamped to the freshly rebuilt displaySlices, exactly like setContrastIndex does.
Which one to use
- A single user-driven toggle →
addSkip/removeSkip. - Several skip changes in one turn →
setSkips. - Skip changes plus a target contrast →
commitSkipsAndContrast. - A complete case/series load →
commitSeriesLoad.
Swapping the series without losing the view
// Resets slice index / zoom / pan
nrrdTools.switchAllSlicesArrayData(allSlices);
// Keeps current slice index, zoom and pan — for register/origin image switching
nrrdTools.switchSlicesPreservingView(allSlices);// Resets slice index / zoom / pan
nrrdTools.switchAllSlicesArrayData(allSlices);
// Keeps current slice index, zoom and pan — for register/origin image switching
nrrdTools.switchSlicesPreservingView(allSlices);3.4 Previewing one slice before the volume loads 3.11.6
A large volume takes seconds to arrive. showPreviewSlice puts one slice on screen immediately, positioned as slice index of the full stack, and lets the reader scrub while the rest is still downloading:
nrrdTools.showPreviewSlice(header, slice, index, async (target) => {
// `target` is the clamped slice the reader asked for — fetch and show it
const next = await fetchSinglePlane(caseId, target);
nrrdTools.showPreviewSlice(header, next, target, onSliceMove);
});
nrrdTools.isPreviewing(); // → true until a full volume takes overnrrdTools.showPreviewSlice(header, slice, index, async (target) => {
// `target` is the clamped slice the reader asked for — fetch and show it
const next = await fetchSinglePlane(caseId, target);
nrrdTools.showPreviewSlice(header, next, target, onSliceMove);
});
nrrdTools.isPreviewing(); // → true until a full volume takes over| Parameter | What it is |
|---|---|
header | The full volume's geometry (NrrdHeaderLike: dimensions, spacing, space_origin) — not the single plane's |
slice | A depth-1 slice triple built from one plane's voxels |
index | Which slice of the full stack this plane is |
onSliceMove | Receives the clamped target index for every slice move while previewing |
While previewing, slice moves do not move the display. Wheel, keys, drag and setSliceMoving are all intercepted and handed to onSliceMove with the clamped target instead — the displayed slice has depth 1, so moving it would paint blank. Fetching that plane and calling showPreviewSlice again is what advances the view.
Geometry is re-initialised only when header differs from the current one, so calling it repeatedly while scrubbing is cheap. It writes no mask data.
Handing over to the real volume:
const allSlices = await loadFullVolume(caseId);
nrrdTools.switchSlicesPreservingView(allSlices); // keeps the previewed index, ends previewingconst allSlices = await loadFullVolume(caseId);
nrrdTools.switchSlicesPreservingView(allSlices); // keeps the previewed index, ends previewingPreviewing also ends on setAllSlices() and reset().
Axial only
showPreviewSlice does not switch orientation — it shows a z plane. Sagittal and coronal need the full volume.
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);On-demand scenes, and registering early 4.1.2
This works on copperSceneOnDemond too. While anything is registered, an on-demand scene runs a requestAnimationFrame loop that calls the callbacks and nothing else; the 3D scene still renders only on demand. Before 4.1.2 it accepted the registration and never called it.
nrrdTools.start is one function for the life of the instance, so it may be registered before draw(). It does nothing until draw() has run and the first slice has sized the canvases. Before 4.1.2, registering before draw() registered an empty function that stayed empty.
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();Teardown — dispose() 3.11.2
scene.removePreRenderCallbackFunction(callbackId);
nrrdTools.dispose();
container.remove(); // your DOM, your callscene.removePreRenderCallbackFunction(callbackId);
nrrdTools.dispose();
container.remove(); // your DOM, your calldispose() releases what the instance holds outside its own DOM subtree, so a viewer that is torn down can actually be garbage-collected:
| Released | Why it matters |
|---|---|
| The event router's listeners | Its window blur listener closes over the router, which reaches the whole engine graph — one live listener pins the entire instance |
| A pending slice step | The requestAnimationFrame setSliceMoving coalesces into |
| The drawing-flag timer | The pending setIsDrawFalse timeout |
| Any mask-mirror link | Broken from both ends, so neither viewer keeps the other alive. Disposing a source detaches its mirrors and repaints them empty (see §6.7) |
The instance must not be used afterwards. It is safe to call more than once, so an unmount hook that may run twice needs no guard.
It does not remove your container or free the volumes
dispose() deliberately stops at the boundary of what NrrdTools attached elsewhere. The host element you passed to the constructor is yours to remove, and the MaskVolume buffers go when the instance does. Drop your reference to nrrdTools after calling it.
Vue 3:
import { onBeforeUnmount } from 'vue';
onBeforeUnmount(() => {
scene.removePreRenderCallbackFunction(callbackId);
nrrdTools.dispose();
});import { onBeforeUnmount } from 'vue';
onBeforeUnmount(() => {
scene.removePreRenderCallbackFunction(callbackId);
nrrdTools.dispose();
});An app that builds a viewer per case and never calls this leaks one engine graph per case — see Scene Resources for the scene-level counterpart.
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-255)
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-255)
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, via setMode("sphere")):
├─ 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, via setMode("sphere")):
├─ 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 SphereBrush & SphereEraser — 3D Volume Painting & Erasing
SphereBrush and SphereEraser are direct 3D volume tools that write/erase a sphere into the active layer's shared MaskVolume using the active channel label. Unlike the Sphere (Marker) tool which uses a separate sphereMaskVolume, these tools modify layer data directly and are fully compatible with the undo/redo system, NIfTI export, and GLTF generation.
Tool Modes
| Tool | Mode Value | Description |
|---|---|---|
| Sphere Brush | "sphereBrush" | Left-click to paint a 3D sphere into the active layer |
| Sphere Eraser | "sphereEraser" | Left-click to erase a 3D sphere (or drag to erase continuously). Only erases the active channel. |
Activating SphereBrush / SphereEraser
// Switch to Sphere Brush mode
nrrdTools.setMode('sphereBrush');
// Switch to Sphere Eraser mode
nrrdTools.setMode('sphereEraser');
// Read current mode
const mode = nrrdTools.getMode();
// → 'sphereBrush' | 'sphereEraser' | 'pencil' | 'brush' | 'eraser' | 'sphere' | 'calculator'// Switch to Sphere Brush mode
nrrdTools.setMode('sphereBrush');
// Switch to Sphere Eraser mode
nrrdTools.setMode('sphereEraser');
// Read current mode
const mode = nrrdTools.getMode();
// → 'sphereBrush' | 'sphereEraser' | 'pencil' | 'brush' | 'eraser' | 'sphere' | 'calculator'Radius Control
// Set sphere brush/eraser radius (clamped to [1, 50])
nrrdTools.setSphereBrushRadius(10);
// Read current radius
const radius = nrrdTools.getSphereBrushRadius(); // → 10// Set sphere brush/eraser radius (clamped to [1, 50])
nrrdTools.setSphereBrushRadius(10);
// Read current radius
const radius = nrrdTools.getSphereBrushRadius(); // → 10During interaction, the user can also adjust radius via the scroll wheel while holding the left mouse button.
Interaction Flow
SphereBrush mode (gui_states.mode.sphereBrush = true):
├─ Shift key DISABLED (no draw mode)
├─ Drag slice DISABLED
├─ Crosshair toggle allowed (S key)
│
├─ Left-click DOWN → record center, draw preview circle
│ Then activeWheelMode = 'sphereBrush'
│
├─ Scroll wheel (while holding) → adjust radius [1, 50]
│
└─ Left-click UP → write 3D sphere to MaskVolume
├─ push undo group (all affected Z-slices)
├─ re-render layer canvas
├─ fire onMaskChanged for ALL affected slices
└─ activeWheelMode = 'zoom'
SphereEraser mode (gui_states.mode.sphereEraser = true):
├─ Same constraints as SphereBrush
│
├─ Left-click DOWN → record center, capture before-snapshots
│
├─ Drag (optional) → continuously erase along path
│ └─ lazily captures before-snapshots for newly touched Z-slices
│
├─ Scroll wheel (while holding) → adjust radius [1, 50]
│
└─ Left-click UP → finalize erase
├─ push cumulative undo group (all affected Z-slices from entire drag)
├─ re-render layer canvas
├─ fire onMaskChanged for ALL affected slices
└─ activeWheelMode = 'zoom'SphereBrush mode (gui_states.mode.sphereBrush = true):
├─ Shift key DISABLED (no draw mode)
├─ Drag slice DISABLED
├─ Crosshair toggle allowed (S key)
│
├─ Left-click DOWN → record center, draw preview circle
│ Then activeWheelMode = 'sphereBrush'
│
├─ Scroll wheel (while holding) → adjust radius [1, 50]
│
└─ Left-click UP → write 3D sphere to MaskVolume
├─ push undo group (all affected Z-slices)
├─ re-render layer canvas
├─ fire onMaskChanged for ALL affected slices
└─ activeWheelMode = 'zoom'
SphereEraser mode (gui_states.mode.sphereEraser = true):
├─ Same constraints as SphereBrush
│
├─ Left-click DOWN → record center, capture before-snapshots
│
├─ Drag (optional) → continuously erase along path
│ └─ lazily captures before-snapshots for newly touched Z-slices
│
├─ Scroll wheel (while holding) → adjust radius [1, 50]
│
└─ Left-click UP → finalize erase
├─ push cumulative undo group (all affected Z-slices from entire drag)
├─ re-render layer canvas
├─ fire onMaskChanged for ALL affected slices
└─ activeWheelMode = 'zoom'Key Differences from Sphere (Marker)
| Feature | Sphere (Marker) | SphereBrush / SphereEraser |
|---|---|---|
| Storage target | sphereMaskVolume (separate overlay) | Active layer's MaskVolume (shared) |
| Channel behavior | Fixed per sphere type | Uses active channel |
| NIfTI/GLTF export | Not exported (overlay only) | Fully exported as 3D volume |
| Undo support | No | Yes (grouped multi-slice undo) |
| Drag support | No | SphereEraser supports drag-to-erase |
Scenario: AI result review with Sphere Eraser
// Load AI segmentation result
nrrdTools.setMasksFromNIfTI(layerVoxels);
// Switch to sphere eraser for quick cleanup
nrrdTools.setMode('sphereEraser');
nrrdTools.setSphereBrushRadius(8);
// User can now click or drag to erase incorrect regions
// All changes are undoable and will be correctly exported// Load AI segmentation result
nrrdTools.setMasksFromNIfTI(layerVoxels);
// Switch to sphere eraser for quick cleanup
nrrdTools.setMode('sphereEraser');
nrrdTools.setSphereBrushRadius(8);
// User can now click or drag to erase incorrect regions
// All changes are undoable and will be correctly exported5.5 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}`);
});5.6 Suspending annotation — read-only preview 3.10.0
nrrdTools.setAnnotationSuspended(true); // preview only
nrrdTools.setAnnotationSuspended(false); // annotation live again
const suspended = nrrdTools.isAnnotationSuspended();nrrdTools.setAnnotationSuspended(true); // preview only
nrrdTools.setAnnotationSuspended(false); // annotation live again
const suspended = nrrdTools.isAnnotationSuspended();While suspended, no input can write into a mask. Everything that only reads stays live, so the case remains fully reviewable:
| Still works | Blocked |
|---|---|
Slice scrubbing (drag, wheel, setSliceMoving) | Pencil / brush / eraser strokes |
| Zoom and pan (right-drag) | Sphere placement |
| Crosshair placement | SphereBrush / SphereEraser |
| Window / level | AI-assist prompts |
The typical use is the window while a case's images are still arriving:
nrrdTools.setAnnotationSuspended(true);
await loadAllContrasts(caseId);
nrrdTools.commitSeriesLoad(allSlices, skipEntries, 0);
nrrdTools.setAnnotationSuspended(false);nrrdTools.setAnnotationSuspended(true);
await loadAllContrasts(caseId);
nrrdTools.commitSeriesLoad(allSlices, skipEntries, 0);
nrrdTools.setAnnotationSuspended(false);Greying out your own toolbar is not equivalent
Two reasons this has to be enforced inside the engine:
- A tool selected before the load began stays armed on the canvas — the buttons are disabled, but a left-drag still paints.
- A stroke made during loading cannot be undone.
afterLoadSliceclears the undo stack every time a slice lands, so the mask keeps the paint while the history that would reverse it is thrown away by the next arrival.
The gate sits at a single point — DrawToolCore.onCanvasPointerDown — rather than in each tool, so a tool added later is covered by default.
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.
6.6 Reading and replacing a layer's whole volume
Three ways to move voxels in and out of a layer wholesale. They differ in what ends up on the undo stack, which is the thing to pick on:
| Method | Undoable | Notes |
|---|---|---|
getLayerVolume(id) | — | Returns a copy of the voxel buffer, or null if the layer has no volume |
replaceLayerVolume(id, data, { undoable }) | opt-in | undoable: true records one Ctrl+Z step for the whole volume |
copyLayerData(src, dst) | no | Writes the target buffer directly; leaves nothing for Ctrl+Z |
// Move a mask from layer2 to layer3, undoably
const voxels = nrrdTools.getLayerVolume('layer2');
if (voxels) nrrdTools.replaceLayerVolume('layer3', voxels, { undoable: true });// Move a mask from layer2 to layer3, undoably
const voxels = nrrdTools.getLayerVolume('layer2');
if (voxels) nrrdTools.replaceLayerVolume('layer3', voxels, { undoable: true });getLayerVolume hands back a copy, not the live buffer: the renderer's own array must not be mutated from outside, because nothing would invalidate the slice cache behind it.
Prefer this pair over copyLayerData 3.10.4
copyLayerData(src, dst) still exists for the case it was written for — mirroring a backend cascade (editing layer2 implies layer3) without a network round-trip. It is faster and it preserves the target's colour map, but the clinician cannot undo it. If a person triggered the change, go through getLayerVolume + replaceLayerVolume instead.
replaceLayerVolume does not fire onLayerVolumeReplaced itself — the caller (a mask upload flow, say) is expected to persist the new volume. The callback fires only later, if the clinician undoes or redoes the replacement, because at that point the backend holds the newer volume and has to be told to fall back.
6.7 Mirroring another viewer's masks — setMaskMirror() 3.11.5
Draw a different viewer's masks on this one. The classic use is a side-by-side: annotate on the left, and have the right-hand study show the same findings over its own image.
// `reference` draws `primary`'s masks
reference.setMaskMirror(primary);
// with a voxel→voxel transform between the two grids
reference.setMaskMirror(primary, mirrorToSource); // row-major 4×4
reference.setMaskMirror(null); // detach
const src = reference.getMaskMirrorSource(); // → primary | null// `reference` draws `primary`'s masks
reference.setMaskMirror(primary);
// with a voxel→voxel transform between the two grids
reference.setMaskMirror(primary, mirrorToSource); // row-major 4×4
reference.setMaskMirror(null); // detach
const src = reference.getMaskMirrorSource(); // → primary | nullEverything is read live from the source — its volumes, channel colours, layer and channel visibility, per-layer opacity, and its fill/outline render mode. Change any of them on the source and the mirror follows on the next frame. A mirroring viewer needs no layers of its own; layers: [] pairs naturally with this.
Display only
Nothing is written to either viewer's masks. The mirror holds no mask data at all — it is a view onto the source, so there is no copy to keep in sync and nothing to save.
The transform. mirrorToSource is a row-major 4×4 mapping this viewer's voxel (x, y, z, 1) to the source's voxel. Omit it for the identity, which is right when both viewers share a grid.
| Case | Sampling |
|---|---|
| Identity and identical dimensions | The source's own slice, unchanged |
| Diagonal transform (axes agree; only scale and offset differ) | A mirror voxel coarser than the source shows the first label among every source voxel it spans — so a finding thinner than the mirror's slice still appears on the slice containing it |
| Any other transform | Nearest source voxel |
A cell mapping outside the source reads as empty.
Refresh and teardown. Every visible mask change on the source ends in a composite, and each one schedules a repaint of its mirrors on the next animation frame; repeated changes coalesce into one. dispose() breaks the link from both ends, so neither viewer keeps the other alive — disposing the source detaches its mirrors and repaints them empty.
Sphere mode hides it
The source hides its own layers in sphere mode, so a mirror of it shows nothing while that mode is active — matching what the source itself displays.
6.8 Erasing one channel — clearChannel() 3.10.2
nrrdTools.clearChannel('layer1', 3); // finding 3 goes; 1, 2 and 4 staynrrdTools.clearChannel('layer1', 3); // finding 3 goes; 1, 2 and 4 stayThis is the third member of the clear family. executeAction("clearActiveSliceMask") takes one slice and executeAction("clearActiveLayerMask") takes a whole layer — neither can take a single label. Deleting one annotation needs exactly that, because its mask is one label inside a volume it shares with every other annotation on the layer.
| Method | Scope |
|---|---|
clearActiveSlice() | One slice, every channel |
clearChannel(id, ch) | One channel, every slice |
clearActiveLayer() | One layer, everything |
reset() | Every layer, everything |
It is undoable. The deltas go onto the engine's own undo stack, so a mistaken clear is recoverable for as long as the case stays open:
nrrdTools.clearChannel('layer1', 3);
nrrdTools.undo(); // finding 3 is backnrrdTools.clearChannel('layer1', 3);
nrrdTools.undo(); // finding 3 is backThat is the reason to use it rather than writing zeros into the volume yourself and forcing a repaint — an outside write bypasses the stack, and the clear becomes permanent.
Only slices that actually carry the label are touched, so a small annotation in a deep volume costs one scan and a handful of getMaskData callbacks rather than one callback per slice. If the channel is empty, nothing is pushed and nothing fires.
Errors are thrown, not swallowed
- Unknown
layerId→Errorlisting the known layers. It deliberately does not go throughgetVolumeForLayer, which warns and falls back to the first layer; for a destructive call that would quietly erase a different layer's annotation. channeloutside[1, 255]→RangeErrorfromMaskVolume.0means empty, so it is not a channel you can clear.
It is not part of executeAction because that signature is an action name plus an options bag, and this needs two required arguments.
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
Channels 1–8 ship with a fixed, hand-picked palette:
| 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 |
Channels 9–255 are generated 3.10.2
MaskVolume stores one byte per voxel and the value is the label, so the engine supports 255 annotation channels (0 means empty). Copper.MAX_ENGINE_CHANNEL is that ceiling.
Channels past 8 get a colour from a golden-angle hue walk (137.508° per step) at fixed saturation and lightness. Only the hue varies, deliberately: these sit on greyscale MRI, where a mask that changes brightness between channels reads as a difference in the image rather than in the annotation.
The eight seeds above are kept verbatim rather than regenerated, so a case annotated before this change keeps exactly the colours it was read and signed off with.
Distinct is not the same as distinguishable
Past roughly twenty channels, neighbouring hues stop being tellable apart by eye on a greyscale background. That is a limit on how many findings your product should offer at once — not on what the engine will hand back. All 255 are addressable; only the first ~20 are comfortably readable.
import { MAX_ENGINE_CHANNEL, CHANNEL_HEX_COLORS } from 'copper3d';
MAX_ENGINE_CHANNEL; // → 255
CHANNEL_HEX_COLORS[3]; // → '#3b82f6' (delivered)
CHANNEL_HEX_COLORS[42]; // → generated, stable across runsimport { MAX_ENGINE_CHANNEL, CHANNEL_HEX_COLORS } from 'copper3d';
MAX_ENGINE_CHANNEL; // → 255
CHANNEL_HEX_COLORS[3]; // → '#3b82f6' (delivered)
CHANNEL_HEX_COLORS[42]; // → generated, stable across runsAll four palettes — MASK_CHANNEL_COLORS, MASK_CHANNEL_CSS_COLORS / CHANNEL_COLORS, CHANNEL_HEX_COLORS, and the AI-scratch pair — are extended the same way and are built eagerly, so Object.entries over them works as it always did.
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 |
| Mouse wheel | Zoom |
| Scroll:Zoom shortcut | Ctrl+1 |
| Scroll:Slice shortcut | Ctrl+2 |
Sphere mode no longer has a shortcut breaking in 3.11.5
The sphere: "q" binding is gone, and sphere has been removed from IKeyBoardSettings. Sphere mode is now entered only through setMode("sphere") — which is also how the calculator reaches it — so the host always knows which tool is active.
The shortcut toggled the mode behind the host's back: pressing q cleared the masks from the view while the host's own UI still showed whatever tool it thought was selected.
If you passed sphere to setKeyboardSettings, drop it — it is no longer part of the type:
// before
nrrdTools.setKeyboardSettings({ sphere: 'e', crosshair: 'x' });
// after
nrrdTools.setKeyboardSettings({ crosshair: 'x' });
nrrdTools.setMode('sphere'); // bind your own key to this if you want one// before
nrrdTools.setKeyboardSettings({ sphere: 'e', crosshair: 'x' });
// after
nrrdTools.setKeyboardSettings({ crosshair: 'x' });
nrrdTools.setMode('sphere'); // bind your own key to this if you want oneReading 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
Mask render mode — fill or outline 3.10.3
nrrdTools.setMaskRenderMode('outline'); // stroke only the boundary
nrrdTools.setMaskRenderMode('fill'); // paint the silhouette (default)
const mode = nrrdTools.getMaskRenderMode(); // → 'fill' | 'outline'nrrdTools.setMaskRenderMode('outline'); // stroke only the boundary
nrrdTools.setMaskRenderMode('fill'); // paint the silhouette (default)
const mode = nrrdTools.getMaskRenderMode(); // → 'fill' | 'outline'"outline" strokes only each mask's edge, leaving the interior clear so the tissue underneath stays readable — which is the point: a clinician can see what they annotated and what they annotated it on, at the same time.
It is a display decision and nothing else
MaskVolume is not read differently and not written at all. What is stored, uploaded, exported and undone is the complete mask either way, so switching mode can never lose data.
Applies to every layer and channel at once — there is no per-layer variant. It repaints immediately, the same way setChannelVisible does, so the change is on screen the moment you set it rather than at the next slice scrub.
The default is "fill", which is what every existing caller already got.
Cost. The boundary is extracted per visible label and cached alongside the fill path for that slice. Switching mode costs one extraction per visible label the first time and is a cache hit every time after; a session that never leaves fill mode never builds an outline at all. Outline extraction scales with the perimeter rather than the area, so it is cheaper than the fill it replaces.
Drawing still works on the solid mask
The pencil and eraser rebuild a slice from what is on the layer canvas, so they render the layer filled for the duration of a stroke regardless of the display mode, and the mode comes back at pointer-up. You do not need to leave outline mode to annotate — but if you write your own tool that bakes a canvas back into a volume, it must render filled too (see the architecture guide).
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 resolutionZoom range — setMinSizeFactor() 3.11.3
The 2D zoom factor (sizeFactor) used to bottom out at 1, i.e. 1:1 with the image. Lower the floor and the image can be shown smaller than its original size — which is what a large volume in a small panel needs:
nrrdTools.setMinSizeFactor(0.5); // allow down to half size
nrrdTools.setMainAreaSize(0.6); // now valid
const min = nrrdTools.getMinSizeFactor();nrrdTools.setMinSizeFactor(0.5); // allow down to half size
nrrdTools.setMainAreaSize(0.6); // now valid
const min = nrrdTools.getMinSizeFactor();| Accepted range | clamped to [0.05, 1]; a non-finite value resets it to 1 |
| Default | 1 — exactly the previous behaviour |
| Upper bound | still 8, unchanged |
Everything that moves the zoom respects it: setMainAreaSize, wheel zoom, and NrrdState.setZoomFactor. The "mainAreaSize" GUI slider's min is updated too, so a connected dat.GUI / lil-gui panel can reach the new range.
Wheel zoom follows scroll distance 3.11.4
One notch (100px of scroll) zooms by 10%, and a single event is capped at 2×. Because the ratio comes from the distance scrolled rather than from the number of events, a trackpad's small deltas zoom smoothly, and a busy page — where the browser coalesces wheel events and sums their deltas — zooms at the same rate as an idle one. Nothing to configure.
If the current factor is below the new minimum it is raised to it and the view repaints immediately, so you can lower and raise the floor freely without leaving the view in an invalid state.
You have to re-apply it — the engine never recomputes it
The minimum survives reset() and case loads, and the engine will not adjust it on its own: the right value depends on the image and axis dimensions and on the size of your panel, neither of which the engine knows. Re-apply it after each
- case load,
- axis switch (
setSliceOrientation), and - panel resize.
function fitFloor() {
const [w, h] = nrrdTools.getCurrentImageDimension(); // per current axis
const panel = container.getBoundingClientRect();
nrrdTools.setMinSizeFactor(Math.min(panel.width / w, panel.height / h, 1));
}function fitFloor() {
const [w, h] = nrrdTools.getCurrentImageDimension(); // per current axis
const panel = container.getBoundingClientRect();
nrrdTools.setMinSizeFactor(Math.min(panel.width / w, panel.height / h, 1));
}A thin dimension never floors to zero
resizePaintArea keeps a loaded image at at least 1px per axis, because a factor below 1 could otherwise floor a thin dimension to 0. An unloaded (0×0) image stays 0.
Reading 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-255
// 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;
}
// Tool mode (used by setMode/getMode)
type ToolMode = "pencil" | "brush" | "eraser" | "sphere" | "calculator" | "sphereBrush" | "sphereEraser";
// Mask render mode (used by setMaskRenderMode/getMaskRenderMode)
type MaskRenderMode = "fill" | "outline";
// Keyboard settings
interface IKeyBoardSettings {
draw: string;
undo: string;
redo: string;
contrast: string[]; // always an array, e.g. ["Control", "Meta"]
crosshair: 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-255
// 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;
}
// Tool mode (used by setMode/getMode)
type ToolMode = "pencil" | "brush" | "eraser" | "sphere" | "calculator" | "sphereBrush" | "sphereEraser";
// Mask render mode (used by setMaskRenderMode/getMaskRenderMode)
type MaskRenderMode = "fill" | "outline";
// Keyboard settings
interface IKeyBoardSettings {
draw: string;
undo: string;
redo: string;
contrast: string[]; // always an array, e.g. ["Control", "Meta"]
crosshair: 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 = number; // 0 = empty/erased, 1..MAX_ENGINE_CHANNEL (255)type LayerId = 'layer1' | 'layer2' | 'layer3' | 'layer4'; // or any string
type ChannelValue = number; // 0 = empty/erased, 1..MAX_ENGINE_CHANNEL (255)ChannelValue is a bare number since 3.10.2
It used to be the union 1 | 2 | ... | 8. That union cannot be written for 255 members, so the compile-time error on a bad literal is gone — you now get a RangeError from MaskVolume at runtime instead. That is a genuine loss and it is the price of the range: storage is one byte per voxel whose value is the label, so the engine has no standing to cap it below what the byte allows. Capping it lower is your product's call, not the engine's.
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 | |
clearChannel(id, ch) | Erase one channel across a whole layer, undoably. Throws on an unknown layer or a channel outside [1, 255] | |
setAllSlices(slices) | Load NRRD slices, init MaskVolumes | |
setMasksFromNIfTI(map, bar?) | Load saved NIfTI voxel data (each buffer must be registered via registerNiftiMaskGrid first, or it is refused) | |
registerNiftiMaskGrid(data, dims) | (module export, not a method) Record a mask buffer's NIfTI voxel grid so setMasksFromNIfTI can validate it | |
notifyUser | (assignable property) How engine messages reach the reader; defaults to a console write | |
| Contrast Series | addSkip(index) | Hide one contrast (index into the full contrast list) |
removeSkip(index) | Show a previously hidden contrast | |
setSkips(entries) | Batched addSkip/removeSkip — one refresh for many changes | |
setContrastIndex(index) | Move to a contrast within displaySlices, without moving the slice | |
commitSkipsAndContrast(entries, i) | Batched setSkips + setContrastIndex — one refresh | |
commitSeriesLoad(slices, entries, i) | Case-load completion: swap series + reconcile skips + land on contrast, one refresh | |
switchAllSlicesArrayData(slices) | Swap the loaded series (resets slice index / zoom / pan) | |
switchSlicesPreservingView(slices) | Swap the loaded series, keeping slice index, zoom and pan | |
| Preview | showPreviewSlice(header, slice, i, cb) | Show one depth-1 slice as slice i of the not-yet-loaded stack. While previewing, every slice move is handed to cb with the clamped target instead of moving the display. Axial only; ends on switchSlicesPreservingView / setAllSlices / reset |
isPreviewing() | Whether a preview slice is shown in place of a loaded volume | |
| Render | start | Frame callback — pass to render loop |
| Lifecycle | dispose() | Release the event router's listeners, a pending slice step, the drawing-flag timer and any mask-mirror link (from both ends). Instance unusable afterwards; safe to call twice |
| 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 | |
getLayerVolume(id) | A copy of the layer's voxel buffer, or null | |
replaceLayerVolume(id, data, opts?) | Replace the whole buffer; { undoable: true } records one Ctrl+Z step | |
copyLayerData(src, dst) | Copy voxels between layers — not undoable; keeps the target's colour map | |
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 | |
| Mirror | setMaskMirror(src, m?) | Draw another viewer's masks, read live from it. m = row-major 4×4 mapping this viewer's voxel to the source's; null detaches. Display only — nothing is written to either viewer |
getMaskMirrorSource() | The viewer being mirrored, or null | |
| 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 | |
MAX_ENGINE_CHANNEL | (module export) Highest storable label, 255 | |
| Tool Mode | setMode(mode) | Switch tool mode: "pencil" / "brush" / "eraser" / "sphere" / "calculator" / "sphereBrush" / "sphereEraser" |
getMode() | Read current tool mode | |
isCalculatorActive() | Check if calculator (distance) mode is active | |
setAnnotationSuspended(bool) | Block all mask-writing input; scrubbing, zoom, pan and crosshair stay live | |
isAnnotationSuspended() | Query the suspension state | |
| Sphere Brush | setSphereBrushRadius(radius) | Set sphere brush/eraser radius [1, 50] |
getSphereBrushRadius() | Read current sphere brush/eraser radius | |
| Drawing | setOpacity(value) | Set mask overlay opacity [0.1, 1] |
getOpacity() | Read current opacity | |
setMaskRenderMode(mode) | "fill" or "outline" for every layer/channel; repaints immediately. Display only — the stored mask is unchanged | |
getMaskRenderMode() | Read the current render mode | |
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", "gaussianSmooth" |
| Gaussian Smoothing | GaussianSmoother.gaussianSmooth3D(volume, channel, sigma?, spacing?) | Apply 3D Gaussian smoothing to a MaskVolume channel |
GaussianSmoother.generateKernel1D(sigma) | Generate a normalized 1D Gaussian kernel | |
| Navigation | setSliceOrientation(axis) | Switch viewing axis "x" / "y" / "z"; extracts the plane on demand if the load skipped it |
setSliceMoving(step) | Step the current slice by step (coalesced into one requestAnimationFrame) | |
setMainAreaSize(factor) | Set the main-area zoom factor [minSizeFactor, 8] and reposition the paint area | |
setMinSizeFactor(f) | Lower the zoom floor so the image can show below 1:1. Clamped to [0.05, 1]; default 1. Not recomputed by the engine — re-apply after case loads, axis switches and panel resizes | |
getMinSizeFactor() | Read the current zoom floor | |
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 |
15. Gaussian Smoothing
NrrdTools includes built-in 3D Gaussian smoothing for segmentation masks, used to smooth jagged edges and fill small holes in annotations.
How It Works
Applies a separable 3D Gaussian blur (X → Y → Z convolution) to a single label channel within a MaskVolume, then thresholds the result at 0.5 back to a binary mask.
Usage
Invoke via executeAction:
// Use default sigma (1.0)
nrrdTools.executeAction("gaussianSmooth");
// Specify sigma
nrrdTools.executeAction("gaussianSmooth", { sigma: 2.0 });// Use default sigma (1.0)
nrrdTools.executeAction("gaussianSmooth");
// Specify sigma
nrrdTools.executeAction("gaussianSmooth", { sigma: 2.0 });Parameters:
sigma(optional, default1.0): Gaussian kernel standard deviation in voxel units. Higher values produce stronger smoothing.
Anisotropic Spacing Support
When NRRD images have non-uniform voxel spacing (e.g., [0.488, 0.488, 1.0]), smoothing automatically adjusts per-axis sigma to ensure isotropic smoothing in physical space:
per-axis sigma = sigma / spacing[axis]per-axis sigma = sigma / spacing[axis]Spacing is automatically read from nrrd_states.image.voxelSpacing.
Undo Support
Gaussian smoothing is fully undoable. Before execution, all Z-slices containing the target channel are snapshotted, and the deltas are pushed to the UndoManager stack after execution.
// Apply smoothing
nrrdTools.executeAction("gaussianSmooth", { sigma: 1.5 });
// Undo smoothing (restores all affected slices)
nrrdTools.undo();// Apply smoothing
nrrdTools.executeAction("gaussianSmooth", { sigma: 1.5 });
// Undo smoothing (restores all affected slices)
nrrdTools.undo();Backend Sync
After smoothing completes, onMaskChanged is automatically fired for every modified slice, notifying the backend to sync.
Vue Component Integration
In OperationCtl.vue, the "Smoothing: Gaussian" button and "Smooth Sigma" slider provide full UI control:
// Usage in OperationCtl.vue
emitter.emit("Segmentation:SwitchAnimationStatus", { status: "flex", text: "Smoothing: Gaussian..." });
setTimeout(() => {
try {
nrrdTools.executeAction("gaussianSmooth", { sigma: smoothSigma.value });
toast.success("Gaussian smoothing completed");
} catch (e) {
toast.error("Gaussian smoothing failed");
} finally {
emitter.emit("Segmentation:SwitchAnimationStatus", { status: "none" });
}
}, 50);// Usage in OperationCtl.vue
emitter.emit("Segmentation:SwitchAnimationStatus", { status: "flex", text: "Smoothing: Gaussian..." });
setTimeout(() => {
try {
nrrdTools.executeAction("gaussianSmooth", { sigma: smoothSigma.value });
toast.success("Gaussian smoothing completed");
} catch (e) {
toast.error("Gaussian smoothing failed");
} finally {
emitter.emit("Segmentation:SwitchAnimationStatus", { status: "none" });
}
}, 50);Note: Since smoothing runs synchronously on the main thread,
setTimeoutwith a 50ms delay allows the UI to render the loading animation first.
GaussianSmoother API
GaussianSmoother is a pure static utility class with no DOM/Canvas/GUI dependencies:
| Method | Signature | Description |
|---|---|---|
gaussianSmooth3D | (volume: MaskVolume, channel: number, sigma?: number, spacing?: [number, number, number]): void | Apply separable 3D Gaussian smoothing to the specified channel (modifies volume in-place) |
generateKernel1D | (sigma: number): Float32Array | Generate a normalized 1D Gaussian kernel, truncated at ±3σ |
Performance Optimizations
GaussianSmoother includes two key performance optimizations:
- Direct array access: The extract and write-back phases bypass
getVoxel()/setVoxel()boundary checks and function call overhead, directly accessing the underlyingUint8Arrayviavolume.getRawData()with inline index calculation usingvolume.getBytesPerSlice()andvolume.getChannels(). - Branch-free convolution: The
convolve1Dinner loop is split into three segments — left boundary (with lower bound check), middle interior (no branching at all, handles ~95% of work), and right boundary (with upper bound check).
Impact scope: Only
GaussianSmoother.tsis modified. All other tools continue usinggetVoxel/setVoxelwith full boundary checks.