sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
# 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>
|
||||
Reference in New Issue
Block a user