Skip to content

copper3d / Modules

copper3d ​

CINPM PackageDocumentationCopper3d ExamplesNRRD_Segmentation_Tool exampleMedTech Heart example

A 3D visualisation package based on three.js — multiple scenes, NRRD/DICOM image loading, and a full medical image segmentation annotation engine.

Installation ​

bash
npm install copper3d three@~0.186.0
npm install copper3d three@~0.186.0

three is a peer dependency since 4.0.0 — you install it, and copper3d shares your copy. It is not bundled, so there is exactly one three on the page: bundling it would give you a second (three warns "Multiple instances of Three.js being imported"), and a Mesh built by your code would not be an instanceof the Mesh copper3d checks against.

TypeScript users also want the types, which are an optional peer:

bash
npm install -D @types/three@~0.186.0
npm install -D @types/three@~0.186.0

copper3d imports three's addons through three/addons/*, which three exposes only via its package exports map. If your tsconfig.json still has "moduleResolution": "node", move it to "bundler" (or "node16"/"nodenext") — "node" predates exports maps and cannot resolve those paths.

At import, copper3d compares the three it was built against with the one actually loaded and logs a console.error if they differ. npm refuses a mismatched peer at install time; yarn 1 and pnpm only warn, so the check is repeated where it cannot be scrolled past.

Documentation ​

https://linkungao.github.io/copper3d_visualisation/ — guides and the full generated API reference, in English and 简体中文.

Upgrading to 4.0Start here if you are on 3.x — three is now a peer dependency
Patient OrientationCanonical LPS order, OrientationError, view labels
NrrdTools Usage GuideThe segmentation engine, end to end
Segmentation ModuleIts architecture and internals
Load Progress & ErrorsloadNrrd / loadGltf options and callbacks
AI Coding Assistants (MCP)Point your assistant at the real API
API referenceGenerated from source by TypeDoc

The site is built from apidocs/ with VitePress and deploys to GitHub Pages.

The old Sphinx site on readthedocs was retired at 3.11.6. It had not been updated since v2.0.7 (December 2023) and documented an API that no longer exists — Copper.Renderer rather than copperRenderer, and nothing from the 3.x segmentation engine. Everything now lives at the link above. Its sources remain in git history if you need them.

Examples ​

Pick model with GltfloaderCopper3d_examples


AI coding assistants (MCP) ​

copper3d ships a machine-readable index of its own API and guides. Point your AI assistant at it through the copper3d-mcp server and it can look up real signatures instead of inventing them.

It reads the index from the copper3d installed in your project, so what the assistant sees always matches the version you actually depend on.

Four read-only tools: copper3d_search_api, copper3d_get_symbol, copper3d_list_guides, copper3d_get_guide.

Full guide: AI Coding Assistants (MCP).

Claude Code — one command, no path needed:

bash
# just you, in this project
claude mcp add copper3d -- npx -y copper3d-mcp

# commit it to .mcp.json so the whole team gets it on clone
claude mcp add --scope project copper3d -- npx -y copper3d-mcp

# all your projects
claude mcp add --scope user copper3d -- npx -y copper3d-mcp
# just you, in this project
claude mcp add copper3d -- npx -y copper3d-mcp

# commit it to .mcp.json so the whole team gets it on clone
claude mcp add --scope project copper3d -- npx -y copper3d-mcp

# all your projects
claude mcp add --scope user copper3d -- npx -y copper3d-mcp

Every other client needs to be told where the project is, because they start the server with a working directory that is not your project (why):

ClientConfig fileRoot keyNeeds --project
Claude Codeclaude mcp add, or .mcp.jsonmcpServersno
Cursor.cursor/mcp.json or ~/.cursor/mcp.jsonmcpServersyes
VS Code / Copilot.vscode/mcp.jsonserversyes
Claude Desktop%APPDATA%\Claude\claude_desktop_config.json
~/Library/Application Support/Claude/claude_desktop_config.json
mcpServersyes
Windsurf~/.codeium/windsurf/mcp_config.jsonmcpServersyes
Cursor — .cursor/mcp.json
json
{
  "mcpServers": {
    "copper3d": {
      "command": "npx",
      "args": ["-y", "copper3d-mcp", "--project", "/absolute/path/to/your/project"]
    }
  }
}
{
  "mcpServers": {
    "copper3d": {
      "command": "npx",
      "args": ["-y", "copper3d-mcp", "--project", "/absolute/path/to/your/project"]
    }
  }
}
VS Code / Copilot — .vscode/mcp.json (note: servers, not mcpServers)
json
{
  "servers": {
    "copper3d": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "copper3d-mcp", "--project", "${workspaceFolder}"]
    }
  }
}
{
  "servers": {
    "copper3d": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "copper3d-mcp", "--project", "${workspaceFolder}"]
    }
  }
}
Claude Desktop — claude_desktop_config.json

The absolute path is required here: Claude Desktop has no project, and it ignores the cwd field.

