Early docs These pages are very early. Structure and tone are still being worked out, so anything here may change drastically.
Skip to content
Rendered from ui/src/app/components/viewer/README.md in the repository — edit it there.

Viewer — 3D Canvas, Gizmos, and Object Manipulation

The viewer is the single entry point for 3D visualisation. It owns the Three.js scene, the WebGL render loop, camera controls, and — introduced in this PR — a full suite of interactive object-manipulation gizmos.

Every gesture the user makes becomes a SceneOp. The renderer reads the result back. It never invents transforms of its own.


Why gizmos live here

Scene placement is owned by the Rust scene engine (compiled to WASM). The viewer's job is to translate pointer events into the right SceneOp and then reflect the updated state back into Three.js.

Before this PR only orbit/pan/zoom were interactive. Object placement required clicking toolbar buttons ("center on bed", "drop to floor"). The gizmo layer adds direct on-canvas manipulation without breaking the SSOT contract: every drag still ends with a Rotate, Translate, or Scale op dispatched to the WASM engine; Three.js receives the resulting matrix and mirrors it — nothing more.


Object-manipulation modes

The ObjectMode union defined in viewer-control.ts drives what happens when the user interacts with a selected mesh:

ModeWhat it showsWhat it does on interaction
noneNo gizmo (default)Clicks select / deselect objects
translateThree-axis / three-plane translation handlesEmits Translate ops per frame
rotateThree arc rotation handlesEmits Rotate ops per frame
scaleThree-axis scale handles (no planar handles)Emits Scale ops per frame
pullToFloorFace-highlight cursor; no handles on the canvasSingle face-pick → AlignFaceToFloor op

The toolbar (3d-view-toolbar) exposes one button per mode in a radio group that writes to ViewerControl.objectMode. The viewer reacts via an Angular effect() and calls ViewerScene.setObjectMode().


GizmoManager

GizmoManager (gizmo.ts) wraps Three.js TransformControls and hides all its mechanical details behind a clean delta-stream interface.

Ghost object

GizmoManager attaches TransformControls to an invisible Group (the "ghost") instead of directly to a scene mesh. The ghost lives at the world centroid of the current selection and acts purely as a drag surface. At the end of each frame the ghost is reset to its anchor with identity rotation and unit scale — so the WASM engine is always the authoritative record of where things actually are.

Incremental deltas

TransformControls reports absolute transforms of its target object. The manager converts those to per-frame incremental deltas that map directly to WASM ops:

  • translate → position difference from last frame → Translate { delta }
  • rotate → quaternion difference → axis-angle decomposition → Rotate { axis, degrees }
  • scale → ratio of current to last scale → Scale { factors }

Zero-magnitude deltas are filtered before dispatch so the WASM pipeline is not flooded with no-ops.

Shift-key snapping

While Shift is held, each TransformControls instance snaps to a fixed step defined in GIZMO_SNAP:

ModeSnap step
translate1 mm
rotate15°
scale0.1 (±10%)

When Shift is released, the snap reverts to continuous free motion (null).

Always-on-top rendering

Gizmo handles are rendered on top of the model so they are never occluded. Every Mesh node in the TransformControls helper tree has its material configured with depthTest: false, depthWrite: false, transparent: true, and renderOrder: 999. This is applied once in makeControls() — from that point TransformControls reuses the same materials.


Pull-to-floor — face picking

pullToFloor is a one-shot mode: the user clicks any face on any object and that face is aligned to the build plate floor (Z = 0) via an AlignFaceToFloor op. The mode exits automatically to 'none' after the pick.

How face picking works

raycastFace() runs a Three.js raycaster against all scene meshes and returns the selectableId (the string form of the WASM object id stored in userData.selectableId) and the triangle index of the nearest hit.

Face-group highlighting

When the cursor enters pullToFloor mode, the viewer asks the WASM engine for coplanar face groups (SceneEngineService.getFaceGroups). As the cursor moves, raycastFace identifies the hovered triangle and the viewer highlights all faces in the same coplanar group — giving the user a clear preview of which flat face will be aligned to the floor.

The highlight is applied as a per-vertex color buffer attribute on a cloned BufferGeometry. The original geometry is restored when the mode exits.


Coplanar face groups (Rust side)

