Skip to content

copper3d — Aligned 4D MRI + Surface API

Reference for the changes added to copper3d that let you render a cine MRI (one slice, N cardiac phases) spatially and temporally aligned with deforming surface meshes (e.g. LV endo/epi), all from raw DICOM + VTK, with one shared clock and proper disposal.

Everything is exported from the package entry (copper3d_visualisation / ../ts/index).

📖 Want the "why" behind the API? See the companion deep dive 4D Heart (MRI + LV Surfaces) Aligned Rendering, which walks through how the 32 MRI frames + 64 VTK models are loaded, aligned, and animated on a single cardiac clock.


1. High-level entry: copperScene.loadAligned4D()

The one call you normally use. Loads the DICOM cine and the surface sequences, places the MRI plane at its true patient-space pose, keeps the surfaces in their own world coordinates, drives both off a single frame clock, and hands you a controller.

ts
loadAligned4D(
  opts: aligned4DOptsType,
  callback?: (ctrl: Aligned4DController) => void
): void
loadAligned4D(
  opts: aligned4DOptsType,
  callback?: (ctrl: Aligned4DController) => void
): void

Options — aligned4DOptsType

fieldtyperequiredmeaning
dicomUrlsstring[]yesThe N DICOM frames (one slice, N cardiac phases). Order does not matter — they are sorted internally by TriggerTime/SliceLocation.
surfacesaligned4DSurfaceType[]noZero or more surface sequences (e.g. endo, epi).
cycleMsnumbernoPlayback period for one full loop in ms. Default 1012 (≈ one cardiac cycle).
window{ center: number; width: number }noOverride the DICOM window center/width. Omit to use the value baked in the DICOM.

Surface sequence — aligned4DSurfaceType

fieldtyperequiredmeaning
namestringyesKey used later in the controller (e.g. "endo").
urlsstring[]yesThe N VTK frames for this surface, in cardiac-phase order (index 0..N-1).
optsIOptVTKLoadernoMaterial options: { wireframe?, color?, transparent?, opacity? }.
offsetnumbernoPhase offset (in frames) of this surface relative to the MRI. Default 0.

Surfaces keep their original VTK coordinates (patient space, mm). They are not centered or scaled — that is what makes them line up with the MRI plane.

Example

ts
const dicomUrls = Array.from({ length: 32 }, (_, i) => `/data/mri_4ch/${i + 1}.dcm`);
const pad3 = (n: number) => String(n).padStart(3, "0");
const endo = Array.from({ length: 32 }, (_, i) => `/data/lv/endo_${pad3(i)}.vtk`);
const epi  = Array.from({ length: 32 }, (_, i) => `/data/lv/epi_${pad3(i)}.vtk`);

scene.loadAligned4D(
  {
    dicomUrls,
    surfaces: [
      { name: "endo", urls: endo, opts: { color: 0xff5a6e, transparent: true, opacity: 0.85 } },
      { name: "epi",  urls: epi,  opts: { color: 0x4ea1ff, transparent: true, opacity: 0.25 } },
    ],
    // cycleMs: 900,
    // window: { center: 226, width: 537 },
  },
  (ctrl) => {
    // runs once everything is loaded and on screen
    ctrl.setPlaneOpacity(0.9);
    scene.loadViewUrl("/data/heart4d_view.json"); // set the camera the usual copper3d way
  }
);
const dicomUrls = Array.from({ length: 32 }, (_, i) => `/data/mri_4ch/${i + 1}.dcm`);
const pad3 = (n: number) => String(n).padStart(3, "0");
const endo = Array.from({ length: 32 }, (_, i) => `/data/lv/endo_${pad3(i)}.vtk`);
const epi  = Array.from({ length: 32 }, (_, i) => `/data/lv/epi_${pad3(i)}.vtk`);

scene.loadAligned4D(
  {
    dicomUrls,
    surfaces: [
      { name: "endo", urls: endo, opts: { color: 0xff5a6e, transparent: true, opacity: 0.85 } },
      { name: "epi",  urls: epi,  opts: { color: 0x4ea1ff, transparent: true, opacity: 0.25 } },
    ],
    // cycleMs: 900,
    // window: { center: 226, width: 537 },
  },
  (ctrl) => {
    // runs once everything is loaded and on screen
    ctrl.setPlaneOpacity(0.9);
    scene.loadViewUrl("/data/heart4d_view.json"); // set the camera the usual copper3d way
  }
);