json
{
  "mcpServers": {
    "copper3d": {
      "command": "npx",
      "args": ["-y", "copper3d-mcp", "--project", "/absolute/path/to/your/project"]
    }
  }
}
{
  "mcpServers": {
    "copper3d": {
      "command": "npx",
      "args": ["-y", "copper3d-mcp", "--project", "/absolute/path/to/your/project"]
    }
  }
}
Windsurf — ~/.codeium/windsurf/mcp_config.json
json
{
  "mcpServers": {
    "copper3d": {
      "command": "npx",
      "args": ["-y", "copper3d-mcp", "--project", "/absolute/path/to/your/project"]
    }
  }
}
{
  "mcpServers": {
    "copper3d": {
      "command": "npx",
      "args": ["-y", "copper3d-mcp", "--project", "/absolute/path/to/your/project"]
    }
  }
}

If it does not work

SymptomCauseFix
Tools never appear in VS CodeUsed mcpServers in .vscode/mcp.jsonVS Code calls it servers
"AI index was not found"copper3d not installed, or the client lost the project directorynpm i copper3d, then add --project /abs/path
Assistant still invents APIsServer not connectedclaude mcp list, or your client's MCP panel

You can also skip discovery entirely by setting COPPER3D_INDEX_PATH to the node_modules/copper3d/ai-index directory.

What it does not do. This is documentation lookup only. copper3d is a browser WebGL library and the server is a Node process — it cannot render a scene, run your code, or look at a canvas.


Basic Usage ​

Load demo

ts
import * as Copper from "copper3d";
import { getCurrentInstance, onMounted } from "vue";

let appRenderer;
onMounted(() => {
  const { $refs } = (getCurrentInstance() as any).proxy;
  const bg: HTMLDivElement = $refs.classfy;
  appRenderer = new Copper.copperRenderer(bg);
  appRenderer.getCurrentScene().createDemoMesh();
  appRenderer.animate();
});
import * as Copper from "copper3d";
import { getCurrentInstance, onMounted } from "vue";

let appRenderer;
onMounted(() => {
  const { $refs } = (getCurrentInstance() as any).proxy;
  const bg: HTMLDivElement = $refs.classfy;
  appRenderer = new Copper.copperRenderer(bg);
  appRenderer.getCurrentScene().createDemoMesh();
  appRenderer.animate();
});

Options

ts
appRenderer = new Copper.copperRenderer(bg, { guiOpen: true });
appRenderer = new Copper.copperRenderer(bg, { guiOpen: true });

Multiple scenes with glTF

ts
function loadModel(url: string, name: string) {
  let scene = appRenderer.getSceneByName(name);
  if (!scene) {
    scene = appRenderer.createScene(name);
    appRenderer.setCurrentScene(scene);
    scene.loadViewUrl("/noInfarct_view.json");
    scene.loadGltf(url);
  } else {
    appRenderer.setCurrentScene(scene);
  }
}
function loadModel(url: string, name: string) {
  let scene = appRenderer.getSceneByName(name);
  if (!scene) {
    scene = appRenderer.createScene(name);
    appRenderer.setCurrentScene(scene);
    scene.loadViewUrl("/noInfarct_view.json");
    scene.loadGltf(url);
  } else {
    appRenderer.setCurrentScene(scene);
  }
}

View data structure

ts
CameraViewPoint {
  nearPlane: number = 0.1;
  farPlane: number = 2000.0;
  eyePosition: Array<number> = [0.0, 0.0, 0.0];
  targetPosition: Array<number> = [0.0, 0.0, 0.0];
  upVector: Array<number> = [0.0, 1.0, 0.0];
}
CameraViewPoint {
  nearPlane: number = 0.1;
  farPlane: number = 2000.0;
  eyePosition: Array<number> = [0.0, 0.0, 0.0];
  targetPosition: Array<number> = [0.0, 0.0, 0.0];
  upVector: Array<number> = [0.0, 1.0, 0.0];
}

Loading NRRD volumes ​

scene.loadNrrd(url, loadingBar, callback, opts?) fetches, gunzips and parses the volume in a shared Web Worker; the pixel buffer comes back as a transferable and the main thread only rehydrates a Volume around it. Two concurrent loads of the same URL share one network transfer. The worker is inlined into the bundle, so there is no second asset file to serve.

Every volume comes back in the canonical voxel order (i → patient Left, j → Posterior, k → Superior), whatever order its file stores, so each view is the radiological one without you doing anything. volume.orientation records how the file got there. A file whose patient orientation cannot be established is refused through onError with an OrientationError, never guessed at — see Patient Orientation.

Breaking in 4.1.0: the segmentation argument was removed — loadNrrd(url, loadingBar, segmentation, callback, opts) is now loadNrrd(url, loadingBar, callback, opts). The canonical order makes the flag meaningless, so it is always on; leaving it in shifts your other arguments by one.

ts
const controller = new AbortController();

scene.loadNrrd(url, loadingBar, (volume, meshes, slices) => { /* ... */ }, {
  openGui: false,
  axes: ["z"],                  // extract only the planes you show
  knownMinMax: [min, max],      // skip three's whole-volume intensity scan
  signal: controller.signal,    // cancel a superseded load
  onProgress: (e) => setProgress(e.total > 0 ? e.loaded / e.total : null),
  onError: (err) => showFailure(err),
});
const controller = new AbortController();

