Skip to content

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: NrrdTools is 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 (composes CanvasState + RenderingUtils)
  • RenderingUtils — rendering / slice-buffer helpers (compositeAllLayers, renderSliceToCanvas, etc.)
  • LayerChannelManager — layer/channel/sphere-type management and channel color customization
  • SliceRenderPipeline — slice setup, canvas rendering, mask reload, canvas flip
  • DataLoader — NRRD slice loading, legacy mask loading, NIfTI voxel loading

The old 3-level inheritance chain (NrrdTools → DrawToolCore → CommToolsData) has been fully replaced. CommToolsData has been deleted. All modules communicate via ToolContext (shared state) and Pick<ToolHost, ...> type aliases (host method dependencies). The public API documented below is unchanged.


Table of Contents ​

  1. Quick Start
  2. Constructor & Initialization
  3. Data Loading
  4. Render Loop Integration
  5. Drawing Setup
  6. Layer & Channel Management
  7. Channel Color Customization
  8. Undo / Redo
  9. Keyboard Shortcuts
  10. Display & Canvas Control
  11. Reading State & Diagnostics
  12. Advanced Scenarios
  13. Vue 3 Integration Pattern
  14. Type Reference

1. Quick Start ​

typescript
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 ​

typescript
new Copper.NrrdTools(container: HTMLDivElement, options?: { layers?: string[] })
new Copper.NrrdTools(container: HTMLDivElement, options?: { layers?: string[] })
ParameterTypeDefaultDescription
containerHTMLDivElementrequiredThe DOM element that will host all annotation canvases
options.layersstring[]["layer1","layer2","layer3"]Named layers to create. Each layer gets its own MaskVolume and canvas

Example: Single-layer (minimal) ​

typescript
const nrrdTools = new Copper.NrrdTools(document.getElementById('viewer') as HTMLDivElement);
const nrrdTools = new Copper.NrrdTools(document.getElementById('viewer') as HTMLDivElement);

Example: Custom layer set ​

typescript
// 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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.

typescript
// 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 before setAllSlices() 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:

typescript
// 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:

typescript
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:

typescript
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.

typescript
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 ​

typescript
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:

TermAddressesNote
skip indexposition in the full contrast listwhat addSkip / removeSkip / setSkips take
contrastIndexposition in displaySlices — the selected subsetwhat setContrastIndex takes

With three of five phases selected, valid contrastIndex values are 0..2. Callers that think in phase names must convert first.

typescript
nrrdTools.addSkip(2);          // hide contrast 2
nrrdTools.removeSkip(2);       // show it again
nrrdTools.setContrastIndex(1); // jump to the 2nd *displayed* contrast
nrrdTools.addSkip(2);          // hide contrast 2
nrrdTools.removeSkip(2);       // show it again
nrrdTools.setContrastIndex(1); // jump to the 2nd *displayed* contrast
Batched 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:

typescript
// 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 ​
typescript
// 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:

typescript
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 over
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 over
ParameterWhat it is
headerThe full volume's geometry (NrrdHeaderLike: dimensions, spacing, space_origin) — not the single plane's
sliceA depth-1 slice triple built from one plane's voxels
indexWhich slice of the full stack this plane is
onSliceMoveReceives 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:

typescript
const allSlices = await loadFullVolume(caseId);
nrrdTools.switchSlicesPreservingView(allSlices);   // keeps the previewed index, ends previewing
const allSlices = await loadFullVolume(caseId);
nrrdTools.switchSlicesPreservingView(allSlices);   // keeps the previewed index, ends previewing

Previewing 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 ​

typescript
// 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 ​

typescript
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 ​

typescript
scene.removePreRenderCallbackFunction(callbackId);
nrrdTools.dispose();
container.remove();          // your DOM, your call
scene.removePreRenderCallbackFunction(callbackId);
nrrdTools.dispose();
container.remove();          // your DOM, your call

dispose() releases what the instance holds outside its own DOM subtree, so a viewer that is torn down can actually be garbage-collected:

ReleasedWhy it matters
The event router's listenersIts window blur listener closes over the router, which reaches the whole engine graph — one live listener pins the entire instance
A pending slice stepThe requestAnimationFrame setSliceMoving coalesces into
The drawing-flag timerThe pending setIsDrawFalse timeout
Any mask-mirror linkBroken 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:

