Parametron

@parametron/three/viewport is a lazy subpath — it pulls in OrbitControls, TransformControls, LineSegments2, and constructs a WebGLRenderer, so it's kept out of the root @parametron/three entry (dynamic-import it when a canvas actually mounts). It exports one class, Viewport, which owns a full Three.js scene/camera/controls setup, and viewport.highlight (a SceneHighlight instance) — the web-side interpreter of the PreviewJSON hover/highlight contract shared with @parametron/ui. See /three/ for the renderer that builds the geometry Viewport displays.

Setup

import { Viewport } from "@parametron/three/viewport"

const viewport = new Viewport(container) // container: HTMLElement

The constructor creates the canvas (appended into container), a WebGLRenderer (antialias on, NoToneMapping, clear color #fafafa), a Scene, a Z-up PerspectiveCamera (fov 45, near 0.1, far 100000, starting at (-1200,-1200,1200)), an OrbitControls, a TransformControls gizmo, a SceneHighlight, and a red/green/blue RGB axis helper. Lighting is a fixed SketchUp-style rig — hemisphere fill + ambient + a directional "headlight" parented to the camera — there's no IBL/reflections to configure. Rendering is on-demand: a requestAnimationFrame-coalesced _requestRender fires on every OrbitControls "change" event, every gizmo "change", and after every mutating method below; a ResizeObserver on container keeps the camera aspect and renderer size in sync.

Public fields: renderer: WebGLRenderer, scene: Scene, camera: PerspectiveCamera, controls: OrbitControls, canvas: HTMLCanvasElement, unit: SceneUnit (mm/cm/m/in/ft conversion helper bound to the scene, see SceneUnit.tsviewport.unit.set("in"), .toScene(value, from), .format(value)), and highlight: SceneHighlight (below).

Camera state & preset views

type CameraJSON = {
    type?: "perspective" | "orthographic"
    position: [number, number, number]
    target: [number, number, number]
    zoom?: number
}

viewport.getCameraState(): CameraJSON
viewport.setCameraState(state: CameraJSON): void

CameraJSON (from @parametron/parametric) is the persisted-camera shape on ModelJSON.camera. setCameraState also flips toggleProjection() if the incoming type doesn't match the viewport's current projection.

type PresetView = "top" | "bottom" | "front" | "back" | "right" | "left" | "isometric"

viewport.setPresetView(view: PresetView): void

Re-aims the camera along a fixed direction at the current orbit distance and target, keeping distance-to-target constant. Top/bottom temporarily swap camera.up during the look-at (the default Z-up is degenerate looking straight down/up the Z axis) and restore it after.

viewport.fitAll(meshes: Object3D[]): void

Frames the camera on the union bounding box of meshes (empty array resets to the default home position/target).

Orthographic projection

viewport.isOrthographic: boolean       // getter
viewport.toggleProjection(): void
viewport.activeCamera: PerspectiveCamera | OrthographicCamera  // getter

toggleProjection builds (or discards) an OrthographicCamera sized from the current perspective camera's distance/fov/aspect, copying position and rotation across so the toggle doesn't jump the view. activeCamera is the one actually driving renderer.render — it's what pick(), the gizmo, and SceneHighlight's screen-space label math all read.

Picking

viewport.pick(clientX: number, clientY: number, root: Object3D): Object3D | null

Raycasts a canvas-space click (browser clientX/clientY) against root's subtree using activeCamera, skipping hits inside any hidden ancestor (visible === false) — e.g. the instanced-group render lane's invisible tessellated template. Returns the closest hit Object3D, or null. The host maps the result back to a model node with nodeKeyFromObject (@parametron/three's root entry).

Wireframe & screenshot

viewport.wireframe: boolean            // getter
viewport.setWireframe(visible: boolean): void
viewport.captureScreenshot(): Promise<Blob>

setWireframe delegates to setWireframeVisible (@parametron/three's root entry) over the whole scene. captureScreenshot forces a synchronous render then resolves the canvas's toBlob("image/png") result (the renderer must be constructed with preserveDrawingBuffer: true, which Viewport already does).

Transform gizmo

type GizmoMode = "translate" | "rotate" | "scale"

viewport.gizmoMode: GizmoMode          // getter
viewport.setGizmoMode(mode: GizmoMode): void
viewport.attachGizmo(target: Object3D): void
viewport.detachGizmo(): void
viewport.onGizmoChange(fn: () => void): void

Wraps a Three.js addon TransformControls. Dragging the gizmo suspends OrbitControls for the duration (wired in the constructor); onGizmoChange registers a listener on the gizmo's "objectChange" event, fired after a drag — a host uses it to persist the transform back to the model and refresh.

Preview / highlight bridge

viewport.preview(preview: PreviewJSON): void
viewport.clearPreview(): void

These two methods make Viewport itself a ViewportBridge (/ui/'s host contract: { preview, clearPreview }) — a host wires viewport straight into BuilderHost.viewport and every panel's hover feedback renders through viewport.highlight with no adapter code. preview forwards to viewport.highlight.apply(preview) (additive — call clearPreview() first if the previous preview should be removed); clearPreview forwards to viewport.highlight.clear().

SceneHighlight

viewport.highlight is a SceneHighlight — the interpreter for the PreviewJSON data contract described on /ui/ (types.ts's PreviewItem/PreviewJSON). It isn't exported as a named class (construct one only through Viewport); its instance methods are the reachable surface:

viewport.highlight.apply({ items: [{ kind: "point", position: [10, 20, 0] }] })
viewport.highlight.clear() // removes every highlight + label

apply dispatches each PreviewItem by kind to a drawing method, additive — it does not clear a previous preview itself, callers do that (clearPreview() / viewport.highlight.clear()) when needed:

kindmethoddraws
pointpoint(x, y, z, color?)a small sphere + 3 axis guide lines + dimension labels for any non-zero coordinate
pointspoints(pts, color?)many small spheres — e.g. all candidate vertices of a vertex filter
segmentssegments(segs, color?)many line segments (via LineSegments2/LineMaterial for consistent screen-space width) — e.g. all candidate edges of an edge filter
vectorvector(dx, dy, dz, anchorKey?, color?)an ArrowHelper + length label, anchored at anchorKey's rendered bounding-box min corner if that scene object is found, else at the orbit target
axisaxis(origin, direction, color?)a long camera-relative half-line with an arrowhead + origin dot
planeplane(origin, normal, color?)a translucent finite quad + border + short normal arrow, sized relative to camera distance
entitiesentities(list, color?)any EntityJSON[] (the engine's own currency) rendered as a transient ghost via render() — points as dots, curves as lines, faces/solids as translucent meshes. The escape hatch that keeps the fixed kind vocabulary above from growing per use case; nested groups are skipped (bake first)

vector's anchor resolution (anchorKey) looks up the key first by object name (a generator's own key), then by userData.placedId (a whole model's id, for a root-level modifier with no owning generator) — see NodeArgs.ts's previewAnchorKey on the @parametron/ui side for how a panel picks which key to send.

Every highlight object is tagged name: "_highlight" and tracked internally so clear() can dispose geometry/material and remove DOM label elements (labels are absolutely-positioned <div>s appended next to the canvas, re-projected on every OrbitControls "change" event, hidden once their world position goes behind the camera).

Disposal

viewport.dispose(): void

Disconnects the ResizeObserver, detaches and disposes the gizmo, cancels any pending render RAF, clears the highlight (geometry/material/labels), and disposes the renderer and controls. Call it when the host unmounts the canvas — Viewport doesn't remove its own <canvas> from container, only the host-owned DOM wrapper does.

GLB import — the ./import subpath

import { importGlbEntities, type ImportedEntities } from "@parametron/three/import"

const { entities, materials } = await importGlbEntities(await file.arrayBuffer())

importGlbEntities(data: ArrayBuffer) parses a .glb/.gltf payload (three's GLTFLoader) into an entities-node payload: every mesh triangle becomes a FaceJSON with its world transform baked in and meters scaled to millimeters, then the shared imported-mesh cleanup (normalizeImportedFaces from @parametron/parametric/compute) recovers real topology — coplanar triangles merge into one face per planar region, leftover curved-band triangles pair into quads.

Materials carry PBR factors only (baseColor/roughness/metallic as a MaterialJSON record per glTF material, faces stamped by name). Texture maps are deliberately not imported: glTF UVs are arbitrary per-vertex mappings, while ParaShape faces carry a planar uvMatrix — a lossy remap would silently misplace textures, so import delivers geometry + flat materials and texturing happens in ParaShape.

This module lives on its own subpath so GLTFLoader stays out of the eager renderer chunk — hosts await import("@parametron/three/import") inside the file-picker handler (see BuilderHost.importEntities in the builder page).

📖 6 min read