scene.loadNrrd(url, loadingBar, (volume, meshes, slices) => { /* ... */ }, {
  openGui: false,
  axes: ["z"],                  // extract only the planes you show
  knownMinMax: [min, max],      // skip three's whole-volume intensity scan
  signal: controller.signal,    // cancel a superseded load
  onProgress: (e) => setProgress(e.total > 0 ? e.loaded / e.total : null),
  onError: (err) => showFailure(err),
});
OptionTypeDescription
openGuibooleanShow the built-in volume GUI
containerHTMLDivElementHost element for that GUI
axesreadonly ("x"|"y"|"z")[]Which slice planes to extract. Default ["x","y","z"]. Omitted axes are undefined on the callback's meshes / slices
knownMinMax[number, number]Volume intensity range, if already known (e.g. from a backend headers endpoint). Skips Volume.computeMinMax()'s full voxel scan
signalAbortSignalAborts the in-flight fetch. onError fires with a DOMException whose name is "AbortError"
onProgress(e: ProgressEvent) => voidDownload progress, in addition to the built-in loading bar
onError(error: unknown) => voidFetch or parse failure

Skipped a plane and need it later? ensureAxisExtracted(slices, axis, meshes?) extracts it on demand, mutating slices (and meshes) in place. NrrdTools.setSliceOrientation() already calls it for you, so an axial-only load can still switch to sagittal or coronal.

Extracting a slice yourself with Volume.extractSlice? Run prepareVolumeSlices(...slices) on the result. It gives the slice's readback buffer a willReadFrequently context (three creates it without the flag, so every repaint copies the canvas back from the GPU) and restores the geometry three's VolumeSlice constructor wipes. The loader does this for everything it extracts.


NrrdTools Usage Guide ​

Copper3D NrrdTools — Medical Image Segmentation Annotation Engine

NrrdTools manages multi-layer mask volumes, a layered canvas pipeline, drawing tools, undo/redo history, channel color customization, and keyboard shortcuts on top of a Three.js medical image viewer.

Internal Architecture: NrrdTools is a Facade using composition (no inheritance). It composes:

  • CanvasState — unified state container (nrrd_states, gui_states, protectedData, callbacks, keyboardSettings)
  • DrawToolCore — tool orchestration and event routing
  • RenderingUtils — slice extraction and canvas compositing helpers
  • LayerChannelManager — layer/channel/sphere-type management and color customization
  • SliceRenderPipeline — slice setup, canvas rendering, mask reload, canvas flip
  • DataLoader — NRRD slice loading, NIfTI voxel loading

The old inheritance chain (NrrdTools → DrawToolCore → CommToolsData) has been fully replaced. All modules communicate via ToolContext (shared state). The public API below is unchanged.


1. Quick Start ​

typescript
import * as Copper from 'copper3d';

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

nrrdTools.reset();
nrrdTools.setAllSlices(allSlices); // allSlices from Copper scene loader

nrrdTools.drag({ getSliceNum: (index) => console.log('Slice:', index) });

nrrdTools.draw({
  getMaskData: (sliceData, layerId, channelId, sliceIndex, axis, width, height, clearFlag) => {
    // Called after every stroke, undo, redo — sync to backend here
  }
});

scene.addPreRenderCallbackFunction(nrrdTools.start);
import * as Copper from 'copper3d';

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

nrrdTools.reset();
nrrdTools.setAllSlices(allSlices); // allSlices from Copper scene loader

nrrdTools.drag({ getSliceNum: (index) => console.log('Slice:', index) });

nrrdTools.draw({
  getMaskData: (sliceData, layerId, channelId, sliceIndex, axis, width, height, clearFlag) => {
    // Called after every stroke, undo, redo — sync to backend here
  }
});

scene.addPreRenderCallbackFunction(nrrdTools.start);

2. Constructor & Initialization ​

typescript
new Copper.NrrdTools(container: HTMLDivElement, options?: { layers?: string[] })
new Copper.NrrdTools(container: HTMLDivElement, options?: { layers?: string[] })
ParameterTypeDefaultDescription
containerHTMLDivElementrequiredHost DOM element for all annotation canvases
options.layersstring[]["layer1","layer2","layer3"]Named layers to create
typescript
// Custom layer set — any names, not just layerN
const nrrdTools = new Copper.NrrdTools(container, {
  layers: ['tumour', 'edema', 'necrosis', 'vessel']
});

// Read-only reference viewer — shows the image, nothing to annotate into.
// Scrub / zoom / pan / window-level / crosshair all still work.
const reference = new Copper.NrrdTools(container, { layers: [] });

// Optional: show current slice index in a panel
nrrdTools.setDisplaySliceIndexPanel(document.getElementById('slice-panel') as HTMLDivElement);

// Optional: connect dat.GUI / lil-gui
import GUI from 'lil-gui';
nrrdTools.setupGUI(new GUI() as any);
// Custom layer set — any names, not just layerN
const nrrdTools = new Copper.NrrdTools(container, {
  layers: ['tumour', 'edema', 'necrosis', 'vessel']
});

// Read-only reference viewer — shows the image, nothing to annotate into.
// Scrub / zoom / pan / window-level / crosshair all still work.
const reference = new Copper.NrrdTools(container, { layers: [] });

