ADR-006: Camera controls are gestures in, cameras out
- Status: Accepted (2026-10-16).
- Evidence:
packages/effect-three/src/kernel/orbit.ts,cameraMotion.ts,pointer.ts;test/orbit.test.ts(checked against three.js’sOrbitControls), the adapter’s browser test of orbiting and page scrolling. - References: three.js
examples/jsm/controls/OrbitControls.jsat 0.186.1; Dreisrc/core/OrbitControls.tsxatbf6f4ad.
Context
In React Three Fiber, Drei’s <OrbitControls> wraps three.js’s OrbitControls. The
controls listen to the canvas themselves, keep their own state (target, spherical deltas,
damping), and mutate the camera every frame. The application learns of changes through an
onChange callback, if at all.
Here the camera is part of the scene description, derived from the Model. Controls that mutated the host camera would leave the Model out of date: time travel, undo, persistence and tests would no longer see where the camera is.
Decision
- The scene decides who owns gestures.
scene({ gestures: 'scene' })claims them, as connectingOrbitControlsdoes: the wheel no longer scrolls the page,touch-actionisnone, every press captures the pointer and the context menu stays shut. The default,'page', leaves all of these with the page. - The kernel reports gestures, not camera moves. While claimed, every drag, wheel and
two-pointer pinch on the canvas becomes a
Gestureevent: pixel deltas, the press’s button and modifiers, the area’s size, the view it began in, and the mesh it began on. Line and page wheel deltas are converted to pixels as three’s controls convert them. - Orbiting is a pure function.
orbit(camera, gesture, options)returns the nextCamera, followingOrbitControlsstep by step: button mapping and modifier swaps, rotate, dolly and pan maths, polar, azimuth, distance and zoom limits. The camera’slookAtis the target, so the Model holds nothing but the camera.rotateCamera,dollyCameraandpanCameraare exported for keys and buttons. - Damping belongs to the camera.
dampingon a camera eases the rendered camera towards its description round the target, in scene time, asenableDampingdoes per update. The Model always holds where the camera is going. - The Model decides when controls apply. A gesture that began on a mesh says so
(
key), and one over a view names it (view); the application ignores what it should, as an R3F app disables controls while dragging an object.
Consequences
- Every camera move is a Message: DevTools replay it, and a test can drive the camera
without a browser.
orbitis checked against three’s own controls, driven through the same mouse and wheel handlers, to six decimal places. - A gesture that starts on an interactive mesh also orbits unless the application ignores
it, as in R3F without
stopPropagation. - Since added: zoom to cursor (gestures carry where they happened; a middle-drag aims at
its press point, where three 0.186 has a typo),
orbitKeyfor the arrow keys (an app maps focused key presses to Messages), and Drei’sBoundsas measuring events plusfitCameraandclipCamera. - Since added: view cameras damp as the scene’s camera does, each on its own.
- Since added (2026-10-19): the transform gizmo, the same way. three’s
TransformControlslisten to the canvas and move the object; heregizmoNodesdescribes the handles from the Model,pressGizmotakes hold with the press ray and the camera, anddragGizmoreturns the next placement for each drag ray, following three’s maths (the drag plane, offsets, snaps, rotation speed) to six decimal places. The adapter’sTransformGizmosubmodel reportsMovedandFinishedOutMessages; the parent keeps the placements, so undo is a list. Evidence:test/gizmo.test.ts(three’s controls as the oracle), the adapter’stransformGizmo.test.ts, the arrange sample. - Since added (2026-10-19): box select, the same way. three’s
SelectionBoxwalks the scene for meshes inside a frustum; hereboxSelect(camera, size, from, to, selectable)takes the candidates from the Model and returns their keys, matching three’s side planes (and keeping to the camera’s near and far, where three’s box does not). AGestureEndscene event ends a gesture that moved, so a drag on nothing can finish what it drew. The adapter’sBoxSelectsubmodel claims shift-drags, reportsSelected { keys }, and places its outline in CSS pixels;movedAlikemoves the rest of a selection as the gizmo moved the first. Evidence:test/boxSelect.test.ts(three’s box as the oracle),test/gestures.test.ts, the adapter’sboxSelect.test.ts, the arrange sample. - Since added (2026-10-19): the viewport gizmo, the same way. Drei’s
GizmoHelpercopies the inverse of the camera’s matrix onto its group every frame and tweens the camera on a press; hereviewportGizmoNodes(key, camera)turns the axes by the Model’s camera, andcameraAlong(camera, axis)is where the tween ends, for the Model to keep (a camera’sdampingeases it round). It is drawn in the newhud, Drei’sHudas data: a nested graph drawn over the scene with only depth cleared (renderOverin both render kits), through an orthographic camera in CSS pixels aimed so the HUD’s origin sitsmarginin from itsalignment, and picked before the scene. The gizmo follows the Model’s camera, so a damped camera arrives after it. Evidence:test/hud.test.ts,test/viewportGizmo.test.ts(Drei’s turn as the oracle), the adapter’sviewportGizmo.test.ts, the arrange sample. - Since added (2026-10-19): Drei’s
PivotControls, the same way:pivotNodesdescribes every handle at once, turned with the object and sized in scene units;pressPivottakes the point the press hit, anddragPivotfollows Drei’s maths (calculateOffsetfor arrows and spheres, the ray’s line meeting the plane for squares and arcs,calculateAngle, a sphere’s 2 to the power of a fifth of the drag). Drei’s handlers compose a world delta matrix; ours return the placement it gives. Unseen hit shapes round the thin arrows and arcs make them easy to find, as Drei’s hit cylinder does. Evidence:test/pivot.test.ts(Drei’s handlers replayed with three’s matrices as the oracle, and picking each handle), the adapter’spivotControls.test.ts, the arrange sample. - Since added (2026-10-20): a measuring tape, with no three or Drei counterpart, built
the same way:
placeTapeEndandpointTapereturn the nexttapefor the Model,tapeNodesdraws it (unpicked, so clicks reach the scene), and the adapter’sMeasuringTapereportsMeasured { from, to, length }and labels the length with an overlay. Evidence:test/tape.test.ts, the adapter’smeasuringTape.test.ts, the arrange sample. - Since added:
autoRotateon a camera, afterOrbitControls.autoRotate(rotateLeftby2π / 60 * autoRotateSpeeda second, only while no gesture is in progress). The rendered camera turns round its target in scene time, holding still while a gesture is pressed. Before a gesture on a camera that has turned, the scene reportsAutoRotated { view?, position };autoRotated(camera, detail)keeps it, so the gesture goes on from where the camera was shown, as gliding reportsXrMoved. A new position orlookAtturns on from there; a lens change keeps the turn. A turning camera’s pose depends on how long it has gone undescribed, so the convergence property describes still cameras andautoRotate.test.tscovers turning.