Files
sciagent/docs/architecture/medical-imaging-3d-viewer-spec.md
Thinh Lam 688fac73e9
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped
sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:38:30 +07:00

25 KiB
Raw Permalink Blame History

Medical-Imaging 3D Viewer — Reconstruction Spec

A self-contained spec for the VTK.js quad-view DICOM/NIfTI viewer + the organ-mask overlay system, written so it can be rebuilt from scratch in another React app. It captures the architecture, the public API, every VTK module + magic number, the interaction model, and the hard-won gotchas (each marked ⚠️).

Source of truth: shared/src/components/viewer/ in this repo. This doc is a snapshot (2026-06-20) — if it disagrees with the code, the code wins.


1. What it is

A 4-pane ("quad view") medical image viewer rendered with VTK.js into a single WebGL canvas:

┌─────────────┬─────────────┐
│   AXIAL     │   CORONAL   │   3 panes  = orthogonal 2D slices (reslice of the volume)
│  (2D slice) │  (2D slice) │   1 pane   = 3D volume rendering
├─────────────┼─────────────┤
│  SAGITTAL   │     3D      │   + per-organ mask overlays (3D surface + 2D fills)
│  (2D slice) │  (volume)   │   + a 5-tool annotation layer on the 2D panes
└─────────────┴─────────────┘

It loads a single .nii/.nii.gz (NIfTI) or a set of .dcm (DICOM) files, shows three orthogonal slices + a 3D volume, supports window/level + opacity, slice-scroll, zoom, double-click-to-expand, client-side annotations, and colored organ-segmentation overlays.


2. Tech stack & exact dependencies

Package Version Role
@kitware/vtk.js ^34.16.2 (built/ran on 34.18) All rendering. ~960 KB — code-split it (see §4).
nifti-reader-js ^0.8.0 NIfTI header/image parse + gzip decompress.
react / react-dom ^18.3.1 Component shell + hooks. React 18 auto-batches (matters for the overlay toggle).
@radix-ui/react-dropdown-menu ^2.1.15 Only used by ViewRotationControls (optional).
lucide-react ^0.462.0 Icons (host UI only — not core).

DICOM parsing additionally uses a DICOM lib inside useDicomData (e.g. dicom-parser / cornerstone-style decode) — out of scope here; the NIfTI path is the reference.

VTK.js profile imports (MUST be imported once, before any vtk instance):

import "@kitware/vtk.js/Rendering/Profiles/Volume";   // volume rendering
import "@kitware/vtk.js/Rendering/Profiles/Geometry";  // surfaces (organ overlays), slices
import "@kitware/vtk.js/Rendering/Misc/RenderingAPIs";  // OpenGL backend

3. File inventory (shared/src/components/viewer/)

File LOC Responsibility
index.ts 14 The @ump/shared/viewer barrel — the only public entry.
types.ts 92 NiftiData, OrganMaskData, Annotation* contracts.
niftiLoader.ts 121 parseNiftiBuffer() + non-hook loadNiftiImageData().
useNiftiData.ts 48 React hook wrapping parseNiftiBuffer for the main image.
useDicomData.ts 219 DICOM equivalent of useNiftiData.
UnifiedQuadViewRenderer.tsx 189 Format dispatch (NIfTI vs DICOM) + prop pass-through.
NiftiQuadViewRenderer.tsx 1249 The core — quad view, slices, volume, interaction, overlays.
QuadViewRenderer.tsx 820 DICOM equivalent (same structure, DICOM input).
AnnotationOverlay.tsx 254 Per-pane 2D drawing surface (5 tools).
ViewRotationControls.tsx 128 Optional 3D-orientation dropdown.

The host (not in shared): a full-screen dialog that mounts UnifiedQuadViewRenderer, owns the window/level/opacity sliders, the annotation toolbar, and the organ panel.


4. Packaging / bundle strategy ⚠️

VTK is heavy (~960 KB). Expose the viewer as a lazy subpath, never from the main barrel, so VTK lands in its own async chunk and never bloats a page's initial bundle.

shared/package.json:

{ "name": "@ump/shared",
  "exports": { ".": "./src/index.ts", "./viewer": "./src/components/viewer/index.ts" } }

viewer/index.ts (the entire public API):

export { UnifiedQuadViewRenderer } from './UnifiedQuadViewRenderer';
export type { UnifiedQuadViewRendererProps, FileFormat } from './UnifiedQuadViewRenderer';
export type { Annotation, AnnotationTool, AnnotationPoint } from './types';
export { loadNiftiImageData, parseNiftiBuffer } from './niftiLoader';
export type { OrganMaskData, OrganName } from './types';