2. The controller: Aligned4DController

The object passed to your callback. Use it to drive playback and appearance at runtime.

method / fieldsignaturewhat it does
planeTHREE.MeshThe MRI plane mesh (ShaderMaterial).
surfaceMeshesRecord<string, THREE.Mesh>Each surface mesh by name.
frameCountnumberNumber of cardiac phases (= dicomUrls.length).
play()() => voidResume playback.
pause()() => voidPause playback.
toggle()() => voidToggle play/pause.
setSpeed(x)(x: number) => voidPlayback speed multiplier (e.g. 0.5, 2).
setFrame(i)(i: number) => voidJump to an absolute frame i (wraps; good for a scrub slider while paused).
setFrameOffset(name, n)(name: string, n: number) => voidPhase-shift one surface by n frames vs the MRI (live).
setWindow(center, width)(center: number, width: number) => voidRe-window the MRI (16-bit, recomputed).
setPlaneOpacity(v)(v: number) => voidMRI plane opacity, 0..1. Auto-enables transparency when v < 1.
setSurfaceOpacity(name, v)(name: string, v: number) => voidOpacity of one surface, 0..1. Auto-enables transparency when v < 1.
setSurfaceVisible(name, visible)(name: string, visible: boolean) => voidShow/hide one surface.
dispose()() => voidStops the clock and disposes all geometries / textures / materials, and removes meshes from the scene. Call this on teardown.

Example (wiring to UI)

ts
let ctrl: Aligned4DController | undefined;
// ... inside loadAligned4D callback: ctrl = controller;

playBtn.onclick   = () => ctrl?.toggle();
speedSlider.oninput = (e) => ctrl?.setSpeed(+e.target.value);
endoAlpha.oninput  = (e) => ctrl?.setSurfaceOpacity("endo", +e.target.value);
epiToggle.onchange = (e) => ctrl?.setSurfaceVisible("epi", e.target.checked);
mriAlpha.oninput   = (e) => ctrl?.setPlaneOpacity(+e.target.value);
windowSlider.oninput = () => ctrl?.setWindow(center, width);

// teardown (e.g. Vue onBeforeUnmount):
appRenderer.stop();
ctrl?.dispose();
let ctrl: Aligned4DController | undefined;
// ... inside loadAligned4D callback: ctrl = controller;

playBtn.onclick   = () => ctrl?.toggle();
speedSlider.oninput = (e) => ctrl?.setSpeed(+e.target.value);
endoAlpha.oninput  = (e) => ctrl?.setSurfaceOpacity("endo", +e.target.value);
epiToggle.onchange = (e) => ctrl?.setSurfaceVisible("epi", e.target.checked);
mriAlpha.oninput   = (e) => ctrl?.setPlaneOpacity(+e.target.value);
windowSlider.oninput = () => ctrl?.setWindow(center, width);

// teardown (e.g. Vue onBeforeUnmount):
appRenderer.stop();
ctrl?.dispose();

3. Renderer teardown: copperRenderer.stop()

Stops the render loop (cancels the requestAnimationFrame chain) so the canvas/GPU context can be reclaimed. Call it together with ctrl.dispose() when leaving the page to avoid the "laggy after a while" leak.

ts
stop(): void
stop(): void
ts
onBeforeUnmount(() => {
  appRenderer.stop();
  ctrl?.dispose();
});
onBeforeUnmount(() => {
  appRenderer.stop();
  ctrl?.dispose();
});

4. Low-level building blocks (only if you bypass loadAligned4D)

4.1 computeImagePlaneCorners() — DICOM tags → world corners

Exported from the DICOM loader. Computes the 4 world-space corners of a DICOM image plane. IPP is the center of pixel (0,0), so corners are half a pixel out. PixelSpacing is [rowSpacing, colSpacing]; the first IOP vector is the +column-index direction.

ts
import { computeImagePlaneCorners } from "copper3d_visualisation";

