Note 03 — camera template + image-pose properties & constraints (2026-06-17) (Image Pose Uxui)

**Note** from Bead: Image Pose Uxui · [canonical source](https://redfish.acequia.io/guerin/.agents/74c30681-2b0c-48b2-b2a8-9e21190a4926/2026-06-16/notes/03-camera-template-and-pose-constraints.md) · session 2026-06-16 · discussion: Talk: Image Pose Uxui

Stephen (verbatim): *"make sure the camera view controls match those incident-viewer. We should name that template to refer to in future. add a icon where the image matches view camera. extrinsic only or extrinsics and intrinsics. display a properties for image pose and allow user to express constraints on any property. eg height above dem, dsm (if google photo tiles), above sea level."*

## 1. Named camera-control template — **`incident-viewer-camera`** The canonical Redfish 3D-camera scheme, ported 1:1 from `incident-viewer/index.ts` into the calibrator (`installIncidentViewerCamera()`), so every viewer feels the same. **Bind it by this name in future apps.** - **left-drag = ORBIT** about the ground point under the view centre - **right-drag = PAN** (screen-space, world-units/pixel at the pivot) - **wheel = DOLLY** toward/away from that point - **WASD = fly** (`CameraController`, speed 600) · **V = reset** - aimAt convention: `yaw = atan2(-look.x,-look.z)`, `pitch = -atan2(look.y,|xz|)`; built-in drag-look disabled via `controller.sensitivity = 0`. - *Calibrator-specific:* the orbit/dolly pivot is the camera ray ∩ the local ground plane (anchor DEM), not incident-viewer's FMV footprint. Otherwise identical. - **TODO:** extract to a shared `santafe.live/lib/incident-viewer-camera.js` so the viewer + calibrator import the same module (currently a faithful copy in each).

## 2. ⊕ match icon Header **⊕ match** = *image matches view camera* = snapshot-view-as-pose (reads the steered camera as the photo's pose). Sits beside the ext/int toggle.

## 3. extrinsics-only vs extrinsics+intrinsics Header toggle **`ext + int` / `ext only`** → `solveIntrinsics` → `solvePose({solveFov})`. ext-only fixes fov; ext+int also solves it. (The per-property `fov: lock` does the same locally.)

## 4. Image-pose properties + per-property constraints The pose panel is now **image pose properties**: every property (Lat/Lon/Elev/heading/pitch/roll/fov) has a constraint select: - **free** — optimized normally. - **lock** — held at its current value (captured when locked). - **Elevation only:** **▲ DEM** (height above terrain), **▲ DSM** (height above the Google photo-tile surface), **▲ MSL** (above mean sea level) — with an offset (m) input. Constraints are **soft** (per feedback_every-constraint-is-soft): the prior carries the constrained values at a raised weight, and the result is re-projected onto the constraints (locks/height-refs honored exactly). Saved into the pose record as `propertyConstraints`. **First-cut limits (flagged):** - **DSM = DEM for now.** True DSM needs sampling the Google 3D-tile *surface* height under the camera (a tile raycast); currently both use the anchor DEM (`baseElev`) + offset. Wire the tile-surface raycast next. - DEM reference is the **anchor** terrain height, not the terrain directly under the (moving) camera — fine while the camera stays in the local patch; sample-at-camera is a refinement. - Constraints are enforced as soft-prior + post-solve projection, not as hard equality-constrained LM.

## Verification Bracket/identifier balance checked; **WebGPU render + camera feel NOT verified from here** (no GPU). Open the app and confirm: orbit/pan/dolly feel matches incident-viewer; the properties panel + constraints behave; ext/int toggle + ⊕ match work.