// Optional: show current slice index in a panel
nrrdTools.setDisplaySliceIndexPanel(document.getElementById('slice-panel') as HTMLDivElement);

// Optional: connect dat.GUI / lil-gui
import GUI from 'lil-gui';
nrrdTools.setupGUI(new GUI() as any);

3. Data Loading ​

typescript
// Reset state then load new slices
nrrdTools.reset();
nrrdTools.setAllSlices(allSlices);
// Reset state then load new slices
nrrdTools.reset();
nrrdTools.setAllSlices(allSlices);

Loading existing NIfTI mask data

Each buffer must have its own NIfTI voxel grid registered first. A buffer whose grid does not match the loaded image — or that was never registered — is refused, not truncated or zero-padded, because a wrong-grid mask would otherwise render silently offset onto the wrong voxels. Other layers in the same map still load.

typescript
import { registerNiftiMaskGrid } from 'copper3d';

registerNiftiMaskGrid(layer1Uint8Array, layer1Dims); // dims = NIfTI header [x, y, z]
registerNiftiMaskGrid(layer2Uint8Array, layer2Dims);

const layerVoxels = new Map<string, Uint8Array>([
  ['layer1', layer1Uint8Array],
  ['layer2', layer2Uint8Array],
]);
nrrdTools.setMasksFromNIfTI(layerVoxels);

// With loading progress bar
const loadingBar = { value: 0 };
nrrdTools.setMasksFromNIfTI(layerVoxels, loadingBar);
import { registerNiftiMaskGrid } from 'copper3d';

registerNiftiMaskGrid(layer1Uint8Array, layer1Dims); // dims = NIfTI header [x, y, z]
registerNiftiMaskGrid(layer2Uint8Array, layer2Dims);

const layerVoxels = new Map<string, Uint8Array>([
  ['layer1', layer1Uint8Array],
  ['layer2', layer2Uint8Array],
]);
nrrdTools.setMasksFromNIfTI(layerVoxels);

// With loading progress bar
const loadingBar = { value: 0 };
nrrdTools.setMasksFromNIfTI(layerVoxels, loadingBar);

A refused layer is logged and reported through notifyUser, which defaults to a console write — the package ships without a UI and does not reach into yours. Point it at your own:

typescript
nrrdTools.notifyUser = (message, level) => toast[level](message); // 'error' | 'warning' | 'info'
nrrdTools.notifyUser = (message, level) => toast[level](message); // 'error' | 'warning' | 'info'

Previewing one slice before the volume loads

typescript
// Put one plane on screen immediately, positioned as slice `index` of the full stack
nrrdTools.showPreviewSlice(header, slice, index, async (target) => {
  const next = await fetchSinglePlane(caseId, target);   // `target` is already clamped
  nrrdTools.showPreviewSlice(header, next, target, onSliceMove);
});

// Later, when the real volume arrives — keeps the previewed index
nrrdTools.switchSlicesPreservingView(allSlices);
// Put one plane on screen immediately, positioned as slice `index` of the full stack
nrrdTools.showPreviewSlice(header, slice, index, async (target) => {
  const next = await fetchSinglePlane(caseId, target);   // `target` is already clamped
  nrrdTools.showPreviewSlice(header, next, target, onSliceMove);
});

// Later, when the real volume arrives — keeps the previewed index
nrrdTools.switchSlicesPreservingView(allSlices);

While previewing, wheel/keys/drag do not move the display — the shown slice has depth 1, so the target index is handed to your callback to fetch instead. header is the full volume's geometry, not the plane's. Axial only. isPreviewing() reports the state; it ends on switchSlicesPreservingView, setAllSlices or reset.

Contrast series

A case is a series of contrasts. addSkip / removeSkip index the full contrast list; setContrastIndex indexes displaySlices, the displayed subset. Each of those ends in a full display refresh, so use the batched forms when changing several at once:

typescript
nrrdTools.setSkips([{ index: 1, skip: true }, { index: 2, skip: true }]);
nrrdTools.commitSkipsAndContrast(skipEntries, 0);        // skips + contrast, one refresh
nrrdTools.commitSeriesLoad(allSlices, skipEntries, 0);   // whole case load, one refresh
nrrdTools.setSkips([{ index: 1, skip: true }, { index: 2, skip: true }]);
nrrdTools.commitSkipsAndContrast(skipEntries, 0);        // skips + contrast, one refresh
nrrdTools.commitSeriesLoad(allSlices, skipEntries, 0);   // whole case load, one refresh

Read-only preview while loading

setAnnotationSuspended(true) blocks every input that can write into a mask, while slice scrubbing, zoom, pan and the crosshair stay live:

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);

Disabling your own toolbar is not equivalent: a tool selected before the load began stays armed on the canvas, and a stroke made during loading cannot be undone — afterLoadSlice clears the undo stack each time a slice arrives.


4. Render Loop Integration ​

typescript
// Register once after initialization
const callbackId = scene.addPreRenderCallbackFunction(nrrdTools.start);