Consume it via React.lazy:

const ViewerDialog = lazy(() => import('./DatasetFileViewerDialog')); // statically imports @ump/shared/viewer

⚠️ Vite/TS alias ORDER: when @ump/shared is aliased to source, the subpath @ump/shared/viewer needs its own alias entry listed before the bare one (array form, so prefix-matching picks the longer key first):

// vite.config.ts
resolve: { alias: [
  { find: '@ump/shared/viewer', replacement: path.resolve(__dirname, '../shared/src/components/viewer/index.ts') },
  { find: '@ump/shared',        replacement: path.resolve(__dirname, '../shared/src/index.ts') },
]}
// tsconfig.json
"paths": {
  "@ump/shared/viewer": ["../shared/src/components/viewer/index.ts"],
  "@ump/shared": ["../shared/src/index.ts"]
}

⚠️ Keep the NIfTI loader (loadNiftiImageData) and OrganMaskData in the /viewer subpath — importing them from the main barrel would pull VTK into the page's initial bundle.


5. Data model (types.ts, verbatim)

export interface NiftiData {
  header: { dims: number[]; pixDims: number[]; datatype: number; littleEndian: boolean;
            voxOffset: number; affine: number[][]; description: string };
  imageData: any;            // vtkImageData
  rawData: ArrayBuffer;
  dimensions: [number, number, number];   // [nx, ny, nz]
  spacing: [number, number, number];      // [sx, sy, sz]
}

export type OrganName = string;
export interface OrganMaskData {
  id?: string;               // ⚠️ STABLE unique key (the mask file id). Renderer keys by this.
  organName: OrganName;      // display label
  imageData: any;            // vtkImageData of the binary mask (0 = bg, >0 = organ)
  color: [number, number, number];   // 0-255 RGB
}

export type AnnotationTool = 'none' | 'bbox' | 'points' | 'pen' | 'brush' | 'polygon';
export interface AnnotationPoint { x: number; y: number }   // normalized [0..1] to the pane
export interface Annotation {
  id: string; view: 'axial' | 'coronal' | 'sagittal'; sliceIndex: number;
  tool: Exclude<AnnotationTool, 'none'>; points: AnnotationPoint[];
  color: string; strokeWidth?: number; label?: string;
}

6. Public API — UnifiedQuadViewRendererProps

interface UnifiedQuadViewRendererProps {
  files: File[];                 // 1 NIfTI File, or N DICOM Files
  windowWidth: number;           // CT window width (e.g. 400)
  windowLevel: number;           // CT window level (e.g. 40)
  opacity: number;               // 3D volume opacity 0..1 (e.g. 0.8)
  isLoading?: boolean;
  onRotate3D?: (o: ViewOrientation) => void;
  // segmentation/MedSAM props (optional, unused unless you wire a backend):
  segmentationEnabled?: boolean; boundingBox?; onBoundingBoxChange?; segmentationMask?;
  currentSliceIndex?: number; onSliceIndexChange?;
  // organ-mask overlays:
  organMasks?: OrganMaskData[];          // ← selected organs to overlay (3D + 2D)
  // annotations:
  annotationTool?: AnnotationTool;
  annotations?: Annotation[];
  onAnnotationsChange?: (a: Annotation[]) => void;
}

UnifiedQuadViewRenderer detects format from file extension (.nii/.nii.gz → NIfTI, .dcm/.dicom → DICOM), runs the matching hook (useNiftiData/useDicomData), and renders NiftiQuadViewRenderer or QuadViewRenderer, forwarding all props. ⚠️ organMasks is forwarded only on the NIfTI path — DICOM ignores it.


7. NIfTI loading pipeline (niftiLoader.ts)

Pure, non-hook, throws on bad input. Reused by both the main-image hook and the organ-mask loader (DRY — one parser).