src/mesh/analysis.rs::compute_coplanar_groups is the Rust function that powers the face-pick highlight. See src/mesh/README.md for the full algorithm description.

The WASM bridge method SceneHandle.getFaceGroups(id, angleThresholdDeg) calls it and returns a Uint32Array of group ids (one per triangle). The SceneEngineService.getFaceGroups() wrapper logs the call timing and hands the array to the viewer.


Interaction priority

When multiple input consumers are active at the same time (orbit, selection raycaster, gizmo), the viewer applies a clear priority order:

  1. Palm rejection (stylus in use)PointerArbiter listens in the capture phase on the canvas host (an ancestor of the WebGL canvas), so it runs before every other consumer. While an Apple Pencil / stylus is down, hovering, or was active within the grace window, it swallows pointerType === 'touch' events — the hand and wrist resting on the glass — so the palm never orbits, pinches, or selects. It decides per gesture group (see the no-tear invariant below), so it can never split a real two-finger gesture into a lone survivor. Genuine finger gestures are untouched whenever no pen is involved.
  2. Gizmo dragging in progress — gizmo owns the pointer; OrbitControls and the selection raycaster are both suppressed.
  3. Gizmo hovering (cursor over a handle, not yet dragging) — the selection raycaster is suppressed on this frame so a click registers on the handle, not on an underlying mesh.
  4. pullToFloor mode — selection raycaster is disabled; pointer is entirely dedicated to face picking.
  5. Normal mode — the selection raycaster runs; OrbitControls handles any gesture that misses a selectable object.

Viewport-cube auto-ortho

Clicking a viewport-cube face/edge/corner snaps the camera to that view and flattens the projection to orthographic — the CAD convention that a snapped view is dimension-true. The snap is then pinned: it survives pan and zoom of any distance — sticky, so a selected face can be inspected up close without ever popping back to perspective — and only breaks free on a genuine rotate dragged past the breakout distance, at which point the projection reverts to whatever the toolbar preset was (normally perspective). Because the revert targets the toolbar currentView, leaving the snap lands back in perspective only when the user entered it from perspective; a toolbar ortho preset stays ortho.

The rotate behaviour is a true detent, Shapr3D-style. While the snap is held the camera does not move at all: a rotate gesture shorter than SNAP_BREAKOUT_TRAVEL_PX (70 px, measured straight-line from where the drag started) is absorbed completely, so the dimension-true view survives jitter, a small screen touch or a stray nudge — and wiggling back and forth never breaks out, because travel is measured from the origin rather than summed along the path. Cross that distance and the snap "pops": the camera starts orbiting from the snapped orientation and the projection animates back. Interacting with the cube again always keeps ortho.

A pan or zoom releases the freeze — otherwise the very next frame would pin the camera straight back and the gesture would appear to do nothing — but never the projection: autoOrtho stays engaged, so the view keeps its flat, dimension-true look while the user pans/zooms around it freely.

ActionAuto-ortho
Cube face/edge/corner snapengage (→ ortho)
Rotate inside the breakout distancekeep — view frozen
Rotate past breakout (1-finger / left-drag / swipe)revert (pops)
Cube drag-orbit / roll / re-snapkeep
Pan (2-finger / right-drag / ⌥-swipe)keep — sticky
Zoom (pinch / wheel / autoscroll)keep — sticky
Toolbar view toggle / home resetcancel (manual)

This lives across two files. Both the projection override (autoOrtho) and the detent (snapHoldPose) are in SceneCamera. Engaging animates to the snapped direction at ~1° FOV with an apparent-size-preserving distance, then pins that pose on landing.

A snap also tells the toolbar what it did. Engaging sets currentView = 'ortho' and reports it through onViewChangeViewerScene.setViewChangeSink → the UI's view signal; a breakout restores the remembered preSnapView the same way. Earlier the snap deliberately left that signal alone, which desynced the button from the screen: the toolbar claimed "perspective" while the view was flat, so the button's icon lied and its first press was swallowed re-asserting a projection that was already active — you had to press it twice to see anything. The viewer guards the echo (cameraOriginatedView, armed only when the write actually changes the signal) so a camera-originated value is not routed straight back into setView, which would cancel the in-flight snap and its detent.