// Unregister on teardown, then release the instance
scene.removePreRenderCallbackFunction(callbackId);
nrrdTools.dispose();
// Register once after initialization
const callbackId = scene.addPreRenderCallbackFunction(nrrdTools.start);

// Unregister on teardown, then release the instance
scene.removePreRenderCallbackFunction(callbackId);
nrrdTools.dispose();

dispose() releases what the instance holds outside its own DOM subtree — the event router's listeners (its window blur listener pins the whole engine graph), a pending slice step, and the drawing-flag timer. Without it, an app that builds a viewer per case leaks one engine graph per case. The instance must not be used afterwards; calling it twice is safe.


5. Drawing Setup ​

drag() — Slice navigation ​

typescript
nrrdTools.drag({
  showNumber: true,
  getSliceNum: (sliceIndex, contrastIndex) => updateUI(sliceIndex),
});
nrrdTools.drag({
  showNumber: true,
  getSliceNum: (sliceIndex, contrastIndex) => updateUI(sliceIndex),
});

draw() — Annotation callbacks ​

typescript
nrrdTools.draw({
  getMaskData: (sliceData, layerId, channelId, sliceIndex, axis, width, height, clearFlag?) => {
    sendSliceToBackend({ sliceData, layerId, channelId, sliceIndex, axis, width, height, clearFlag });
  },
  onClearLayerVolume: (layerId) => notifyBackendLayerCleared(layerId),
  getSphereData: (sphereOrigin, sphereRadius) => sendSphereToBackend({ sphereOrigin, sphereRadius }),
  getCalculateSpherePositionsData: (tumour, skin, rib, nipple, axis) => {
    if (tumour && skin && rib && nipple) aiBackend.runSegmentation({ tumour, skin, rib, nipple, axis });
  },
});
nrrdTools.draw({
  getMaskData: (sliceData, layerId, channelId, sliceIndex, axis, width, height, clearFlag?) => {
    sendSliceToBackend({ sliceData, layerId, channelId, sliceIndex, axis, width, height, clearFlag });
  },
  onClearLayerVolume: (layerId) => notifyBackendLayerCleared(layerId),
  getSphereData: (sphereOrigin, sphereRadius) => sendSphereToBackend({ sphereOrigin, sphereRadius }),
  getCalculateSpherePositionsData: (tumour, skin, rib, nipple, axis) => {
    if (tumour && skin && rib && nipple) aiBackend.runSegmentation({ tumour, skin, rib, nipple, axis });
  },
});

enableContrastDragEvents() — Window/Level ​

typescript
nrrdTools.enableContrastDragEvents((step, towards) => {
  console.log(`Contrast: ${towards} ${step}`);
});
nrrdTools.enableContrastDragEvents((step, towards) => {
  console.log(`Contrast: ${towards} ${step}`);
});

SphereTool — Sphere Types & Channel Mapping ​

Sphere TypeChannelDefault ColoractiveSphereType value
tumour1#10b981"tumour" (default)
nipple2#f43f5e"nipple"
ribcage3#3b82f6"ribcage"
skin4#fbbf24"skin"
typescript
nrrdTools.setActiveSphereType('nipple');  // also updates brush/fill color
const type = nrrdTools.getActiveSphereType(); // → 'tumour' | 'skin' | 'nipple' | 'ribcage'
nrrdTools.setActiveSphereType('nipple');  // also updates brush/fill color
const type = nrrdTools.getActiveSphereType(); // → 'tumour' | 'skin' | 'nipple' | 'ribcage'

Programmatic sphere placement (backend → frontend):

typescript
// Replicates full click flow internally — no user interaction needed
nrrdTools.setCalculateDistanceSphere(120, 95, 42, 'tumour');
// Coordinates are in unscaled image space; sizeFactor is applied internally
// Replicates full click flow internally — no user interaction needed
nrrdTools.setCalculateDistanceSphere(120, 95, 42, 'tumour');
// Coordinates are in unscaled image space; sizeFactor is applied internally

6. Layer & Channel Management ​

typescript
// Active layer / channel
nrrdTools.setActiveLayer('layer2');
nrrdTools.setActiveChannel(3);
const layer   = nrrdTools.getActiveLayer();
const channel = nrrdTools.getActiveChannel();

// Layer visibility
nrrdTools.setLayerVisible('layer2', false);
const visible = nrrdTools.isLayerVisible('layer2');
const visMap  = nrrdTools.getLayerVisibility(); // { layer1: true, layer2: false, ... }

// Channel visibility (per layer)
nrrdTools.setChannelVisible('layer1', 2, false);
const allChannelVis = nrrdTools.getChannelVisibility();

// Check if a layer has annotations
if (nrrdTools.hasLayerData('layer1')) await saveLayer('layer1');
// Active layer / channel
nrrdTools.setActiveLayer('layer2');
nrrdTools.setActiveChannel(3);
const layer   = nrrdTools.getActiveLayer();
const channel = nrrdTools.getActiveChannel();

// Layer visibility
nrrdTools.setLayerVisible('layer2', false);
const visible = nrrdTools.isLayerVisible('layer2');
const visMap  = nrrdTools.getLayerVisibility(); // { layer1: true, layer2: false, ... }