export function parseNiftiBuffer(input: ArrayBuffer): NiftiData {
  let buf = input;
  if (nifti.isCompressed(buf)) buf = nifti.decompress(buf) as ArrayBuffer;   // .nii.gz
  if (!nifti.isNIFTI(buf)) throw new Error('Not a valid NIfTI file');
  const header = nifti.readHeader(buf);
  const image  = nifti.readImage(header, buf);

  const [ , nx, ny, nz ] = header.dims;
  const sx = Math.abs(header.pixDims[1]) || 1, sy = Math.abs(header.pixDims[2]) || 1,
        sz = Math.abs(header.pixDims[3]) || 1;

  // datatype → typed array (UINT8/INT8/UINT16/INT16/FLOAT32 direct; FLOAT64/INT32 → Float32; default Float32)
  let typed = /* switch(header.datatypeCode) … */;
  // scl_slope / scl_inter scaling (skip when slope===1 && inter===0)
  const slope = header.scl_slope || 1, inter = header.scl_inter || 0;
  const scaled = (slope !== 1 || inter !== 0)
    ? Float32Array.from(typed, v => v * slope + inter)
    : (typed instanceof Float32Array ? typed : new Float32Array(typed));

  const imageData = vtkImageData.newInstance();
  imageData.setDimensions([nx, ny, nz]);
  imageData.setSpacing([sx, sy, sz]);                 // ⚠️ origin left at (0,0,0)
  imageData.getPointData().setScalars(
    vtkDataArray.newInstance({ name: 'Scalars', numberOfComponents: 1, values: scaled }));

  return { header: {}, imageData, rawData: buf, dimensions: [nx,ny,nz], spacing: [sx,sy,sz] };
}

export async function loadNiftiImageData(file: File) {        // for organ masks
  return parseNiftiBuffer(await file.arrayBuffer()).imageData;
}

useNiftiData(file) just wraps this in a useEffect with a lastFileRef dedupe (key = name-size-lastModified) and { niftiData, isLoading, error } state.

⚠️ No affine/world transform is applied — only dims + spacing, origin (0,0,0). Two volumes (image + mask) only co-register if they share the same grid. See §11.


8. The quad-view rendering core (NiftiQuadViewRenderer.tsx)

8.1 Scene graph (built once, in an init useEffect)

  • 1 vtkRenderWindow + 1 vtkOpenGLRenderWindow (.setContainer(containerDiv), .setSize(rect.w, rect.h) in CSS px).
  • 1 vtkRenderWindowInteractor (.setView, .initialize, default style = trackball).
  • 4 vtkRenderer, each .setViewport(...) into a quadrant; backgrounds: 2D panes [0,0,0], 3D pane [0.1,0.1,0.15]. renderWindow.addRenderer(ren) for each.
  • Per renderer, an absolutely-positioned HTML <div> overlay (the visible border + label), appended to the container (see §8.3).

Store everything in a useRef "context": { renderWindow, renderWindowView, interactor, renderers[4], containers[4], imageSliceActors[3], slicePlanes[3], volumeActor, ctf, pf, iStyle, tStyle, organMaskActors: Map }.

8.2 Viewport layouts (normalized [xmin, ymin, xmax, ymax], GL origin bottom-left)

// 2×2 grid (default): note the 0.01 margin + 0.02 gutter between panes
axial    = [0.01, 0.51, 0.49, 0.99]   // top-left
coronal  = [0.51, 0.51, 0.99, 0.99]   // top-right
sagittal = [0.01, 0.01, 0.49, 0.49]   // bottom-left
threeD   = [0.51, 0.01, 0.99, 0.49]   // bottom-right

// Expanded (double-click a pane): main left, 3 stacked right
main  = [0.01, 0.01, 0.74, 0.99]
side  = [ [0.75,0.67,0.99,0.99], [0.75,0.34,0.99,0.66], [0.75,0.01,0.99,0.33] ]

8.3 HTML border overlays ⚠️ (use percentages, not px)

Each pane has a transparent <div> (2px border + a corner label) over the canvas. Position it as percentages of the container, matching the renderer's normalized viewport:

el.style.position = 'absolute';
el.style.left   = `${vp[0] * 100}%`;
el.style.bottom = `${vp[1] * 100}%`;
el.style.width  = `${(vp[2]-vp[0]) * 100}%`;
el.style.height = `${(vp[3]-vp[1]) * 100}%`;
el.style.boxSizing = 'border-box';            // set at creation
el.style.border = 'solid 2px hsl(var(--border))';

⚠️ Do NOT compute px from getBoundingClientRect() — the viewer often opens inside a dialog that animates with transform: scale(.95→1), and ResizeObserver does NOT fire on transform changes, so a rect captured mid-animation freezes ~5% small/offset while the canvas (width:100%) stretches to fill → content spills past the frame. Percentages track the canvas under any transform/resize.

8.4 Slices (the 3 2D panes)