function computeImagePlaneCorners(
  ipp: number[],     // ImagePositionPatient [x,y,z]
  iop: number[],     // ImageOrientationPatient [rx,ry,rz, cx,cy,cz]
  spacing: number[], // PixelSpacing [rowSpacing, colSpacing]
  cols: number,      // Columns
  rows: number       // Rows
): planeCorners;     // { tl, tr, bl, br } each [x,y,z]
import { computeImagePlaneCorners } from "copper3d_visualisation";

function computeImagePlaneCorners(
  ipp: number[],     // ImagePositionPatient [x,y,z]
  iop: number[],     // ImageOrientationPatient [rx,ry,rz, cx,cy,cz]
  spacing: number[], // PixelSpacing [rowSpacing, colSpacing]
  cols: number,      // Columns
  rows: number       // Rows
): planeCorners;     // { tl, tr, bl, br } each [x,y,z]

4.2 copperVolumeType — new fields

The DICOM loader now also fills these (all optional; present when the tags exist):

ts
interface copperVolumeType {
  /* ...existing... */
  instanceNumber?: number;
  imagePositionPatient?: number[];    // IPP [x,y,z]
  imageOrientationPatient?: number[]; // IOP [rx,ry,rz, cx,cy,cz]
  pixelSpacing?: number[];            // [rowSpacing, colSpacing]
  corners?: planeCorners;             // pre-computed world-space plane corners
}
interface copperVolumeType {
  /* ...existing... */
  instanceNumber?: number;
  imagePositionPatient?: number[];    // IPP [x,y,z]
  imageOrientationPatient?: number[]; // IOP [rx,ry,rz, cx,cy,cz]
  pixelSpacing?: number[];            // [rowSpacing, colSpacing]
  corners?: planeCorners;             // pre-computed world-space plane corners
}

4.3 createTexture2D_Array() — aligned plane + frame/window control

The 2D texture builder gained an aligned flag and two control helpers. With aligned = true and a volume that has corners, the plane is built at its real world pose (instead of a centered PlaneGeometry).

ts
function createTexture2D_Array(
  copperVolume: copperVolumeType,
  depth: number,            // number of stacked frames (cine length)
  scene: THREE.Scene,
  gui?: GUI,
  aligned?: boolean         // default false; true → world-placed corner quad
): {
  mesh: THREE.Mesh;
  copperVolume: copperVolumeType;
  updateTexture: (v: copperVolumeType) => void;
  setFrame: (i: number) => void;            // set the visible cine frame (depth uniform)
  setWindow: (center: number, width: number) => void; // re-window (16-bit)
};
function createTexture2D_Array(
  copperVolume: copperVolumeType,
  depth: number,            // number of stacked frames (cine length)
  scene: THREE.Scene,
  gui?: GUI,
  aligned?: boolean         // default false; true → world-placed corner quad
): {
  mesh: THREE.Mesh;
  copperVolume: copperVolumeType;
  updateTexture: (v: copperVolumeType) => void;
  setFrame: (i: number) => void;            // set the visible cine frame (depth uniform)
  setWindow: (center: number, width: number) => void; // re-window (16-bit)
};

Default (aligned = false) keeps the legacy behaviour used by loadDicom, so the existing volume-scroll examples are unchanged.


5. How alignment works (one paragraph)

The DICOM and the surfaces are already in the same patient coordinate frame (LPS, mm). The MRI plane is built from the 4 world corners derived from ImagePositionPatient + ImageOrientationPatient + PixelSpacing; the surfaces are loaded with their original VTK coordinates (no center/scale). Both therefore coincide automatically — no manual transform. Temporal alignment comes from a single shared frameIndex that drives the MRI texture layer (depth uniform) and the surface geometry swap simultaneously. The texture UV in the plane is a fixed structural mapping (corner tl ↔ pixel (0,0)), so it is correct for any dataset, not just one case.


6. Quick checklist for consumers

  • [ ] Pass all DICOM frames in dicomUrls (order doesn't matter, sorted internally).
  • [ ] Pass each surface's VTK frames in cardiac-phase order (index 0..N-1).
  • [ ] Drive the camera with scene.loadViewUrl(...) (camera is NOT baked into the package).
  • [ ] Use setFrameOffset(name, n) if a surface is phase-shifted vs the MRI.
  • [ ] Always call appRenderer.stop() + ctrl.dispose() on teardown.