The perspective preset is seeded from the FOV the camera is built with (setPerspectiveFov(camera.fov) at construction). The settings effect that applies the user's field-of-view runs before the scene exists, so without this the preset kept its built-in default and restoring perspective — the toggle, a breakout, or the home reset — snapped the view to that default instead of the FOV the user had configured.

Holding is enforced by applySnapHold(), which the render loop calls afterOrbitControls.update() and the inertia step: it restores the pinned pose, so whatever rotation those applied is discarded before anything is drawn. Discarding it each frame (rather than accumulating) is what makes the hand-off seamless — OrbitControls re-derives its orbit frame from the camera's current position every update, so the instant the pin is released the view simply starts following the pointer from the snapped orientation, with no jump and no replay of the absorbed movement. A pan or zoom releases the same pin early via releaseSnapPinForPanZoom() — a plain snapHoldPose = null, nothing else — so the gesture is free to move the camera while autoOrtho stays engaged.

Reverting animates over the same VIEW_TRANSITION_MS + easing as the toolbar's perspective/ortho toggle, so the morph reads identically whichever control triggered it. It runs as a projection-only ProjectionTween (notifyUserViewGestureadvanceProjectionTween) rather than a full pose animation: only the FOV is driven, and the orbit distance is rescaled incrementally each frame (tan(prevFov/2) / tan(nextFov/2)) to hold apparent size. Because nothing pins the direction, target or distance, the tween never fights the gesture that triggered it — the render loop advances it on every frame, including frames where OrbitControls is driving the camera, so the user can keep dragging/zooming straight through the transition.

The revert trigger (setRevertGestureSink in SceneControls) is emitted only by the pointer-travel breakout detector for a rotate gesture — a rotate inside the detent, cube-driven moves, and any pan/zoom never emit it. Travel is measured in pixels, not camera angle, precisely because a held snap does not rotate the camera at all. The budget is per gesture: reset on each pointer down/up and, on the trackpad-swipe path (which has no pointer brackets), after an idle gap. Pan/zoom instead emit a separate, lighter setPanZoomGestureSink that only releases the freeze (see above) — the two sinks are deliberately distinct so the projection-reverting side effects of notifyUserViewGesture never fire for a gesture that is supposed to stay sticky.

Pen-priority palm rejection ("wrist detection")

On an iPad the hand resting on the glass while drawing with an Apple Pencil fires touch pointer events for the palm and wrist. Unfiltered, they drive the camera — OrbitControls' single-touch rotate spins the view and two palm contacts read as a pinch — so the model lurches while the user works with the pencil. PointerArbiter vetoes those contacts.

A fresh touch — the first contact of a group, nothing else down — is judged palm at its pointerdown (isPalmTouch, unit-tested) when a pen is active (down, hovering, or lifted within PEN_GRACE_MS) or, once a pen has been used recently (PEN_SIZE_ARM_MS), when its contact patch is palm-sized (PALM_CONTACT_MIN_PX) — which catches the palm that lands just before the tip on iPads without pencil hover. Pure-touch users are never affected: the contact-size path only arms after a pen is seen, and the pen-active path only fires while a pen is in use.

No-tear invariant. The camera's two-finger handler only engages while two touches are down; a lone touch falls to OrbitControls' single-finger rotate. If the arbiter ever swallowed exactly one finger of a two-finger gesture, the survivor would spin the camera — the "spazzing" a stylus user hits when a palm-sized fingertip, or a flickering pen hover/grace state, splits the pair. So a touch landing while exactly one other is down inherits the group verdict (admit wins over palm). A resting hand is still rejected because its contacts open the group as palm (the pencil is the active tool, and a lone palm never lifts, so the group stays palm across long pauses between strokes).

The inheritance stops at the pair. Once two touches are down, a further contact cannot split them, so it is classified on its own merits again. This is what keeps a palm from joining a live pinch: the two-finger controller re-anchors onto whichever contacts remain when a finger lifts, so an admitted palm becomes half the gesture the moment a real finger leaves — and the camera lurches with the wandering contact patch.

Synthetic resets are ignored. The two-finger controller dispatches a pointercancel per live finger to clear OrbitControls' drag state. Those travel the host's capture phase like real events, and taking them at face value emptied the live set while both fingers were still down — destroying group coherence at the exact moment it mattered. They carry a marker (synthetic-pointer.ts) and are skipped by everything that models physical contacts.