axialPlane.setNormal(0,0,1);  coronalPlane.setNormal(0,1,0);  sagittalPlane.setNormal(1,0,0);
// per view i: mapper = vtkImageResliceMapper({ slicePlane: planes[i] }); actor = vtkImageSlice(mapper)
// camera = parallel projection; positioned per medical convention (§8.6)

On data load, set every plane's origin to the volume center. imageSliceActors[i] = { actor, mapper, ctf }.

8.5 3D volume (the 3D pane)

volumeMapper = vtkVolumeMapper({ sampleDistance: 1.0 });
volumeActor  = vtkVolume(); volumeActor.setMapper(volumeMapper);
// shade on, ambient .2 / diffuse .7 / specular .3 / specularPower 8
// on data load: mapper.setInputData(im); renderer3D.removeAllVolumes(); renderer3D.addVolume(volumeActor)
// setScalarOpacityUnitDistance(0, diagonal / max(dims)); gradientOpacity min 0 / max (range*0.05)

8.6 Cameras (medical orientations, set once on data load) — verbatim

// center = volume bounds center; d = boundingBox.diagonalLength * 1.5
axial:    pos(cx, cy, cz-1)  focal(center)  viewUp(0,-1,0)   then renderer.resetCamera()
coronal:  pos(cx, cy-1, cz)  focal(center)  viewUp(0, 0,1)   then resetCamera()
sagittal: pos(cx-1, cy, cz)  focal(center)  viewUp(0, 0,1)   then resetCamera()
3D:       rotate3DView('anterior')
// rotate3DView(o): focal = center; viewUp/pos per orientation:
//   anterior  pos(cx, cy-d, cz) up(0,0,1)      posterior pos(cx, cy+d, cz) up(0,0,1)
//   left-lat  pos(cx-d, cy, cz) up(0,0,1)      right-lat pos(cx+d, cy, cz) up(0,0,1)
//   superior  pos(cx, cy, cz+d) up(0,1,0)      inferior  pos(cx, cy, cz-d) up(0,-1,0)

8.7 Window/level + opacity transfer functions (on every slider change)

const low = level - width/2, high = level + width/2;
// 2D grayscale (per slice ctf): points (low-1,0,0,0)(low,0,0,0)(high,1,1,1)(high+1,1,1,1)
//   + actor.getProperty().setColorWindow(width); setColorLevel(level);
// 3D volume color (bone/soft-tissue ramp):
ctf: (low-200, 0,0,0)(low, .4,.2,.1)(low+.3Δ, .8,.6,.5)(low+.5Δ, .9,.8,.7)(high, 1,1,.9)(high+200, 1,1,1)
pf : (low-200,0)(low,0)(low+.2Δ, op*.2)(low+.5Δ, op*.5)(high, op)        // Δ = high-low

8.8 Slice scrolling + zoom (wheel listener per 2D pane)

// plain wheel  → slice nav: axisIndex = view===axial?2 : view===coronal?1 : 0
//   plane.origin[axisIndex] += spacing[axisIndex] * (deltaY>0?1:-1); clamp to bounds; render
// Ctrl/⌘ wheel → camera.zoom(deltaY<0 ? 1.1 : 0.9)

Attach with { passive: false } and preventDefault().

8.9 Resize

ResizeObserver on the container → renderWindowView.setSize(rect.w, rect.h) + reposition the % border divs + render().


9. Interaction model ⚠️ (the subtle part)

One interactor, bound to the full canvas container (never per-pane). Two styles: vtkInteractorStyleImage (2D: pan/zoom/window-level) and vtkInteractorStyleTrackballCamera (3D: rotate). Each pane <div> carries dataset.viewId = "0..3".

  • Style swap by pane: on pointerenter/mousedown, interactor.setInteractorStyle(viewId==='3' ? trackball : image), and bind events to the full container (once).
  • ⚠️ Confine a drag to its origin pane: VTK re-resolves the "poked" renderer on every mouse-move (findPokedRenderer), and trackball/image act on that renderer — so a drag begun in the 3D pane that wanders into a 2D pane retargets the 2D camera. findPokedRenderer skips renderers whose getInteractive() is false, so on mousedown set every OTHER renderer setInteractive(false), restore all on mouseup + a global mouseup.
  • ⚠️ Double-click to expand: VTK takes pointer capture on press, so the native dblclick/mouseup/click fire on the parent container, not the per-pane div — a dblclick listener on the div never fires. Detect it on the div's mousedown via e.detail === 2 (the 2nd press of a double-click), then toggle expandedView.
  • ⚠️ resetCamera() on expand: when the layout changes, re-run renderers.forEach(r => r.resetCamera()) after setViewport, else the enlarged pane keeps the tiny framing it had as a quadrant (content stuck in a corner). resetCamera preserves direction + view-up (orientation/rotation kept).
  • ⚠️ VTK + Vite HMR: Fast Refresh leaves stale inputless mappers → console floods No input! + black panes. Always verify on a FULL reload, not HMR.

