25 KiB
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+ 1vtkOpenGLRenderWindow(.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.findPokedRendererskips renderers whosegetInteractive()is false, so onmousedownset every OTHER renderersetInteractive(false), restore all onmouseup+ a globalmouseup. - ⚠️ Double-click to expand: VTK takes pointer capture on press, so the native
dblclick/mouseup/clickfire on the parent container, not the per-pane div — adblclicklistener on the div never fires. Detect it on the div'smousedownviae.detail === 2(the 2nd press of a double-click), then toggleexpandedView. - ⚠️
resetCamera()on expand: when the layout changes, re-runrenderers.forEach(r => r.resetCamera())aftersetViewport, else the enlarged pane keeps the tiny framing it had as a quadrant (content stuck in a corner).resetCamerapreserves 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: crosshairwhen a tool is active. - ⚠️ Capture with NATIVE listeners that
stopPropagation()+setPointerCapture— and alsointeractor.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. dblclickwhile a polygon draft has ≥3 pts → close it; otherwise → forward toonRequestExpand()(expand the pane).wheelwhile a tool is active →stopPropagation+ forward{deltaY, ctrlKey, metaKey}to the host, which re-dispatches a syntheticwheelon 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 ~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):
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 deriveorganMasks = 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
loadNiftiImageDatafrom@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(notw-screen/h-screen, which overflows by the scrollbar width); aflex-1child in a non-definite-height parent collapses to 0 → give a definite height (h-screenflex-col).
13. Consolidated gotcha checklist ⚠️
- % border divs, never px from
getBoundingClientRect()(transform-stale-rect). - Confine drags to the origin pane via
setInteractive(false)on the others (VTK re-pokes every move). - Expand =
e.detail===2on mousedown (pointer-capture eatsdblclick) +resetCamera()on layout change. - Organ 3D overlay = marching-cubes SURFACE, not a 2nd volume +
resetCameraClippingRange()(stale far-clip culls it). - Organ 2D overlay: reslice on the shared slice planes +
colorWindow(1)/ colorLevel(0.5)(else near-black) + piecewise opacity. - Key overlays by stable id, not label.
- Free organ actors on toggle-off AND unmount.
- Annotation overlay: pointer-transparent when idle; native listeners +
stopPropagation+interactor.disable()while drawing. - Co-registration: masks must share the image's grid (dims+spacing, origin 0,0,0) — no resampling is applied.
- Lazy subpath + alias order; verify VTK/visual/interaction changes on a FULL reload.
14. Reconstruction build order
- Install deps (§2); import the 3 VTK profiles once.
parseNiftiBuffer+loadNiftiImageData+useNiftiData(§7).- The init effect: 1 canvas / 1 renderWindow / 1 OpenGL view / 4 renderers + viewports (§8.1–8.2) + the % border divs (§8.3).
- Data-load effect: planes + reslice slice actors (§8.4), volume actor (§8.5), cameras (§8.6) — verify the 3 slices + 3D volume render.
- Window/level/opacity effect (§8.7) + wheel scroll/zoom (§8.8) + resize (§8.9).
- Interaction: interactor + style swap + confine + expand (§9) — verify drag isolation + double-click expand on a FULL reload.
- Annotation overlay (§10).
- Organ overlays: 3D surface (§11.1) then 2D reslice (§11.2) + lifecycle (§11.3).
- Host dialog + organ panel (§12).
- 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.