// Channel visibility (per layer)
nrrdTools.setChannelVisible('layer1', 2, false);
const allChannelVis = nrrdTools.getChannelVisibility();

// Check if a layer has annotations
if (nrrdTools.hasLayerData('layer1')) await saveLayer('layer1');

Reading and replacing a whole layer volume

typescript
// Move a mask from layer2 to layer3, undoably
const voxels = nrrdTools.getLayerVolume('layer2');   // a COPY, or null
if (voxels) nrrdTools.replaceLayerVolume('layer3', voxels, { undoable: true });
// Move a mask from layer2 to layer3, undoably
const voxels = nrrdTools.getLayerVolume('layer2');   // a COPY, or null
if (voxels) nrrdTools.replaceLayerVolume('layer3', voxels, { undoable: true });

copyLayerData(src, dst) also copies between layers and is faster, but it writes the target buffer directly and leaves nothing for Ctrl+Z. Use it only to mirror a backend cascade; if a person triggered the change, go through the pair above.


Mask render mode — fill or outline ​

typescript
nrrdTools.setMaskRenderMode('outline');  // stroke only the boundary
nrrdTools.setMaskRenderMode('fill');     // paint the silhouette (default)
nrrdTools.setMaskRenderMode('outline');  // stroke only the boundary
nrrdTools.setMaskRenderMode('fill');     // paint the silhouette (default)

"outline" leaves each mask's interior clear so the tissue underneath stays readable. It is a display decision only — what is stored, uploaded, exported and undone is the complete mask either way — and it applies to every layer and channel at once, repainting immediately. The pencil and eraser render the layer filled for the duration of a stroke, so you do not need to leave outline mode to annotate.


Mask mirroring — show one viewer's masks on another ​

typescript
// `reference` draws `primary`'s masks over its own image
reference.setMaskMirror(primary);

// with a row-major 4×4 mapping reference's voxel (x, y, z, 1) → primary's voxel
reference.setMaskMirror(primary, mirrorToSource);

reference.setMaskMirror(null);   // detach
// `reference` draws `primary`'s masks over its own image
reference.setMaskMirror(primary);

// with a row-major 4×4 mapping reference's voxel (x, y, z, 1) → primary's voxel
reference.setMaskMirror(primary, mirrorToSource);

reference.setMaskMirror(null);   // detach

Everything is read live from the source — volumes, channel colours, layer and channel visibility, per-layer opacity, fill/outline mode — so changing any of them on the source updates the mirror on the next frame. It is display only: nothing is written to either viewer's masks, and the mirror holds no mask data of its own.

A mirroring viewer needs no layers, so { layers: [] } pairs naturally with this. dispose() breaks the link from both ends.


Zoom range — showing below 1:1 ​

The zoom factor used to bottom out at 1. Lower the floor and a large volume can be fitted into a small panel:

typescript
nrrdTools.setMinSizeFactor(0.5);   // clamped to [0.05, 1]; default 1
nrrdTools.setMainAreaSize(0.6);    // now valid
nrrdTools.setMinSizeFactor(0.5);   // clamped to [0.05, 1]; default 1
nrrdTools.setMainAreaSize(0.6);    // now valid

setMainAreaSize, wheel zoom and the "mainAreaSize" GUI slider all follow it; the upper bound stays 8. If the current factor is below the new floor it is raised and repainted.

The engine never recomputes the floor — the right value depends on the image and axis dimensions and your panel size — so re-apply it after each case load, axis switch and panel resize.


7. Channel Color Customization ​

Default colors:

ChannelColorHex
1Emerald#10b981
2Rose#f43f5e
3Blue#3b82f6
4Amber#fbbf24
5Fuchsia#d946ef
6Cyan#06b6d4
7Orange#f97316
8Violet#8b5cf6

Channels 9–255 are generated from a golden-angle hue walk (137.508° per step) at fixed saturation and lightness — only hue varies, because a mask that changes brightness between channels reads as a difference in the greyscale image rather than in the annotation. The eight seeds above are kept verbatim, so cases annotated before this keep the colours they were signed off with. Copper.MAX_ENGINE_CHANNEL is the ceiling (255 — one byte per voxel, and the value is the label).

Distinct is not the same as distinguishable: past roughly twenty channels, neighbouring hues stop being tellable apart by eye on a greyscale background. All 255 are addressable; how many your product should offer at once is a separate question.

typescript
// Set one channel color (RGBAColor: { r, g, b, a } — 0-255)
nrrdTools.setChannelColor('layer1', 3, { r: 255, g: 128, b: 0, a: 255 });

// Batch-set (one reloadMasksFromVolume call — better performance)
nrrdTools.setChannelColors('layer1', {
  1: { r: 255, g: 80, b: 80, a: 255 },
  2: { r: 80, g: 180, b: 255, a: 255 },
});

// Apply same channel color across all layers
nrrdTools.setAllLayersChannelColor(1, { r: 0, g: 220, b: 100, a: 255 });

// Read colors
const rgba = nrrdTools.getChannelColor('layer1', 3);
const hex  = nrrdTools.getChannelHexColor('layer1', 3);  // → "#ff8000"
const css  = nrrdTools.getChannelCssColor('layer1', 3);  // → "rgba(255,128,0,1.00)"