10. Annotation overlay (AnnotationOverlay.tsx)

One overlay <div> per 2D pane, absolute inset-0, rendering an SVG of the annotations. Geometry is normalized [0..1] to the pane and tagged with {view, sliceIndex} so an annotation only shows on the slice it was drawn on. Tools: bbox (2-pt drag), points (click), pen (polyline w=2), brush (polyline w=16, 0.55 opacity), polygon (click vertices, double-click to close ≥3 pts).

  • Pointer-transparent when tool === 'none' (pointerEvents: none) so VTK keeps scroll/zoom/rotate; pointerEvents: auto + cursor: crosshair when a tool is active.
  • ⚠️ Capture with NATIVE listeners that stopPropagation() + setPointerCapture — and also interactor.disable() while a tool is active in the renderer. stopPropagation() ALONE is insufficient: VTK's native canvas listener fires before any React handler can stop it.
  • Latest tool/callbacks kept in refs so the once-bound native listeners always see current values without re-binding mid-drag.
  • dblclick while a polygon draft has ≥3 pts → close it; otherwise → forward to onRequestExpand() (expand the pane).
  • wheel while a tool is active → stopPropagation + forward {deltaY, ctrlKey, metaKey} to the host, which re-dispatches a synthetic wheel on the underlying pane so slice-scroll keeps working under the overlay.

11. Organ-mask overlays ⚠️ (3D surface + 2D fills)

Driven by the organMasks: OrganMaskData[] prop. A useEffect([organMasks]) diffs them against an organMaskActors: Map<key, {...}> and adds/removes per organ. ⚠️ Key by maskData.id ?? organName, NOT the display label — two organs sharing a label otherwise collapse into one (and the UI lies "visible" with nothing rendered).

11.1 3D overlay — render a SURFACE, not a second volume

⚠️ vtk.js does NOT composite two overlapping volumes in one renderer — an overlay volume silently fails to show even with valid data. Render the binary mask as a colored iso-surface instead (also reads better as a solid shell):

const mc = vtkImageMarchingCubes.newInstance({ contourValue: 0.5, computeNormals: true, mergePoints: true });
mc.setInputData(maskImageData);
const mapper = vtkMapper.newInstance(); mapper.setInputConnection(mc.getOutputPort()); mapper.setScalarVisibility(false);
const actor = vtkActor.newInstance(); actor.setMapper(mapper);
actor.getProperty().setColor(r/255, g/255, b/255); actor.getProperty().setOpacity(0.7);
renderer3D.addActor(actor);

⚠️ After adding overlay geometry call renderer3D.resetCameraClippingRange() before render(). The camera's far-clip was set for the main volume (e.g. far=1000) while the surface sits ~10181283 away → it is entirely culled → empty 3D pane. This (not the multi-volume issue) is the usual "nothing renders" cause; diagnose by logging actor.getBounds() vs camera.getClippingRange(). ⚠️ ImageMarchingCubes ships no .d.ts// @ts-expect-error the import. ⚠️ PERF: marching cubes on a 512³ mask ≈ ~9 s main-thread (no worker), and it runs in the effect after the host's loading spinner clears → a frozen UI. Mitigate by cropping to the mask's non-zero bbox before MC, or a Web Worker, or precomputing during load.

11.2 2D overlay — reslice onto the slice panes, sharing the main slice planes

For each view vi ∈ {0,1,2}, reslice the same mask with the same slicePlanes[vi] the main image uses (so it tracks slice-scroll for free — the wheel handler already mutates that plane + renders):

const m = vtkImageResliceMapper.newInstance(); m.setSlicePlane(slicePlanes[vi]); m.setInputData(maskImageData);
const ctf = vtkColorTransferFunction.newInstance(); ctf.addRGBPoint(0,0,0,0); ctf.addRGBPoint(1, r/255,g/255,b/255);
const pwf = vtkPiecewiseFunction.newInstance(); pwf.addPoint(0,0); pwf.addPoint(0.5,0); pwf.addPoint(1, 0.6); // opacity
const slice = vtkImageSlice.newInstance(); slice.setMapper(m);
slice.getProperty().setRGBTransferFunction(0, ctf);
slice.getProperty().setPiecewiseFunction(0, pwf);
slice.getProperty().setColorWindow(1);  slice.getProperty().setColorLevel(0.5);   // ⚠️ see below
slice.getProperty().setInterpolationTypeToNearest();
renderers[vi].addActor(slice);

