@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: HTMLElementThe 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.ts — viewport.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): voidCameraJSON (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): voidRe-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[]): voidFrames 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 // gettertoggleProjection 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 | nullRaycasts 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): voidWraps 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(): voidThese 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 + labelapply 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:
kind | method | draws |
|---|---|---|
point | point(x, y, z, color?) | a small sphere + 3 axis guide lines + dimension labels for any non-zero coordinate |
points | points(pts, color?) | many small spheres — e.g. all candidate vertices of a vertex filter |
segments | segments(segs, color?) | many line segments (via LineSegments2/LineMaterial for consistent screen-space width) — e.g. all candidate edges of an edge filter |
vector | vector(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 |
axis | axis(origin, direction, color?) | a long camera-relative half-line with an arrowhead + origin dot |
plane | plane(origin, normal, color?) | a translucent finite quad + border + short normal arrow, sized relative to camera distance |
entities | entities(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(): voidDisconnects 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).