// Reset
nrrdTools.resetChannelColors('layer1', 3); // one channel
nrrdTools.resetChannelColors('layer1');    // all channels in layer
nrrdTools.resetChannelColors();            // everything
// Set one channel color (RGBAColor: { r, g, b, a } — 0-255)
nrrdTools.setChannelColor('layer1', 3, { r: 255, g: 128, b: 0, a: 255 });

// Batch-set (one reloadMasksFromVolume call — better performance)
nrrdTools.setChannelColors('layer1', {
  1: { r: 255, g: 80, b: 80, a: 255 },
  2: { r: 80, g: 180, b: 255, a: 255 },
});

// Apply same channel color across all layers
nrrdTools.setAllLayersChannelColor(1, { r: 0, g: 220, b: 100, a: 255 });

// Read colors
const rgba = nrrdTools.getChannelColor('layer1', 3);
const hex  = nrrdTools.getChannelHexColor('layer1', 3);  // → "#ff8000"
const css  = nrrdTools.getChannelCssColor('layer1', 3);  // → "rgba(255,128,0,1.00)"

// Reset
nrrdTools.resetChannelColors('layer1', 3); // one channel
nrrdTools.resetChannelColors('layer1');    // all channels in layer
nrrdTools.resetChannelColors();            // everything

8. Undo / Redo ​

Per-layer undo stack (max 50 entries). Every completed stroke pushes a delta snapshot.

typescript
nrrdTools.undo();
nrrdTools.redo();

// Or via keyboard
window.addEventListener('keydown', (e) => {
  if (e.ctrlKey && e.key === 'z') nrrdTools.undo();
  if (e.ctrlKey && e.key === 'y') nrrdTools.redo();
});
nrrdTools.undo();
nrrdTools.redo();

// Or via keyboard
window.addEventListener('keydown', (e) => {
  if (e.ctrlKey && e.key === 'z') nrrdTools.undo();
  if (e.ctrlKey && e.key === 'y') nrrdTools.redo();
});

9. Keyboard Shortcuts ​

ActionDefault Key
Draw modeShift (hold)
Undoz
Redoy
Contrast adjustCtrl / Meta (hold)
Crosshairs
Mouse wheelZoom

Breaking: sphere mode no longer has a shortcut. sphere: "q" is gone and sphere has been removed from IKeyBoardSettings — it toggled the mode behind the host's back, clearing the masks from the view while the host's UI still showed its own tool as selected. Enter it with setMode('sphere') and bind your own key if you want one.

typescript
nrrdTools.setKeyboardSettings({ undo: 'u', mouseWheel: 'Scroll:Slice' });
const settings = nrrdTools.getKeyboardSettings();

// Suppress shortcuts while a form input is focused
inputEl.addEventListener('focus', () => nrrdTools.enterKeyboardConfig());
inputEl.addEventListener('blur',  () => nrrdTools.exitKeyboardConfig());
nrrdTools.setKeyboardSettings({ undo: 'u', mouseWheel: 'Scroll:Slice' });
const settings = nrrdTools.getKeyboardSettings();

// Suppress shortcuts while a form input is focused
inputEl.addEventListener('focus', () => nrrdTools.enterKeyboardConfig());
inputEl.addEventListener('blur',  () => nrrdTools.exitKeyboardConfig());

10. Clearing Annotations ​

typescript
nrrdTools.reset();            // Reset ALL layers, volumes, undo histories, canvases (use when switching cases)
nrrdTools.clearActiveLayer(); // Clear active layer's entire 3D volume + undo history, fire onClearLayerVolume
nrrdTools.clearActiveSlice(); // Clear only the currently viewed 2D slice (undoable)
nrrdTools.clearChannel('layer1', 3); // Erase ONE channel across the whole layer (undoable)
nrrdTools.reset();            // Reset ALL layers, volumes, undo histories, canvases (use when switching cases)
nrrdTools.clearActiveLayer(); // Clear active layer's entire 3D volume + undo history, fire onClearLayerVolume
nrrdTools.clearActiveSlice(); // Clear only the currently viewed 2D slice (undoable)
nrrdTools.clearChannel('layer1', 3); // Erase ONE channel across the whole layer (undoable)

clearChannel is the only one of these that can delete a single annotation: a finding's mask is one label inside a volume it shares with every other annotation on the layer, so anything coarser takes the neighbours with it. Its deltas go on the undo stack, so undo() brings the channel back — which is why you should use it rather than writing zeros into the volume yourself. It throws on an unknown layer, or a channel outside [1, 255].