⚠️ setColorWindow(1) + setColorLevel(0.5) are mandatory. The default color window (255) squashes the binary value 1 to ~0 on the transfer function → the mask renders near-black on the slice instead of the organ color. Window 1 / level 0.5 maps data 0→0, 1→1. No z-fighting in practice (coplanar with the main slice, added after).

11.3 Lifecycle

Store { actor(surface), mapper, ctf: mcFilter, pf: null, sliceActors: [{actor,mapper,ctf,pf}×3] } in the Map. On toggle-off: removeActor the surface from renderer3D + each slice from renderers[vi], then .delete() all. ⚠️ Also free them in the component's unmount cleanup (iterate the Map) — they leak otherwise when the viewer closes with organs selected.


12. Host integration (the dialog)

The viewer is mounted full-screen and fed by a host that owns the controls. Minimal shape:

<UnifiedQuadViewRenderer
  files={[file]} windowWidth={ww} windowLevel={wl} opacity={op}
  organMasks={organMasks}                          // ← selected organs
  annotationTool={tool} annotations={annos} onAnnotationsChange={setAnnos} />
  • Control bar: range sliders → windowWidth (1..4000), windowLevel (-1000..3000), opacity (0..1).
  • Organ panel: lists the available masks (id, label, color swatch). Toggling an organ lazily loads its mask: presignURL → fetch as File → loadNiftiImageData(file) → OrganMaskData{ id, organName, imageData, color }, cache by id, and derive organMasks = selectedIds.map(id => cache[id]). Assign a stable color per organ by list index from a fixed palette.
  • ⚠️ Loader lives behind the lazy boundary — the dialog (already VTK-heavy) imports loadNiftiImageData from @ump/shared/viewer; the host page only uses VTK-free helpers (presign/fetch) so the page bundle stays clean.
  • Full-screen dialog: use inset-0 (not w-screen/h-screen, which overflows by the scrollbar width); a flex-1 child in a non-definite-height parent collapses to 0 → give a definite height (h-screen flex-col).

13. Consolidated gotcha checklist ⚠️

  1. % border divs, never px from getBoundingClientRect() (transform-stale-rect).
  2. Confine drags to the origin pane via setInteractive(false) on the others (VTK re-pokes every move).
  3. Expand = e.detail===2 on mousedown (pointer-capture eats dblclick) + resetCamera() on layout change.
  4. Organ 3D overlay = marching-cubes SURFACE, not a 2nd volume + resetCameraClippingRange() (stale far-clip culls it).
  5. Organ 2D overlay: reslice on the shared slice planes + colorWindow(1)/ colorLevel(0.5) (else near-black) + piecewise opacity.
  6. Key overlays by stable id, not label.
  7. Free organ actors on toggle-off AND unmount.
  8. Annotation overlay: pointer-transparent when idle; native listeners + stopPropagation + interactor.disable() while drawing.
  9. Co-registration: masks must share the image's grid (dims+spacing, origin 0,0,0) — no resampling is applied.
  10. Lazy subpath + alias order; verify VTK/visual/interaction changes on a FULL reload.

14. Reconstruction build order

  1. Install deps (§2); import the 3 VTK profiles once.
  2. parseNiftiBuffer + loadNiftiImageData + useNiftiData (§7).
  3. The init effect: 1 canvas / 1 renderWindow / 1 OpenGL view / 4 renderers + viewports (§8.18.2) + the % border divs (§8.3).
  4. Data-load effect: planes + reslice slice actors (§8.4), volume actor (§8.5), cameras (§8.6) — verify the 3 slices + 3D volume render.
  5. Window/level/opacity effect (§8.7) + wheel scroll/zoom (§8.8) + resize (§8.9).
  6. Interaction: interactor + style swap + confine + expand (§9) — verify drag isolation + double-click expand on a FULL reload.
  7. Annotation overlay (§10).
  8. Organ overlays: 3D surface (§11.1) then 2D reslice (§11.2) + lifecycle (§11.3).
  9. Host dialog + organ panel (§12).
  10. Walk the gotcha checklist (§13).

Verify every visual/interaction change live with real mouse input on a full reloadtsc-clean ≠ works, and synthetic event-dispatch can mask the pointer-capture/clip bugs.