typescript
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.

typescript
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.

typescript
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 TypeChannelDefault ColoractiveSphereType value
tumour1#10b981 (Emerald)"tumour" (default)
nipple2#f43f5e (Rose)"nipple"
ribcage3#3b82f6 (Blue)"ribcage"
skin4#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:

typescript
// 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 sphereMaskVolume are reserved for future integration.

Scenario: Sphere mode with AI backend (distance calculation) ​
typescript
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:

typescript
// 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:

  1. Sets sphereRadius = 5 and navigates to the target slice
  2. Records sphereOrigin on all 3 axes (via crosshairTool.setUpSphereOrigins)
  3. Deep-copies the origin into the type-specific field (e.g., tumourSphereOrigin)
  4. Draws the sphere preview on canvas (drawCalculatorSphere)
  5. Writes all placed spheres to sphereMaskVolume (writeAllCalculatorSpheresToVolume)
  6. Re-renders the sphere overlay (refreshSphereCanvas)

Note: Coordinates (x, y) are in unscaled image space. The method automatically applies sizeFactor scaling 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 ​
ToolMode ValueDescription
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 ​
typescript
// 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 ​
typescript
// 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(); // → 10

During 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) ​
FeatureSphere (Marker)SphereBrush / SphereEraser
Storage targetsphereMaskVolume (separate overlay)Active layer's MaskVolume (shared)
Channel behaviorFixed per sphere typeUses active channel
NIfTI/GLTF exportNot exported (overlay only)Fully exported as 3D volume
Undo supportNoYes (grouped multi-slice undo)
Drag supportNoSphereEraser supports drag-to-erase
Scenario: AI result review with Sphere Eraser ​
typescript
// 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 exported

5.5 enableContrastDragEvents() — Windowing ​

Enable Ctrl+drag to adjust window/level (brightness/contrast):

typescript
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 ​

typescript
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 worksBlocked
Slice scrubbing (drag, wheel, setSliceMoving)Pencil / brush / eraser strokes
Zoom and pan (right-drag)Sphere placement
Crosshair placementSphereBrush / SphereEraser
Window / levelAI-assist prompts

The typical use is the window while a case's images are still arriving:

typescript
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:

  1. A tool selected before the load began stays armed on the canvas — the buttons are disabled, but a left-drag still paints.
  2. A stroke made during loading cannot be undone. afterLoadSlice clears 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 ​

typescript
// 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(); // → 3

6.2 Layer Visibility ​

Toggle layers on/off in the composite canvas:

typescript
// 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 ​

typescript
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:

typescript
// 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 ​

typescript
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 ​

typescript
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].

typescript
// 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() via masterCtx.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 ​
typescript
// 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:

typescript
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:

MethodUndoableNotes
getLayerVolume(id)—Returns a copy of the voxel buffer, or null if the layer has no volume
replaceLayerVolume(id, data, { undoable })opt-inundoable: true records one Ctrl+Z step for the whole volume
copyLayerData(src, dst)noWrites the target buffer directly; leaves nothing for Ctrl+Z
typescript
// 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.

typescript
// `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 | null

Everything 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.

CaseSampling
Identity and identical dimensionsThe 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 transformNearest 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 ​

typescript
nrrdTools.clearChannel('layer1', 3);  // finding 3 goes; 1, 2 and 4 stay
nrrdTools.clearChannel('layer1', 3);  // finding 3 goes; 1, 2 and 4 stay

This 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.

MethodScope
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:

typescript
nrrdTools.clearChannel('layer1', 3);
nrrdTools.undo();   // finding 3 is back
nrrdTools.clearChannel('layer1', 3);
nrrdTools.undo();   // finding 3 is back

That 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 → Error listing the known layers. It deliberately does not go through getVolumeForLayer, which warns and falls back to the first layer; for a destructive call that would quietly erase a different layer's annotation.
  • channel outside [1, 255] → RangeError from MaskVolume. 0 means 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.

typescript
// ❌ 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:

typescript
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:

