# 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):**
```ts
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`:
```json
{ "name": "@ump/shared",
  "exports": { ".": "./src/index.ts", "./viewer": "./src/components/viewer/index.ts" } }
```

`viewer/index.ts` (the entire public API):
```ts
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`:**
```ts
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):
```ts
// 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') },
]}
```
```jsonc
// 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)

```ts
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`

```ts
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).

```ts
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)
```ts
// 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:
```ts
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)
```ts
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)
```ts
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
```ts
// 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)
```ts
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)
```ts
// 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):
```ts
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 ~1018–1283 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):
```ts
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:
```tsx
<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.1–8.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 reload** —
> `tsc`-clean ≠ works, and synthetic event-dispatch can mask the pointer-capture/clip bugs.
</content>
