Viewer
The NeuroSurfaceViewer is the main class that manages the Three.js scene, camera, lighting, and rendering.
The host application owns the container, layout observers, data sources, and application UI. The viewer owns the canvas and every rendered resource registered with it. This boundary determines cleanup: remove host observers in the host, and call the viewer's idempotent dispose() for viewer resources.
Creating a Viewer
import { NeuroSurfaceViewer } from 'surfview';
const viewer = new NeuroSurfaceViewer(
container, // HTMLElement
800, // width
600, // height
{
backgroundColor: 0x1a1a1a,
ambientLightColor: 0xffffff,
directionalLightIntensity: 1.84
}
);Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
showControls | boolean | false | Deprecated warning-only flag; enabled values do not create UI |
useControls | boolean | false | Deprecated warning-only flag; enabled values do not create UI |
allowCDNFallback | boolean | false | Deprecated warning-only flag; runtime CDN loading remains disabled |
backgroundColor | number | 0x000000 | Scene background color |
ambientLightColor | number | 0xffffff | Integer RGB color (0x000000-0xffffff) |
directionalLightColor | number | 0xffffff | Integer RGB color (0x000000-0xffffff) |
directionalLightIntensity | number | 1.84 | Finite nonnegative key-light intensity (see Lighting) |
headlight | boolean | true | Key and fill lights follow the camera; false keeps the world-fixed rig of earlier releases |
preset | string | default | Style preset: presentation, paper-light, talk-dark, clinical-qc, retinotopy, or glass-brain-surface |
initialZoom | number | 12 | Positive finite initial camera distance |
rotationSpeed | number | 2.0 | Positive finite mouse rotation sensitivity |
ssaoRadius | number | 4 | Finite nonnegative SSAO radius |
ssaoKernelSize | integer | 32 | SSAO kernel size from 1 through 64 |
rimStrength | number | 0 | Finite nonnegative rim-light strength |
metalness, roughness | number | 0.1, 0.6 | Finite values in [0, 1]; affect standard/physical surface materials only (Phong ignores them) |
hoverCrosshairSize | number | 1.2 | Positive finite crosshair size |
The constructor and updateConfig() validate the complete normalized configuration before changing viewer state. Passing undefined cannot erase a required runtime default. Invalid numeric values throw NumericValidationError and do not change the camera, renderer, revision, or events.
Lighting and colour
The viewer lights surfaces with an ambient light, a key directional light and a weaker fill light. By default (headlight: true) the key and fill are held fixed relative to the camera — the key about 17° above-right of the line of sight, the fill about 31° below-left — so whichever side of the cortex faces the viewer is key-lit and every view, preset pose or free rotation is shaded alike. headlight: false (or viewer.setHeadlight(false)) keeps the world-fixed rig of earlier releases: key light at the preset's directionalPosition, fill at (-1, -0.5, -1).
three.js lights are physically based: a matte surface lit head-on returns its colour × irradiance / π. The presets choose ambient, key and fill intensities (for example ambient 1.0, key 1.84, fill 0.45) so a camera-facing surface shows its layer colours at their nominal values and slopes shade from the key light: ambient (weighted by the luminance of its colour) + 0.956·key + 0.854·fill ≈ π, the two cosines being those of the key and fill directions to the line of sight. If you set your own intensities, keep that sum near π; above it, light colours (a white base layer, the top of a colormap) clip.
The headlight setting is part of saved viewer state (serialize / deserialize); while it is on, the key light's position is derived from the camera on every render, so it is neither saved nor restored.
Colours are managed so they appear as specified. Layer and colormap colours, curvature greys and hex colours are display (sRGB) values; surfaces composite them in display space, decode the result to linear before lighting, and the renderer encodes the lit image to sRGB once. The material colour of a composited surface is white: baseColor (and updateConfig({ color })) set the base layer's colour, which shows wherever no curvature underlay or opaque layer covers the surface; it never tints the layers above it.
Style presets provide defaults. Explicit style options in the constructor or in the same updateConfig() call (background, lighting, rim, SSAO, metalness and roughness) take precedence over the preset and are recorded in viewer.stylePreset, so surfaces added later inherit them. An explicit background with a preset selects a solid background. A later color-only updateConfig({ backgroundColor }) retains the current transparency; use setFigureBackground(color, transparent) to change transparency explicitly.
SurfView's published declarations use TypeScript exact optional-property semantics: omit an option to preserve or select its default. Explicit undefined is not a reset value and is rejected by TypeScript. Where an API supports clearing state, its type names the reset value (normally null). Plain JavaScript objects that contain an explicit undefined are normalized as if that property had been omitted, including during state restoration.
Methods
Surface Management
// Add a surface
viewer.addSurface(surface, 'brain');
// Remove and dispose a surface by its registered ID
viewer.removeSurface('brain');
// Get a surface by ID
const surface = viewer.getSurface('brain');After addSurface(surface, id), replacement, removeSurface(id), clearSurfaces(), and viewer.dispose() dispose that registered surface. Do not dispose it a second time as part of the normal removal path. If a surface is disposed directly, the viewer observes its disposal and unregisters it.
Camera Controls
// Center camera on all surfaces
viewer.centerCamera();
// Frame visible surfaces in the current viewing direction with 8% padding.
viewer.fitToView();
viewer.fitToView({ padding: 0.05, surfaceId: 'brain' });
// Set a specific viewpoint
viewer.setViewpoint('lateral'); // lateral, medial, dorsal, ventral, anterior, posterior
// Get current camera position
const pos = viewer.getCameraPosition();
// Positive finite camera distance
viewer.setZoom(14);
// Legacy camera snapshots are validated atomically before application.
viewer.setCameraState({
position: [0, 0, 14],
rotation: [0, 0, 0, 'XYZ'],
target: [0, 0, 0]
});
// A finite nonnegative total separation; negative/NaN values are rejected.
viewer.separateHemispheres(20);
// Disable and restore camera/surface interaction
viewer.setInteractionEnabled(false);
viewer.setInteractionEnabled(true);
// Access the interaction controller when direct tuning is necessary
viewer.cameraControls.rotateSpeed = 1.5;fitToView() returns false when there is no visible target surface. It uses the current viewport shape, so call it again after resize() when you want to refit the scene. padding is the fraction reserved on each edge, from 0 to less than 0.5. An unknown surfaceId or invalid padding throws. Clipping planes cover the fitted bounding sphere throughout the camera control's zoom range, allowing subsequent orbit and dolly without refitting depth planes. setZoom() beyond that range widens the range and the far plane to match.
Camera position, rotation, target, zoom, and hemisphere separation must be finite. A camera-state update validates every supplied tuple before changing any camera or control object.
For control surfaces and other reusable integrations, use the explicit anatomical-view API instead of relying on the first loaded surface:
import { ANATOMICAL_VIEWS } from 'surfview';
const registration = viewer.registerBilateralSurfaceGroup({
id: 'cortex',
leftSurfaceId: 'lh',
rightSurfaceId: 'rh'
});
if (!registration.ok) throw new Error(registration.message);
const result = viewer.setAnatomicalView('dorsal', {
layout: 'paired',
groupId: 'cortex',
fit: true,
hemisphereGap: 8
});
if (!result.ok) throw new Error(result.message);
console.log(ANATOMICAL_VIEWS);
console.log(viewer.getAnatomicalViewCapabilities());
viewer.resetAnatomicalView();AnatomicalView is the shared lateral, medial, dorsal, ventral, anterior, and posterior vocabulary. A single layout must name a surfaceId; a paired layout must name a registered groupId. Loading left and right surfaces never creates a group. Registration requires existing, distinct surfaces with matching hemisphere metadata, and a surface can belong to at most one group. Invalid commands return typed failures without changing camera or group state.
The ordinary viewer remains a camera-oriented adapter. For paired lateral and medial views it uses the registered left member as the camera reference and does not transform either mesh. Report scenes use the same orientation fixtures but can rotate and arrange each registered member independently. fit: false preserves camera distance while retargeting and reorienting; resetAnatomicalView() restores the configured origin and zoom. Removing a group member emits surface-group:removed with reason: 'surface-removed' and invalidates the surfaces state domain.
viewer.cameraControls is the canonical interaction-controller property. viewer.controls, enableControls(), and disableControls() remain deprecated 2.x aliases; the methods forward to setInteractionEnabled() and are removed in SurfView 3.
Pane-era API migration
The disabled Tweakpane implementation and all pane DOM, binding, dragging, minimizing, and FPS-monitor state have been removed. The following members remain only to make the 2.x migration explicit:
| 2.x compatibility member | 2.x behavior | Replacement | v3 |
|---|---|---|---|
showControls, useControls, allowCDNFallback | Accepted; enabled values warn once and are normalized to false | Report controls, a ViewerPlugin, or application-owned controls | Removed |
toggleControls(), getControlsVisible() | Warning no-op; visibility is always false | Track panel visibility in the application | Removed |
togglePaneMinimized(), minimizeControlsPane(), restoreControlsPane() | Warning no-op | Track disclosure in the application | Removed |
updateIntensityRange(), updateThresholdRange(), updateDataRange() | Warning no-op | updateLayer() or the corresponding layer setter | Removed |
updateColormap() | Still updates the legacy selected/fallback map and warns | updateLayer(surfaceId, layerId, { colorMap }) or updateColorMap() | Removed |
controls, enableControls(), disableControls() | Deprecated interaction aliases | cameraControls, setInteractionEnabled() | Removed |
No compatibility member mounts DOM, loads a package, changes panel state, or participates in the future first-party controls architecture.
Rendering
// Automatic on-demand rendering is enabled at construction. This is also the
// resume operation after stopRenderLoop(). It requests one coalesced frame.
viewer.startRenderLoop();
// Pause automatic rendering and cancel a pending frame
viewer.stopRenderLoop();
// Invalidate the view. Repeated calls before the next frame are coalesced.
viewer.requestRender();
// Paint synchronously (unless disposed or the WebGL context is lost)
viewer.render();The viewer does not keep a permanent requestAnimationFrame loop alive. A settled viewer has no pending frame. Layer changes, temporal timechange handlers, resize operations, and camera controls call requestRender() as needed. Trackball interaction temporarily owns follow-up frames while the pointer is active or damping is still moving the camera, then releases them when movement settles. start() and stop() are compatibility aliases for startRenderLoop() and stopRenderLoop().
WebGL context loss cancels the pending frame and suppresses rendering. A live viewer requests one fresh frame after context restoration. dispose() is idempotent and terminal: it cancels queued work, removes canvas, control, and context listeners, releases GPU resources, and prevents start(), restoration, or later invalidations from restarting the viewer. Applications remain responsible for removing listeners they attach themselves, such as the resize listener below.
Resize
window.addEventListener('resize', () => {
viewer.resize(window.innerWidth, window.innerHeight);
});Width and height must be positive finite CSS-pixel values. dpr must be positive and finite; SurfView caps it at the exported MAX_DEVICE_PIXEL_RATIO value (4) to bound framebuffer allocation. Validation happens before any renderer, camera, surface, event, or revision mutation:
const actual = viewer.resize(1200, 800, { dpr: window.devicePixelRatio });
console.log(actual.dpr); // <= 4State Serialization
Viewer state uses the version-2 schema. It records each surface's exact bottom-to-top layerOrder, explicit bilateral surfaceGroups, and the scientific inspectionSelection. It deliberately does not record a panel's focused surface or layer, expanded sections, advanced visibility, or range-lock preferences.
const state = viewer.toJSON();
const url = viewer.toURL();
const report = viewer.fromJSON(state);
if (!report.success) {
console.error(report.errors);
}fromJSON() accepts both v2 and legacy v1 state throughout the SurfView 2.x compatibility window. V1 layer order is migrated by ascending LayerState.order; a missing or non-finite order uses the legacy default 0, and source-array position breaks ties. Legacy selectedSurfaceId and selectedLayerId values were pane focus and are ignored. The only v1 crosshair promoted to scientific selection is one that is visible, has mode: 'selection', names a non-empty surface ID, and has a non-negative integer vertex index.
Restoration validates every numeric domain, surface, layer-order, group, and selection reference before changing canonical viewer state. Encoding and serialization reject non-finite values instead of allowing JSON.stringify() to turn them into null. Invalid references are returned in report.errors with a stable code, path, and message; validation failure does not partially restore the camera or any other section.
Scene Export
const scene = viewer.exportScene({
id: 'sub-01-activation',
provenance: {
sourceFiles: ['sub-01_task-localizer_zstat1.nii.gz']
}
});
const json = viewer.exportSceneJSON({ id: 'sub-01-activation' });
const html = viewer.exportStaticHTML({
id: 'sub-01-activation',
title: 'Subject 01 activation',
scriptUrl: './surfview.es.js'
});exportScene() captures the current serialized viewer state plus provenance, asset references, software version, and optional SubjectPackage metadata. exportStaticHTML() embeds that scene manifest in an HTML shell for later hydration. Its default module URL is the local ./surfview.es.js; it never inserts a CDN URL.
Style Presets and Figure Export
import { getStylePreset, listStylePresets } from 'surfview';
viewer.applyStylePreset('paper-light');
const png = viewer.exportPNG({
preset: 'paper-light',
width: 2400,
height: 1800,
background: 'transparent',
colorbar: true,
roiLabels: [{ text: 'V1', x: 0.62, y: 0.36, normalized: true }],
scaleBar: true,
scaleBarLabel: '20 mm',
title: 'Subject 01'
});
console.log(listStylePresets(), getStylePreset('paper-light').figure.dpi);Style presets control the viewer background, lighting, material defaults, curvature display parameters, annotation defaults, ROI/export label styling, colormap defaults, export dimensions, and font scale. paper-light is an appearance preset, not a report behavior mode. exportPNG() renders at the requested pixel size and returns a PNG data URL; dpi is retained as figure intent because browser PNG encoders do not reliably write DPI metadata.
background takes 'viewer' (the live viewer background, opaque unless the viewer canvas itself is transparent), 'transparent', or an opaque 0xRRGGBB colour. Without it, figures are opaque on the live viewer background unless the preset declares transparent publication figures (paper-light, glass-brain-surface) or you pass transparent. The colour key describes the active layer — the selected layer if it is a visible scalar layer, otherwise the top-most visible one, or colorbarLayer — with its colormap, display range, label and units, the masked threshold interval as a striped band, and cap triangles where its data extend beyond the colour scale. colorbarLabel, colorbarRange, colorbarColors, colorbarThreshold (null hides the band) and colorbarCaps override any of these; viewer.getFigureColorbarSource() returns the same description for applications that draw their own key. With no visible scalar layer there is nothing to describe, so the preset's key is omitted unless you pass colorbar: true, colorbarRange or colorbarColors.
Alignment QA
AlignmentQAWorkspace renders sagittal, coronal, axial, and 3D overlay panels for checking an explicit surface-to-volume transform. It reports transform metadata, surface-to-volume bounds distance, edge-gradient samples, and dropout-style low-intensity overlap.
import { AlignmentQAWorkspace } from 'surfview';
const qa = new AlignmentQAWorkspace(container, {
volume: {
id: 'boldref',
data: boldrefData,
dims: [nx, ny, nz],
space: 'boldref'
},
surfaces: [
{ id: 'white', kind: 'white', vertices: whiteVertices },
{ id: 'pial', kind: 'pial', vertices: pialVertices }
],
transform: {
id: 'anat-to-boldref',
from: 'anat',
to: 'boldref',
matrix: anatToBoldref,
provenance: { source: 'fMRIPrep' }
}
});
console.log(qa.getReport().metrics.surfaceVoxelDistance);Cleanup
const observer = new ResizeObserver(() => {
// host-owned layout work
});
observer.observe(container);
// During application teardown:
observer.disconnect(); // host-owned
viewer.dispose(); // viewer-owned; safe to call more than onceviewer.dispose() cancels scheduled frames, removes viewer and WebGL-context listeners, disposes registered plugins and surfaces, releases camera controls, post-processing, picking, annotations, renderer resources, and the WebGL context, and removes the canvas from its host. It is terminal: later invalidations and context restoration cannot restart rendering.
Picking and Interaction
// Pick at screen coordinates
const hit = viewer.pick({ x: event.clientX, y: event.clientY });
if (hit.surfaceId && hit.vertexIndex !== null) {
console.log(`Hit surface ${hit.surfaceId} at vertex ${hit.vertexIndex}`);
}Scientific Selection and Vertex Inspection
Inspection selection is canonical scientific state, separate from a panel's focused layer, annotations, and crosshair rendering:
const result = viewer.setInspectionSelection({
kind: 'vertex',
surfaceId: 'brain',
vertexIndex
});
if (!result.ok) throw new Error(result.message);
const selection = viewer.getInspectionSelection();
const inspection = viewer.inspectVertex('brain', vertexIndex);
console.log(inspection?.world); // frozen [x, y, z] world tuple
for (const layer of inspection?.values ?? []) {
console.log(layer.layerId, layer.label, layer.value, layer.units, layer.missing);
}
viewer.clearInspectionSelection();The selection union is none, vertex, or parcel. Parcel selections carry stable surface and parcel IDs plus optional representative vertex and atlas IDs. Invalid surfaces, vertices, parcels, and atlas mismatches return typed failures without changing the current selection. A removed surface clears a selection that refers to it.
inspectVertex() returns null for an absent, out-of-domain, or disposed target. Successful results contain only frozen plain data: stable IDs, a world coordinate tuple, null-safe sample values, and optional parcel/atlas descriptors. UI code never needs access to a layer's sparse indices and never receives live layers, surfaces, Three.js objects, or typed arrays.
Crosshair rendering is opt-in when setting selection:
viewer.setInspectionSelection(
{ kind: 'vertex', surfaceId: 'brain', vertexIndex },
{ showCrosshair: true }
);Mouse clicks and setParcelSelection() update canonical inspection selection while preserving their existing compatibility events. Calling showCrosshair() alone does not change scientific selection.
Crosshair
// Show crosshair at a vertex
viewer.showCrosshair('brain', vertexIndex, {
size: 2,
color: 0xffcc00
});
// Hide crosshair
viewer.hideCrosshair();
// Toggle crosshair
viewer.toggleCrosshair();Annotations
// Add annotation marker
viewer.addAnnotation('brain', vertexIndex, { label: 'ROI' });
// List all annotations
const annotations = viewer.listAnnotations('brain');
// Remove annotations
viewer.removeAnnotations('brain');