ChannelColorHex
1Emerald (Tumour)#10b981
2Rose (Edema)#f43f5e
3Blue (Necrosis)#3b82f6
4Amber (Enhancement)#fbbf24
5Fuchsia (Vessel)#d946ef
6Cyan (Additional)#06b6d4
7Orange (Auxiliary)#f97316
8Violet (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.

typescript
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 runs
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 runs

All 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 ​

typescript
// 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 }); // → orange

One reloadMasksFromVolume() call instead of N calls — better performance:

typescript
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 ​

typescript
// 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 ​

typescript
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 binding
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 binding

7.5 Reset colors ​

typescript
// 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 ​

typescript
// 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:

typescript
// 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.

typescript
// 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 ​

typescript
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: z and y without Ctrl). See Keyboard Shortcuts for configuring this.


9. Keyboard Shortcuts ​

Default bindings ​

ActionDefault Key
Draw modeShift (hold)
Undoz
Redoy
Contrast adjustCtrl / Meta (hold)
Crosshairs
Mouse wheelZoom
Scroll:Zoom shortcutCtrl+1
Scroll:Slice shortcutCtrl+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:

typescript
// 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 one

Reading current settings ​

typescript
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 ​

typescript
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:

typescript
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 ​

typescript
// 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 ​

typescript
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:

typescript
nrrdTools.setBaseDrawDisplayCanvasesSize(2); // 2× base resolution
nrrdTools.setBaseDrawDisplayCanvasesSize(2); // 2× base resolution

Zoom 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:

typescript
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 rangeclamped to [0.05, 1]; a non-finite value resets it to 1
Default1 — exactly the previous behaviour
Upper boundstill 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.
typescript
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 ​

typescript
// 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 ​

typescript
// 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 ​

typescript
// 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 ​

typescript
// 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 ​

typescript
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 ​

typescript
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 ​

typescript
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 ​

typescript
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:

typescript
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 ​

typescript
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 ​

typescript
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:

typescript
// 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) ​

vue
<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) ​

vue
<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 ​

typescript
// 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 ​

typescript
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 ​

CategoryMethodDescription
Constructornew NrrdTools(container, { layers })Create instance with optional layer config
Setupdrag(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)
Datareset()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 SeriesaddSkip(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
PreviewshowPreviewSlice(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
RenderstartFrame callback — pass to render loop
Lifecycledispose()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
LayersetActiveLayer(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
MirrorsetMaskMirror(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
SpheresetActiveSphereType(type)Set active sphere type ('tumour'/'skin'/'nipple'/'ribcage'), updates brush color
getActiveSphereType()Read current sphere type
ChannelsetActiveChannel(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
ColorsetChannelColor(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 ModesetMode(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 BrushsetSphereBrushRadius(radius)Set sphere brush/eraser radius [1, 50]
getSphereBrushRadius()Read current sphere brush/eraser radius
DrawingsetOpacity(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
ContrastsetWindowHigh(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.)
ActionsexecuteAction(action)Run named action: "undo", "redo", "clearActiveSliceMask", "clearActiveLayerMask", "resetZoom", "downloadCurrentMask", "gaussianSmooth"
Gaussian SmoothingGaussianSmoother.gaussianSmooth3D(volume, channel, sigma?, spacing?)Apply 3D Gaussian smoothing to a MaskVolume channel
GaussianSmoother.generateKernel1D(sigma)Generate a normalized 1D Gaussian kernel
NavigationsetSliceOrientation(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)
Historyundo()Undo last stroke
redo()Redo last undone stroke
KeyboardsetKeyboardSettings(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
InspectgetCurrentImageDimension()[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:

typescript
// 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, default 1.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.

typescript
// 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:

typescript
// 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, setTimeout with 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:

MethodSignatureDescription
gaussianSmooth3D(volume: MaskVolume, channel: number, sigma?: number, spacing?: [number, number, number]): voidApply separable 3D Gaussian smoothing to the specified channel (modifies volume in-place)
generateKernel1D(sigma: number): Float32ArrayGenerate a normalized 1D Gaussian kernel, truncated at ±3σ

Performance Optimizations ​

GaussianSmoother includes two key performance optimizations:

  1. Direct array access: The extract and write-back phases bypass getVoxel()/setVoxel() boundary checks and function call overhead, directly accessing the underlying Uint8Array via volume.getRawData() with inline index calculation using volume.getBytesPerSlice() and volume.getChannels().
  2. Branch-free convolution: The convolve1D inner 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.ts is modified. All other tools continue using getVoxel/setVoxel with full boundary checks.