To keep a dropped pointerup (an iPad that never delivers the palm's lift) from stranding a palm verdict that every later finger would inherit — locking out all touch — stale verdicts and a stuck pen-down latch are reclaimed by timeout (TOUCH_VERDICT_STALE_MS, PEN_CONTACT_STALE_MS); a contact really still down keeps itself fresh. The user can turn the whole behaviour off from Settings → General → Controls → Palm rejection (persisted; default on).

Two-finger gestures: deciding what the user meant

Pinch-dolly, centroid pan and twist-roll are computed from the same two contacts, and fingers never move on a clean line — so every pinch carries some rotation and every twist carries some separation change. Feeding all three raw signals to the camera made a zoom spin the model.

TwoFingerGestureTracker (pure and unit-tested, driven by the DOM bookkeeping in controls.ts) arbitrates:

Three properties carry the design:

  • Roll must earn its way in, and can be shut out. Rotation is measured as an angle, so its noise floor scales with 1 / separation: 2 px of tremor reads as 0.6° at 200 px apart but 4.6° at 25 px. Since pinching in drives separation down, a fixed angular threshold gets easier to trip exactly as the user zooms — which is why an ordinary pinch used to spray roll every frame (~16° of unwanted spin on a single measured pinch-in). Roll now engages only after a sustained twist (ROLL_ENGAGE_ANGLE_RAD) at a separation where the angle means something (ROLL_MIN_SEPARATION_PX), and only while rotation dominates scaling (ROLL_DOMINANCE_RATIO). Once separation has changed by ROLL_LOCKOUT_PINCH_RATIO the gesture is a pinch for good — a latch, not a threshold a jittery frame can beat, and it survives a re-anchor so swapping fingers is not a backdoor.
  • Rotation and pinch are compared per unit radius, so the test is scale-invariant. This is what lets a twist be recognised with the fingers close together, and getting it wrong made roll unusable in the hand even after it "worked". Rotating by θ moves each fingertip θ·r; scaling by s moves each fingertip (s−1)·r. Dividing out the common r leaves radians against a separation ratio, independent of how far apart the fingers are — the same split UIKit draws between its pinch (scale) and rotation (angle) recognisers. Comparing an arc length against an absolute pixel change instead is biased by the radius: it demanded 6° of twist at a 300 px span but 30° at 60 px, so a normal pinch-sized grip could never roll. The lockout is a ratio for the same reason — a narrow grip and a wide one should have to pinch equally hard, not equally far.
  • Travel is net displacement from the gesture's origin, never summed per-event path length. This distinction decides whether roll can fire at all on real hardware, and getting it wrong is subtle because it still looks correct in a clean test. A fingertip's reported separation jitters every event, so summing |Δdist| integrates the absolute value of that noise and only ever grows: at 120 Hz even 0.3 px of jitter crosses a 24 px pinch threshold in 0.58 s, latching roll off mid-twist before the user has rotated far enough to engage it. Measured from the origin, the same pure twist reads 0.4 px radial against 43.6 px tangential. The fixtures in two-finger-gesture.spec.ts therefore model contact jitter deliberately — a constant-separation twist describes no hardware that exists, and hides exactly this class of bug.
  • Dead zones accumulate, they do not discard. Each channel keeps its own anchor and moves it only when it actually applies motion, so sub-threshold movement is stored rather than thrown away. The previous code re-based every anchor each event, silently deleting anything below the threshold; on a 120 Hz iPad a deliberate slow pinch never reached the 1.5 px per event it needed and the camera simply refused to zoom.

Per-event clamps (ROLL_MAX_STEP_RAD, DOLLY_MAX_STEP_FACTOR, PAN_MAX_STEP_PX) absorb discontinuities no hand can produce — a contact patch morphing, coalesced events after a stall, or the pair re-anchoring. The pair driving the camera is pinned explicitly to the two longest-standing contacts, so a third finger never silently displaces one, and a change of pair re-anchors without emitting motion.

Recovering a stranded gesture. While two fingers are down the controller disables OrbitControls, so a lift it never sees leaves the camera dead until a reload. Three nets cover that: window-level pointerup/pointercancel (a finger released over the toolbar is delivered to the toolbar, not the canvas), blur/visibilitychange (backgrounding an iPad mid-pinch delivers no lift at all), and a staleness sweep on the next pointerdown (TOUCH_STALE_MS) for events the OS drops outright. The sweep deliberately does not run on move: a stationary finger emits nothing, so a user pausing mid-pinch would have their live gesture torn down underneath them.

Hand-aware inspector tooltip placement

The G-code inspector tooltip (extrusion width/height/speed on hover in the scalar views) is anchored to a virtual element at the pointer. A fixed below-right placement is ideal with a mouse but lands the readout directly under the palm of a right-handed pen user. preferredHoverPlacement (hover-placement.ts, unit-tested) picks the side per input:

PointerPlacement
mouseright-start — the familiar below-right desktop behaviour.
touchtop — the finger and hand occlude below, so float above.
penopposite the tilt (hand) direction; top when near-upright.

The elegant part is the pen case: PointerEvent.tiltX/tiltY point from the tip toward the barrel — i.e. toward the hand — so the tooltip floats to the opposite side. Because tilt reveals which way the pen leans, this adapts to left- vs. right-handed users with no setting to configure. Floating UI's flip/shift still keep it on-screen, so this only chooses the preferred side. The viewer.ts effect re-anchors when the preferred side changes (input swap or a tilt that crosses an axis).


Anatomy

The viewer is split into focused files so each concern stays under ~300 lines.

viewer/
├── viewer.ts                  Angular component — effects wiring, WASM ↔ Three bridge
├── hover-placement.ts         preferredHoverPlacement — hand-aware inspector-tooltip side (pen tilt)
├── scene/                     ViewerScene and all Three.js sub-systems
│   ├── index.ts               ViewerScene — owns renderer, render loop, delegates to sub-modules
│   ├── camera.ts              SceneCamera — animations, view presets, fit-to-content, near/far
│   ├── controls.ts            SceneControls — orbit inertia, multi-touch (pinch/pan/roll), autoscroll zoom
│   ├── grid.ts                SceneGrid — adaptive build-plate grid with cross-fade and fade-on-graze
│   ├── pointer-arbiter.ts     PointerArbiter — pen-priority palm rejection (capture-phase touch veto)
│   ├── selection.ts           SceneSelection — selectable registry, emissive highlight, raycasting, face-pick
│   ├── types.ts               Shared public types (SceneSelectionHandlers, SceneGizmoHandlers, ViewerView, …)
│   └── utils.ts               disposeObject — recursive Three.js geometry/material cleanup
├── gizmo.ts                   GizmoManager, computeSelectionCentroid, raycastFace
├── gcode-orchestrator.ts      GcodeOrchestrator — owns the built model; Three.js visibility only (no geometry)
├── gcode-layer-renderer.ts    buildGcodeModel, applyLayerVisibility, applyHiddenRoles, setDetailLevel
└── index.ts                   Public re-exports

ViewerScene sub-module responsibilities

FileClassOwns
scene/camera.tsSceneCameraPerspectiveCamera pose, view animations, fitToContent
scene/controls.tsSceneControlsOrbitControls config, orbit inertia, touch gestures, autoscroll zoom
scene/grid.tsSceneGridBed grid LineSegments, adaptive spacing, CSS theme integration
scene/pointer-arbiter.tsPointerArbiterPen-priority palm rejection — capture-phase touch veto while a stylus is in use
scene/selection.tsSceneSelectionSelectable Map, emissive highlight, pointer event plumbing, face-pick overlay
scene/index.tsViewerSceneThree.js primitives (Scene, WebGLRenderer, OrbitControls), contentRoot, render loop

G-code layer architecture

All G-code geometry is built exclusively inside GcodeOrchestrator.buildFromHandle() by calling the WASM-side GcodeSource.getLayer(). Three.js receives finished Float32Array buffers and is responsible only for visibility and scrubbing draw-ranges. No geometry is constructed in TypeScript.

One buffer per role, not per layer

Geometry is packed into a single instanced buffer pair (tube + joint balls) per role, spanning every layer, with instances ordered layer-ascending. The obvious alternative — a group per layer — costs a draw call per layer per role: a 335-layer plate reached ~2 500 draw calls and ~2 500 distinct materials, which pinned the frame at ~25 fps on an M-series Mac purely in driver overhead. Per role it is ~18.

The packing order is what keeps that cheap to drive:

ControlRange shapeMechanism
Layer maxprefixInstancedMesh.count / setDrawRange
Progress scrubprefix (within top)same count, split per role by block order
Layer min0 or == maxuLayerMin uniform, collapsed in-shader
Role hidingwhole rolemesh.visible

layerMin is never an arbitrary window (it is 0 when showing all layers, otherwise layerMax), so the only non-prefix case is "single layer" — handled by a per-instance aLayer attribute and one uniform rather than by splitting the buffer back up. Because a raycast cannot see a shader-side collapse, the hover probe uses an offset-aware raycast that starts at the first visible instance.

On-demand rendering

ViewerScene only calls renderer.render() when the image can actually have changed: camera pose delta (which covers orbit damping, inertia, snap-hold, autoscroll and the projection tween), an active animation or gizmo drag, pointer activity over the canvas, or an explicit invalidate() from content changes. A static plate therefore costs nothing — previously it was fully redrawn 60 times a second.

Detail levels, and when each is used

Draw calls were only half the story: a 1.14 M-segment plate at full detail is 77.7 M triangles per frame, which no draw-call count makes affordable. So the preview has two levels of bead geometry:

LevelTubeJointsTris/segment
highoctagon, cappedyes68
low4-sided diamond, cappedno16

Both LODs are built up front and share the same instanced attributes, so switching is a geometry pointer swap — no instance data is touched, and their extents are identical so the instance bounding sphere stays valid.

Two properties of the cheap bead are load-bearing, and both were learned by breaking them:

  • Ridge on top, never a flat face. A first attempt rotated the 4-gon 45° into a flat-topped box, reasoning that a squished extrusion really does have a flat top. But every bead on a layer then has a horizontal top face at exactly the same Z, and beads overlap constantly — at every path corner, and wherever flow deliberately overlaps a neighbour. Coplanar faces at identical depth is textbook z-fighting, and it speckled the whole plate. The default diamond orientation puts a ridge on top, so overlapping beads differ in Z almost everywhere. The octagon has the same property, which is why the high LOD never showed the artifact.
  • Capped ends. An open tube shows its hollow interior wherever a path ends, which reads as beads being chopped off mid-air. Caps cost 8 triangles and remove it. They are invisible mid-path (the next segment covers them), so they only pay off where it matters.

Choosing a level — PreviewDetail

The user always decides, via Settings → General → Preview detail (auto / performance / quality, persisted). auto is the default and is built around one observation:

Rendering is on-demand, so a still view costs exactly one frame.

Expensive, good-looking geometry is therefore affordable precisely when the user has stopped to evaluate the plate — and only interaction has to be cheap. auto resolves as:

  • Plates under the interactive budget stay at full detail permanently, so ordinary models never visibly change as you orbit.
  • Heavier plates drop to the cheap bead only while the view is moving, and snap back to full detail ~200 ms after it settles.
  • The settled budget is far larger than the interactive one, because it buys a single frame rather than a sustained frame rate.

Hardware detection is by measurement, not by name. GPU strings are routinely masked and are a poor guide to throughput anyway, so auto instead promotes once, measures the real frame, and demotes permanently for that model if it blew the budget. This adapts to the actual machine, including thermal throttling and integrated GPUs.

Detail is re-evaluated whenever the view settles, the layer range or scrub changes (the layer slider is a draw-range prefix, so isolating a layer genuinely shrinks the frame and can earn full detail back), or the user changes the preference.


What this module deliberately does not do

  • No scene-state ownership. Transforms are not stored here. The WASM SceneHandle is the only truth; Three.js matrices are a read-only mirror of it.
  • No G-code geometry construction. GcodeOrchestrator never builds BufferGeometry itself — it only routes the WASM-emitted Float32Array buffers into Three.js LineSegments via gcode-layer-renderer.
  • No multi-object gizmo. When multiple objects are selected, the gizmo appears at their collective world centroid but each object receives independent Rotate/Translate/Scale ops with the same delta. A unified pivot is not implemented for v1.
  • No undo stack. onDragEnd is the hook point for a history layer; the viewer itself discards the delta stream when the drag ends.

See also