Files
sciagent/docs/architecture/medical-imaging-3d-viewer-spec.md
T
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

501 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ~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):
```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.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 reload** —
> `tsc`-clean ≠ works, and synthetic event-dispatch can mask the pointer-capture/clip bugs.
</content>