**Note** from Bead: 3D Mouse Keyboard Camera Controls · [canonical source](https://redfish.acequia.io/guerin/.agents/b28561b2-f9e6-4a2a-88f3-edb06fb262e9/2026-06-17/notes/07-ecology-synthesis-and-our-apps.md) · session 2026-06-17 · discussion: Talk: 3D Mouse Keyboard Camera Controls
Purpose: step back from the per-library catalogs (notes 01-06) and read them as one design space, then document what the apps built across the ecology in the last ten days actually do, and where they agree and diverge. This is the note to read when deciding the camera + input scheme for a new app.
## Part A — The design space, distilled ### A.1 Two questions decide everything Every navigation scheme answers two questions first. Get these right and the bindings follow. 1. **Is the user inspecting a thing, or inside the scene?** - Inspecting (a model, a footprint, a dataset): **orbit / examine**. Pivot = the thing. Free dolly on the wheel. F frames the selection. This is the CAD / DCC / web-viewer family (notes 01, 04, the editor halves of 05). - Inside (a walk-through, an avatar, a drone fly): **fly / walk**. Pivot = the camera itself. Pointer captured for look. The wheel is free for a domain action. This is the game-runtime / sandbox family (notes 05 runtime, 06). 2. **Is the surface flat-ish (a map) or fully 3D (a scene)?** - Map: the mapping-library model (center / zoom / bearing / pitch, roll locked). Left-drag pans, wheel zooms, right-drag tilts. Cesium / Mapbox / MapLibre / Leaflet (notes 02, 03). - Scene: the full 6-DOF or constrained-orbit model. three.js / taos-engine / the game engines (notes 01, 04, 05). These two axes give the 2x2 our apps actually populate: orbit-scene, fly-scene, pan-map-2D, pan-map-2.5D. ### A.2 The third paradigm rides alongside, it does not replace Object selection + edit (gizmos / manipulators, note 00c) is orthogonal to camera nav. It binds to bare keys (W/E/R or G/R/S) while camera nav binds to a modifier or a button-hold, so the two coexist on separate input channels. In our apps this shows up as **click-to-select + side panel** rather than in-scene gizmos; none of our apps yet ship a transform gizmo, which is the obvious gap if we ever build an authoring surface. ### A.3 The recurring conventions worth standardizing on Across every library surveyed, these are the points of agreement. They are the safe defaults: - **Wheel = zoom / dolly** in any inspect-the-thing app (and toward the cursor, not screen center, where the library supports it). - **WASD + mouse-look + Shift-faster** for any fly / walk mode; Space / E up, Shift / Q down. - **Left-drag is the primary gesture**, but its meaning is paradigm-dependent: look (fly), pan (map), orbit (examine). This ambiguity is the single biggest cross-app inconsistency risk. - **F (or G) frames / fits**; digit keys recall saved views, Shift+digit saves (our own emerging idiom). - **"Maya navigation"** (Alt + LMB/MMB/RMB) is the nearest cross-industry standard if we ever want an orbit scheme that power users already know. Godot is the only surveyed engine that ships it as a named preset; everyone else offers rebinding. ### A.4 The gotchas the catalog surfaced - **Inverted pitch.** The taos-engine controller uses positive-pitch-is-down (drag down = look down), so apps seed pitch with a "+down" comment, and incident-viewer negates it to get a conventional feel. Decide one convention ecology-wide and stop re-deriving it per app. - **Mapbox vs MapLibre maxPitch** (85 vs 60). If a MapLibre app wants a near-horizon tilt it must opt into the experimental 60-85 band. - **Convention drift inside one vendor** (PlayCanvas old-LMB-look vs new-RMB-look; Roblox player-wheel-zoom vs Studio-wheel-zoom-to-selection). The lesson for us: pick a controller and reuse it, do not let each app invent its own look button. - **Switching controls is dispose+recreate + preserve camera state** in three.js; the camera jumps otherwise (note 01). A mode-switch UI must carry position / orientation / target across the swap.
## Part B — Our apps' controls (last ten days) Six apps surveyed across five locations; findings read from the live source. Two share the taos-engine `CameraController` (a fly / FPS controller); two are map-pan apps; one is a passive preview; incident-viewer runs a custom orbit / pan / dolly hybrid over the engine. ### B.1 ants-in-taos - Path [`agentscript/ants-in-taos.html`](https://redfish.acequia.io/guerin/agentscript/ants-in-taos.html); engine taos-engine (ion bundle), basemap Google Photorealistic 3D Tiles. - **Paradigm: free-fly / FPS.** No mode switch. Hint: "Drag to look, WASD fly". - Mouse: left-drag = look (yaw + pitch, pitch inverted); right / middle / wheel = nothing. - Keyboard: WASD / arrows fly; Space / E up; ShiftLeft / Q down; Ctrl / Alt held = speed x3; v / V = reset view to seed. GUI (mouse): pause / reset / drop-food, sliders. - No scene picking; roster row click toggles the Properties panel. ### B.2 fire-in-taos - Path [`agentscript/fire-in-taos.html`](https://redfish.acequia.io/guerin/agentscript/fire-in-taos.html); engine taos-engine (hosted bundle), basemap AWS Terrarium DEM + Esri imagery; controller speed 700 (vs ants' 18) for the metres-scale region; seed yaw faces uphill. - **Paradigm: free-fly / FPS**, same controller as ants. No mode switch. - Mouse: same as ants (left-drag look; right / middle / wheel nothing). - Keyboard: WASD / arrows + Space-E / Shift-Q / Ctrl as ants, plus a **view-slot system**: v / V recalls default (slot 1); Digit 1-9, 0 recalls a saved camera; Shift+Digit saves the current camera (persisted in `localStorage`). Hint: "Drag to look, WASD fly, digit recalls a camera, Shift+digit saves, V = default". - No scene picking; roster row click toggles Properties. ### B.3 The shared taos-engine `CameraController` `lib/taos-engine-ion.js` ~line 1831. **A fly / FPS controller: no orbit, no drag-pan, no wheel.** - Mousedown only reads button 0 (left); right / middle never start a drag. - Drag-look: `yaw -= dx*sens`, `pitch += dy*sens` clamped near +/-pi/2. Rotation built as `Quat.fromAxisAngle(xAxis, -pitch)`, so positive pitch tilts the camera down (the inverted-pitch source). - No wheel / deltaY listener anywhere in the bundle; "zoom" is W/S fly only. - WASD / arrows + Space/E (up) + ShiftLeft/Q (down); Ctrl/Alt = x3. Keydown ignored when focus is an input / textarea. Touch: left half = move joystick, right side = look, on-screen up/down buttons. This controller is the de-facto ecology default for full-3D scenes, and it is a pure fly controller. Any app that wants inspect-the-model behavior has to build orbit on top, which is exactly what incident-viewer did. ### B.4 incident-viewer (3D STAC / FMV viewer) - Path `Documents/src/incident-viewer/`; deployed [`apps/incident-viewer/`](https://redfish.acequia.io/guerin/apps/incident-viewer/) and `santafe.live`. Engine taos-engine, but the built-in look is suppressed (`controller.sensitivity = 0`) and Space / Shift keydowns are swallowed, then a custom orbit / pan / dolly handler is layered on. - **Paradigm: hybrid.** Pointer = target-orbit / examine around the footprint center; keyboard WASD = free-fly simultaneously; plus cinematic locks (sensor view, chase-cam, view slots) that any manual input releases. Pitch is sign-inverted to a conventional feel; orbit elevation clamped. - Mouse: left-drag = orbit around footprint; right-drag = pan (screen-space); middle = nothing; wheel = dolly to / from footprint (clamped); contextmenu suppressed to free right-drag. No raycast pick. - Keyboard (transport keys ignored in form fields / over the timeline): | Key | Action | |---|---| | G | Fit all (frame whole incident) | | K | Play / pause (master transport, replaces Space) | | J / L | Seek -10s / +10s | | , / . | Step -1 / +1 frame | | F | Fullscreen | | H | Fly to selected node; FMV item -> sensor-pose look-through | | T | Set time-span of selected layer-tree node | | **Shift+T** | Toggle AIR7 ADS-B track-follow chase-cam (3rd-person behind+above, over the master clock) | | I | Toggle image on / off | | P | Drape onto terrain, or toggle FMV projection World / Cap | | Digit 1-9, 0 | Recall view slot | | Shift+Digit | Save current view to slot | | O | Re-frame footprint | | ` | Toggle network / data-source log overlay | | Escape | Hide roster context menu | | W/S/A/D + arrows | Engine fly (Space / Shift vertical disabled here) | In-app legend: "left-drag orbit, right-drag pan, wheel dolly, WASD fly". Selection via the roster / layer-tree panel, not scene raycast. The timeline is a separate input surface (drag pans the window, drag below scrubs, click seeks, wheel zooms the window) whose pointer events are stopped from reaching the camera. Note: this is the chase-cam key correction. MEMORY currently records the chase-cam on `T`; the live code binds it to **Shift+T** (plain `T` sets a node's time-span). Trust the code. ### B.5 storm-event (NOAA Storm Events) - Path [`simtable.acequia.io/apps/storm-event/`](https://simtable.acequia.io/apps/storm-event/); library Leaflet 1.9.4, dark CARTO basemap, DuckDB-WASM over NOAA Parquet. - **Paradigm: 2D map-pan** (Leaflet defaults). No 3D, no pitch / bearing, no mode switch, no custom mouse / key handlers. - Mouse: left-drag pan, wheel zoom, double-click zoom in, click marker = select / identify. - Keyboard: Leaflet built-ins only (arrows pan, +/- zoom when focused). No app keybindings. - Selection: click a circleMarker highlights it + renders the event detail panel; deep-link via URL hash. ### B.6 bonobo GIS-ABM
- Path [`harvardviz.live/models/bonobo/`](https://harvardviz.live/models/bonobo/); library MapLibre GL 5.18.0, chassis in `abm-template/src/view/MapView.js`; default `pitch:45`. - **Paradigm: map-pan with optional pitch / bearing.** The one app with a real view-mode switch: map / terrain / globe buttons (`setViewMode`, mouse only) change projection + terrain + ease pitch. - Mouse: left-drag pan; right-drag / Ctrl+left-drag rotate bearing + pitch; wheel zoom; double-click zoom in; click glyph = select bonobo -> dossier. - Keyboard: MapLibre built-ins only (arrows pan, +/- zoom, Shift+arrows rotate / pitch when focused). Zero app keybindings; all app controls are GUI buttons / sliders. - Selection: buffered `queryRenderedFeatures` picks nearest centroid -> AgentDossier; pointer cursor on hover. GUI: play / step / speed / tick-scrub, overlay selectors, fullscreen. ### B.7 snow-globe / harvard-spheres (sphere-faces) - Path `…/.agents/6a8f0a99-…/repo/greg-faces/sphere-harmonics.html`; library three.js r128; a 220px preview sphere with a harmonics CanvasTexture. - **Paradigm: passive auto-spin examine.** No OrbitControls imported; the sphere only auto-rotates. No drag, wheel, pointer, or key handler touches the 3D view. - "Controls" here = HTML sliders / toggles plus a companion-controller bridge (`?role=controller` publishes panel events through acequia group state; `?role=viewer` replays them), a remote GUI param-sync rather than a camera scheme. The chord-ray WebGPU / 3DGS snow-globe viewer in the bead's plan is not built yet.
## Part C — Cross-app read and recommendations ### C.1 Where the apps agree - Wheel = zoom / dolly wherever it does anything (Leaflet, MapLibre, incident-viewer). The two raw taos apps are the exception with no wheel handling at all. - Click-to-select + side panel is the universal picking idiom (storm-event, bonobo, incident-viewer). No app does in-scene gizmo editing. - Playback transport (play / pause / step / speed / scrub) appears in every time-driven app, via GUI buttons (bonobo) or keyboard K/J/L/,/. (incident-viewer). - View slots (digit recall, Shift+digit save) are shared between fire-in-taos and incident-viewer. ### C.2 Where they diverge (the inconsistencies to resolve) - **Left-drag means three different things** across the ecology: look (ants, fire), orbit (incident-viewer), pan (storm-event, bonobo). A user moving between apps has no stable expectation. This is inherent to the paradigm split, but it argues for a clear, visible per-app mode indicator. - **Wheel does nothing in the raw taos apps** but dollies everywhere else. ants / fire force W/S to move in and out, which is unintuitive for an inspect-a-simulation use case. Adding wheel-dolly to the taos controller would align them. - **Pitch sign is inconsistent**: taos positive-is-down, incident-viewer negates it, the map apps use library defaults. Pick one. - **Keyboard density varies wildly**: incident-viewer ~15 bindings, ants / fire add only view-slot keys, storm-event and bonobo bind none. Fine per app, but a shared help-overlay convention would help users. - **Only bonobo offers a true pointer-paradigm-relevant mode switch** (map / terrain / globe). incident-viewer has cinematic lock modes rather than a nav-mode toggle. The taos apps have none. ### C.3 Recommendations for the ecology 1. **Adopt the two-question decision (A.1) explicitly per app**, and show the user which mode they are in. A one-line on-canvas hint (as ants / fire / incident-viewer already do) is the cheap, effective pattern; standardize its wording. 2. **Extend the taos-engine `CameraController` toward a dual orbit+fly controller** (the PlayCanvas CameraControls and Godot Shift+F toggle are the models), or factor incident-viewer's orbit / pan / dolly layer into a reusable module. Right now every inspect-the-scene app re-implements orbit over a fly-only controller. This is the highest-leverage consolidation. 3. **Fix the pitch-sign convention once** in the shared controller so apps stop compensating. 4. **Give the wheel a job in the taos apps** (dolly for inspect use cases; reserve the no-wheel behavior for genuinely first-person experiences, per the sandbox-game inversion in note 06). 5. **Promote view-slots (digit recall / Shift+digit save) to a shared helper.** It already exists in two apps with duplicated code. 6. **When an authoring surface arrives, reach for a gizmo** (three.js TransformControls, note 01) on the bare-key W/E/R channel, and gate the camera controller off during a drag, rather than inventing a new selection-edit scheme. 7. **Reuse one look button across web-3D apps.** PlayCanvas and Roblox both show how same-vendor drift confuses users; our taos apps already share a controller, so keep new apps on it rather than hand-rolling. ### C.4 Memory correction noted The incident-viewer ADS-B chase-cam is bound to **Shift+T**, not plain `T`, in the live code (plain `T` sets a layer node's time-span). The active-bead note for `be9c3efd` should be read with that correction; the code is ground truth.