11. 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 / lil-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, sphere data
clearActiveLayer()Clear active layer volume + undo history
clearActiveSlice()Clear current slice (undoable)
clearChannel(id, ch)Erase one channel across a whole layer (undoable); throws on unknown layer or channel outside [1, 255]
setAllSlices(slices)Load NRRD slices, init MaskVolumes
setMasksFromNIfTI(map, bar?)Load saved NIfTI voxel data (grids must be registered first)
registerNiftiMaskGrid(data, dims)(module export) Record a mask buffer's NIfTI grid for validation
notifyUser(assignable property) How engine messages reach the reader; defaults to a console write
Contrast SeriesaddSkip(i) / removeSkip(i)Hide / show one contrast (full-list index)
setSkips(entries)Batched skip changes — one display refresh
setContrastIndex(i)Move to a contrast within displaySlices
commitSkipsAndContrast(entries, i)Batched skips + contrast — one refresh
commitSeriesLoad(slices, entries, i)Case-load completion — one refresh
switchAllSlicesArrayData(slices)Swap the series (resets view state)
switchSlicesPreservingView(slices)Swap the series, keep slice index / zoom / pan
PreviewshowPreviewSlice(header, slice, i, cb)Show one depth-1 slice as slice i of the not-yet-loaded stack; slice moves go to cb instead of moving the display. Axial only
isPreviewing()Whether a preview slice is shown
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)Read one layer's opacity
getLayerOpacityMap()All per-layer opacity values
MirrorsetMaskMirror(src, m?)Draw another viewer's masks, read live. m = row-major 4×4 voxel→voxel; null detaches. Display only
getMaskMirrorSource()The viewer being mirrored, or null
SpheresetActiveSphereType(type)Set active sphere type, updates brush color
getActiveSphereType()Read current sphere type
setCalculateDistanceSphere(x, y, slice, type)Programmatically place a calculator sphere
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: "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
setBrushSize(size)Set brush/eraser size [5, 50]
getBrushSize()Read current brush size
setPencilColor(hex)Set pencil stroke color (hex string)
getPencilColor()Read current pencil color
setMaskRenderMode(mode)"fill" or "outline" for every layer/channel; repaints immediately. Display only — the stored mask is unchanged
getMaskRenderMode()Read the current render mode
ContrastsetWindowHigh(value)Set window high
setWindowLow(value)Set window low
finishWindowAdjustment()Repaint all contrast slices after drag ends
adjustContrast(type, delta)Nudge "windowHigh" / "windowLow" by a delta
getSliderMeta(key)Slider min/max/step/value for UI config ("globalAlpha", "layerAlpha", "brushAndEraserSize", …)
ActionsexecuteAction(action, opts?)Run: "undo" / "redo" / "clearActiveSliceMask" / "clearActiveLayerMask" / "resetZoom" / "downloadCurrentMask" / "gaussianSmooth" (takes { sigma? })
NavigationsetSliceOrientation(axis)Switch viewing axis "x" / "y" / "z"; extracts the plane on demand if the load skipped it
setSliceMoving(step)Step the current slice (coalesced per animation frame)
setMainAreaSize(factor)Set the main-area zoom factor [minSizeFactor, 8]
setMinSizeFactor(f)Lower the zoom floor below 1:1. Clamped to [0.05, 1]; default 1. Re-apply after case loads, axis switches and panel resizes
getMinSizeFactor()Read the current zoom floor
Historyundo() / redo()Undo / redo last stroke
KeyboardsetKeyboardSettings(partial)Remap shortcuts
getKeyboardSettings()Read current bindings
enterKeyboardConfig() / exitKeyboardConfig()Suppress / restore shortcuts
setContrastShortcutEnabled(bool)Enable/disable contrast key
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
getContainer()Host HTMLElement
getDrawingCanvas()Top-layer HTMLCanvasElement

12. Type Reference ​

typescript
interface RGBAColor { r: number; g: number; b: number; a: number; } // 0-255

type ChannelColorMap = Record<number, RGBAColor>; // key = channel number, 1-255

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;
}

interface IDragOpts {
  showNumber?: boolean;
  getSliceNum?: (sliceIndex: number, contrastIndex: number) => void;
}

interface IKeyBoardSettings {
  draw: string;
  undo: string;
  redo: string;
  contrast: string[];
  crosshair: string;
  mouseWheel: 'Scroll:Zoom' | 'Scroll:Slice';
}

interface ICommXYZ { x: number; y: number; z: number; }

type LayerId        = 'layer1' | 'layer2' | 'layer3' | 'layer4'; // or any string
type ChannelValue   = number; // 0 = empty/erased, 1..MAX_ENGINE_CHANNEL (255)
type MaskRenderMode = 'fill' | 'outline';
interface RGBAColor { r: number; g: number; b: number; a: number; } // 0-255

type ChannelColorMap = Record<number, RGBAColor>; // key = channel number, 1-255

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;
}

interface IDragOpts {
  showNumber?: boolean;
  getSliceNum?: (sliceIndex: number, contrastIndex: number) => void;
}

interface IKeyBoardSettings {
  draw: string;
  undo: string;
  redo: string;
  contrast: string[];
  crosshair: string;
  mouseWheel: 'Scroll:Zoom' | 'Scroll:Slice';
}

interface ICommXYZ { x: number; y: number; z: number; }

type LayerId        = 'layer1' | 'layer2' | 'layer3' | 'layer4'; // or any string
type ChannelValue   = number; // 0 = empty/erased, 1..MAX_ENGINE_CHANNEL (255)
type MaskRenderMode = 'fill' | 'outline';

Acknowledgements ​

Special thanks to Duke University dataset for providing the MRI data.