diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index aaedd4eb32..121183149f 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -66,6 +66,13 @@ Items with a **→ details** link have a full write-up in [`backlog/`](backlog/) - [ ] **Fades pop instead of ramping after the render loop idles** `ready` — `fadeRegistry.fadeTo` stamps the ramp's start from the stale last-_rendered_-frame time, not a live clock. → [details](backlog/2026-08-20-fade-pop-after-idle.md) - [ ] **Liveness guards accept `undefined`; `NaN` alpha class in fixtures** `ready` — `zoneOfAvoidanceLiveness.ts`'s `=== null` guard lets `undefined` slip through, and `focusBlend`-omitting fixtures produce silent `NaN` alphas. → [details](backlog/2026-08-20-liveness-undefined-guards.md) - [ ] **`createTieredScfdFetcher` factory** `ready` — the Edenhofer dust fetcher will be the third hand-copied tiered-SCFD fetcher (after `mcpmFetcher` and polyphorm's); collapse the three into one `createTieredScfdFetcher(baseName)` factory on next touch. +- [ ] **Body-arm entry doesn't adopt the arriving pose's tilt** `deferred` — disengage can pop up to 37°. → [details](backlog/2026-09-11-camera-arm-entry-adopts-arriving-tilt.md) +- [ ] **World-arm wheel burst has no per-frame turn clamp** `awaiting-decision` — a 20-notch burst can roll 115° in one frame. → [details](backlog/2026-09-11-camera-absolute-arm-wheel-burst-unclamped.md) +- [ ] **`deriveBodyStates`' key type is a pre-existing lie (14 casts)** `needs-design` — callers cast to an unrelated `BodyId` union. → [details](backlog/2026-09-11-derive-body-states-scene-body-id-type.md) +- [ ] **Frame-tagged clip keyframes have no per-endpoint authoring validation** `needs-design` — a straddling channel tween silently freezes. → [details](backlog/2026-09-11-clip-per-endpoint-tag-authoring-validation.md) +- [ ] **f32 half-ulp sightline nudge at the body-arm flip** `needs-verification` — ~4 mas, in two narrow `h/R` windows. → [details](backlog/2026-09-11-camera-f32-half-ulp-sightline-nudge-at-arm-flip.md) +- [ ] **`bodyLikeFraming` ⇄ `focusFraming` import cycle** `ready` — a type-only back-edge; extract the type. → [details](backlog/2026-09-11-framing-import-cycle-bodyLikeFraming-focusFraming.md) +- [ ] **Camera radar residuals: wake vote, channel expiry, authoredOverride** `ready` — three hand-restated facts from the wave-end radar. → [details](backlog/2026-09-11-camera-radar-residuals-h3-m4-m5.md) ## Rendering @@ -179,6 +186,8 @@ Items with a **→ details** link have a full write-up in [`backlog/`](backlog/) - [ ] **Earth caption stamp out-picks occluders** `ready` — the forced-band 18 px Earth pick point punches through a transiting Moon. → [details](backlog/2026-07-29-earth-caption-stamp-outpicks-occluders.md) - [ ] **Touch picking selects the wrong galaxy** `needs-design` — the pick pad is in device px, so the clickable disc halves on retina/phone. → [details](backlog/2026-07-29-touch-pick-accuracy.md) - [ ] **Windows touchscreen pinch-zoom dead** `needs-repro` — works on mobile; gesture code is platform-uniform PointerEvents, so event delivery on Windows is the suspect; needs an on-device event log. → [details](backlog/2026-08-16-windows-touchscreen-pinch-zoom.md) +- [ ] **Camera smooth wheel zoom + flick coast** `awaiting-decision` — Google Maps / Cesium feel via synthetic `InputStep`s in the aggregator; revises grill Q8 (no inertia), shape pre-ruled. Follow-up to #647. → [details](backlog/2026-09-09-camera-smooth-zoom-and-flick-coast.md) +- [ ] **Wheel notch routed by last frame's winner** `needs-design` — the replay routes a notch before the frame has picked its winner; four defects follow. → [details](backlog/2026-09-10-wheel-notch-route-by-last-winner.md) - [ ] **Autorotate + mouse-move jitter** `needs-repro` — intermittent frame jitter seen on `refactor/debug-derivation`, diff-clean per investigation; falsify against base commit from a second worktree. → [details](backlog/2026-08-20-autorotate-mousemove-jitter.md) - [ ] **StatusBar mobile reflow** `ready` — reflow the StatusBar for narrow viewports (no media queries today). The InfoCard bottom-sheet + SettingsPanel collapse-launcher already shipped. - [ ] **SettingsPanel polish** `needs-design` — visual cleanup + section re-ordering + per-section icons; 2.3k lines of hand-coded text-only rows today. → [details](backlog/2026-07-22-settings-panel-polish.md) diff --git a/docs/backlog/2026-09-09-camera-smooth-zoom-and-flick-coast.md b/docs/backlog/2026-09-09-camera-smooth-zoom-and-flick-coast.md new file mode 100644 index 0000000000..18b6f5de9f --- /dev/null +++ b/docs/backlog/2026-09-09-camera-smooth-zoom-and-flick-coast.md @@ -0,0 +1,85 @@ +# Camera: smooth wheel zoom + flick coast (Google Maps / Cesium feel) + +**Raised:** 2026-09-09, user, on the spec-2 camera-pivot branch (PR #647). +Investigated, not built. Follow-up PR after #647 lands. + +## Ask + +Google Maps and Cesium animate a wheel notch instead of jumping, and a short +drag that ends fast coasts a little after release. skymap does neither: a +notch is applied whole in the frame it fired, and release stops the camera dead. + +## Ruling context + +Grill Q8 (`docs/grill-sessions/globe-camera-pivot-2026-08-24.md`) ruled **no +inertia in the first landing**, and pre-ruled the only acceptable later shape: +Cesium's flick-only synthetic replay, no persistent velocity, cross-cancel on +any new input, replayed in the body-fixed frame. Building this is a Q8 +revision and needs the user's word; the shape is already fixed. + +## Why it is cheap here + +Every camera motion is an `InputStep` (`src/@types/camera/InputStep.d.ts`) +that `inputAggregator` (`src/services/engine/subsystems/inputAggregator.ts`) +collapses per frame and `replayInput` (`src/services/engine/camera/replayInput.ts`) +replays through one path — the surface controller in a body arm, the world-arm +fold otherwise. `gestureEnd` is the single commit site: it bakes the register, +drops the surface latch, and clears `dragging`. Nothing downstream can tell a +synthesized step from a real one. Both features therefore live in the +aggregator, with `nowMs` passed into `drain()`. No change to the controller, +the drivers, the fold, or the authored/displayed two-box. + +## A. Smooth zoom + +Today: `factor = e^(deltaY·k)` per notch, applied whole; notches in one frame +multiply (`foldZoom`). + +Shape: the aggregator keeps a remaining log-factor plus the latest cursor +pixel, and each frame emits a synthetic zoom step releasing a fraction of it +(exponential approach, ~150–250 ms, retargeting as notches arrive). Cursor +anchoring, `anchoredZoomStep`, the roll ride, the tilt mapping and the floor +come for free. + +Two consequences to decide up front: + +- The orientation settle's decay is defined **per input step**. Ten small + steps per notch decay unauthored deviation faster than one big step. Make + the step law "per unit of log-zoom" in the settle un-braid rather than + accept a silent feel change. +- At-rest zoom commits to the store per step. Treat the ease as a gesture + (`duringGesture: true` + a synthetic `gestureEnd`) so it commits once. + +Trackpads already deliver continuous small deltas (`WHEEL_GESTURE_GAP_MS` +handles their momentum bursts); keep the ease short or they feel laggy. + +Estimate: 40–60 lines + two tests (retargeting mid-ease; single commit). + +## B. Flick coast + +Shape (Cesium `maintainInertia` / `decay`, see +`docs/research/2026-08-24-camera-pivot/cesium-notes.md` §2.9): on release +after a hold under ~400 ms, hold the last drag motion and emit a synthetic +drag step per frame with the motion scaled by `exp(−(1−c)·25·t)`, c ≈ 0.9; +stop under 0.5 px; **only then** emit `gestureEnd`. Deferring `gestureEnd` +keeps the surface latch and the frozen anchor alive and the orbit-drag driver +rendering the register. Any pointer or wheel event cancels the coast (the +aggregator sees it first). Body-fixed replay falls out of the anchored drag +rotation re-solving the ground under the synthetic cursor. + +Pan and orbit modes only. Cesium gives tilt and look no inertia, and a +coasting tilt would keep writing the remembered-tilt memory (ruling 12). + +Estimate: 80–100 lines + three tests (flick vs hold threshold; decay stops; +cross-cancel). + +## Shared hook + +The same per-frame synthetic-step emitter would host the gesture-end settle +tween proposed for the jarring tilt-up + zoom-out (SDD ledger, 2026-09-03 +ideas list, item 1). + +## Sequencing + +After the settle un-braid on #647 lands, so the per-step vs per-unit decay +question is answered in one place. Feel constants (ease τ, decay c, flick +threshold) go in `src/data/camera/`. diff --git a/docs/backlog/2026-09-10-wheel-notch-route-by-last-winner.md b/docs/backlog/2026-09-10-wheel-notch-route-by-last-winner.md new file mode 100644 index 0000000000..703d27cf4e --- /dev/null +++ b/docs/backlog/2026-09-10-wheel-notch-route-by-last-winner.md @@ -0,0 +1,103 @@ +# Camera: a wheel notch is routed by LAST frame's winner + +**Raised:** 2026-09-10, during PR #647's final review. User ruled: later, own change. + +Two pre-existing defects with one cause, and it is stage order: the replay runs +BEFORE the winner is picked, so `replayInput` has nothing to route a notch by +except `cameraRuntime.register.winner` — last frame's answer +(`src/services/engine/camera/replayInput.ts:215`, `:242`). A third case (c) has +the same shape one stage later: the replay routes by the STORED arm, not the arm +the fold resolves this frame. + +## (a) The notch on a follow→spin hand-off frame is dropped + +On the exact frame `followApproach` saturates and hands off to `autoRotate`, the +notch resolves into follow memory (`isFollowDriverId(winnerLastFrame)`) that the +winning spin never adopts. Pinned by the driver golden trace at +`tests/services/engine/frame/driverGoldenTrace.test.ts:189` — "This notch lands on +the hand-off frame and is DROPPED (routed by last frame's winner into follow memory +the spin never adopts)" — the marker added by `e0985c5d4`. + +Splitting the follow driver into `followApproach`/`followHold` gave every spin-on +focus a hand-off frame, so this went from rare to once per focus. + +## (b) The notch on a spin-OFF frame zooms a frozen base + +`spin: { owns: winnerLastFrame === 'autoRotate', … }` still reads true on the frame +autoRotate goes off, so the notch is applied to the pre-spin `base` the spin froze, +and `commitOnEdge` (`src/services/engine/camera/commitOnEdge.ts`) bakes that stale +pose on the same edge. + +## (c) The notch on a fly-to's pre-fold frame takes the other zoom + +Raised by T19's review (2026-09-10). `routeToSurface` gates on `camera.base.frame` +(`replayInput.ts:104-106`), and the fly-to saga now commits a body arm from +anywhere. Launched from OUTSIDE the band, `base` is a body arm for the one frame +before `projectFramePose` disengages it, so an at-rest notch in that frame runs +`surfaceStep`/`anchoredZoomStep` (and commits its own body arm) where the orbit +zoom was due. Same when the body arm names a body other than the focused one +(`regimeArmFor` rejects it). One notch's scaling in a ≤ 16 ms window, no pose +discontinuity; the fix is routing on the RESOLVED regime, not the stored one — +gating the saga on the band would put the band predicate back in the instrument +(spec §9). + +## Fix shape + +Route the notch by THIS frame's winner. The routing stage is the replay, so the +winner has to be known above it — and today the pick cannot simply move up, because +both of its arguments are replay OUTPUTS: + +- `pickWinner(drivers, rootState, approachDone)` reads the POST-replay EFFECTIVE + `rootState` — the snapshot with the replay's own actions folded through the camera + reducer (`src/services/engine/camera/stepCameraRuntime.ts:89-95`). That is + load-bearing, not incidental: the drivers must see this frame's commits, `endDrag` + above all, or `orbitDrag` wins one frame too long. +- `approachDone` is `drained.follow.saturated` (`stepCameraRuntime.ts:100-101`) — + the replay's follow memory. + +Not a blocker, contrary to first appearances: the epochs. `pickWinner` takes none +(`src/services/engine/camera/cameraDrivers.ts:38-42`), and `advanceEpochs` already +runs AFTER the pick, consuming `winner.epoch` (`stepCameraRuntime.ts:104-112`). + +Two directions, neither worked through: + +- Pick from the PRE-replay snapshot and re-establish the `endDrag` guarantee some + other way — the gesture-end edge is present in the drained steps themselves, + before the replay folds it. +- Split the pick in two: a ROUTE decision above the replay, needing only the + pre-replay intent plus last frame's follow memory, with the existing post-replay + pick kept for the pose. Whether a correct route can be decided on that much is + the open question. + +Either way, re-record `tests/fixtures/camera/driverGoldenTrace.json` with a +parse-compared cell diff in the commit body, and drop the marker at the leg. + +## (d) The spin phase the notch zooms off is advanced twice, under two rules + +Raised by the wave-end entanglement radar (2026-09-10, finding M3). Same family: +the replay needs a fact the frame has not resolved yet, so it resolves a private +copy. + +`replayInput.ts:236-239` advances the autoRotate epoch locally — +`advanceEpoch(ctx.autoRotateEpoch, active ? camera.base : null, nowMs)` — reads +`elapsedMs` off it for `applyWheelZoom`'s `spinElapsedMs`, and throws the row +away (handing it to `advanceEpochs` would keep a fold-time reset the frame's own +rule declines). The frame's advance +(`stepCameraRuntime.ts:106` → `cameraEpochs.ts:59-67`) replays `prev.ref` — a +guaranteed no-op — on every frame the winner is not `autoRotate`. + +The two disagree by construction on any frame some other driver wins while +auto-rotate is on: the local advance re-bases on a changed `camera.base` (a +commit from earlier in the same drain) where the frame's does nothing. On that +frame `applyWheelZoom` zooms off a spin position the renderer never showed, and +the commit bakes it — a yaw pop on a wheel notch during auto-rotate, which is +the bug `applyWheelZoom`'s header says it fixed, re-entered through the other +door. + +Fix shape: make the spin phase a VALUE rather than a place read twice — resolve +`(base ref, nowMs) → spinElapsedMs` once, above the replay, and let both the +replay and `advanceEpochs` take it. The equivalent: advance the real row before +the replay and let the winner gate only the RESET, never the read. Either +removes the discard, and with it the comment at `replayInput.ts:236-237` that +exists to teach the discard. Golden-trace re-record likely — this moves a phase +the `driverGoldenTrace` fixtures sample. diff --git a/docs/backlog/2026-09-11-camera-absolute-arm-wheel-burst-unclamped.md b/docs/backlog/2026-09-11-camera-absolute-arm-wheel-burst-unclamped.md new file mode 100644 index 0000000000..ee5a49b803 --- /dev/null +++ b/docs/backlog/2026-09-11-camera-absolute-arm-wheel-burst-unclamped.md @@ -0,0 +1,56 @@ +# World-arm wheel burst has no per-frame turn clamp + +**Raised:** 2026-09-10/11, F1 fix rounds on PR #647 (`f1-fix1-report.md` concern +1; `f1-decay-review.md` finding F2). User ruled: backlog, not this PR. + +The body arm's zoom settle prices its per-notch turn on `u = |ln +spentZoomFactor(factor)|`, where `spentZoomFactor` (`src/utils/camera/spentZoomFactor.ts`) +folds a frame's wheel events through a `[0.5, 2]` altitude-step clamp before +the turn is computed — a burst can never buy more settle than it bought zoom. +The world (absolute) arm has no equivalent clamp: `u` is the raw folded +`logZoom`, unbounded. + +## Where + +- `src/data/camera/orientDecay.ts:7-8` still documents the no-whip property + ("a reference move beyond `rideBoundRad` in ONE notch is unauthored") — true + of the ride, not of the decay. +- `src/utils/camera/orientStepRad.ts:12` caps at `capRadPerLogZoom · u` with no + ceiling on `u` itself; `src/services/engine/camera/levelledPose.ts:32` same. +- `src/services/engine/camera/replayInput.ts:161,213` pass the raw folded + factor into the world-arm path; nothing clamps it before + `frameAlignedRoll`/`orientStepRad` see it. +- `zoomedDistance.ts:17-35` clamps only at the collision/recession envelope, + which does not bound a large in-band burst. + +## Measured consequence + +`f1-decay-review.md` F2: a 20-event burst that folds to `e²` gives +`frameAlignedRoll` a `u = 2.0` cap — a 115° one-frame roll, versus the +pre-change bound of `rideBoundRad + capRad ≈ 0.4` rad. The standing "Δscreen-up +never exceeds 23° across 16,000 fuzzed notches" guarantee (H4) no longer holds +on the world arm. + +## Why the obvious fix doesn't transfer + +The body-arm util (`spentZoomFactor`, an eye-distance ratio) is not reusable +here: the world arm's zoom factor applies to **altitude above the pivot's +surface** (`zoomedDistance.ts:17-35`, `distance′ = radiusMpc + (distance − +radiusMpc)·factor`), not to `distance` itself, and which quantity is right +depends on `pivot.radiusMpc` — a fact only the caller (`replayInput`, three +call sites) has. `frameAlignedRoll` cannot derive `u` from its own arguments; +deriving it correctly needs a pivot-aware `spentDistanceFactor(pre, post, +pivot)` util threaded from those three sites — a larger artifact than the +current single threaded parameter, and it was explicitly flagged for a +controller ruling rather than implemented in the fix round. + +## Fix shape + +Author `spentDistanceFactor(pre, post, pivot)` (mirrors `zoomedDistance`'s +altitude-vs-plain-distance branch on `pivot.radiusMpc`), thread it through +`replayInput`'s three world-arm call sites in place of the raw folded factor, +and drop the now-redundant `Math.abs(Math.log(step.factor))` duplication +(`f1-fix1-report.md` §"Not collected"). Re-verify `driverGoldenTrace.json` — +`f1-fix1-report.md` measured `displayed[6]` moving at the 1e-17 level under the +naive distance-ratio approach, so the correct pivot-aware form needs its own +fixture check. diff --git a/docs/backlog/2026-09-11-camera-arm-entry-adopts-arriving-tilt.md b/docs/backlog/2026-09-11-camera-arm-entry-adopts-arriving-tilt.md new file mode 100644 index 0000000000..755e592b77 --- /dev/null +++ b/docs/backlog/2026-09-11-camera-arm-entry-adopts-arriving-tilt.md @@ -0,0 +1,51 @@ +# Body-arm entry doesn't adopt the arriving pose's tilt + +**Raised:** 2026-09-10/11, orientation-unbraid design pass on PR #647 (design B7). +User ruled: backlog, not this PR. + +A pose that _arrives_ at the body arm with more tilt than the arm's own mapping +implies — a fly-to, a clip end, a body switch that wiped the tilt memory — does +not get adopted into `SurfaceMemory`'s remembered tilt on entry. `display = +remembered × bodyUpWeight(h/R)` (ruling 12) reads only what got explicitly +written by a drag or the decay; an arriving pose's actual tilt is not one of +those writes. + +## The symptom + +A body-arm disengage that started from an unbacked tilt pops by as much as 37° +on the frame it hands off. `orientation-unbraid-design.md` §4 B2 and §5 T-G +(`.superpowers/sdd/2026-09-01-camera-pivot/orientation-unbraid-design.md:405-410,545`) +name this the standing "unbacked tilt pops at disengage" symptom, present +before this branch and explicitly _unchanged_, not worsened, by the branch's +orientation work. + +## Second route (drag-authored, near-disengage) + +`docs/superpowers/specs/2026-09-01-camera-pivot.md` §6 (lines ~465-479) +documents a second way to reach the same unbacked state: a tilt drag authors +display tilt directly, at any altitude; above `zeroHR` `unmappedTiltRad`'s +`w > 1e-6` guard skips the write back to `remembered`, so the drag-authored +tilt is unbacked even though it was interactively set. Held at the flip, it +crosses onto the absolute arm as roll/tilt — "the B7 symptom's second route." + +## Why B7 (adopt-on-entry) is not the fix + +Design B7 (`orientation-unbraid-design.md:424-428`) considered making arm +_entry_ adopt the arriving pose's tilt into `remembered`, which would make the +pop unrepresentable. Rejected for this pass: it clobbers a kept memory +whenever the world arm was not itself projecting the tilt (focus null, +`pivotsOnFocusedBody` false) — a case that needs its own ruling on what +"clobber" should mean before B7 can land safely. + +## Readiness note + +T22's feel gate (post-branch) attested no visible pop at the shipped tilt-band +defaults (`0.45`/`0.9`). The defect is real and reachable (both routes above), +but not observed at current tuning — readiness is `deferred`, not `ready`. + +## Fix shape + +Rule the "focus null / non-projecting world arm" case first, then let entry +adopt `remembered := display-implied tilt from the arriving pose` only when +the world arm was projecting it. Needs a design pass before a plan; not a +mechanical fix. diff --git a/docs/backlog/2026-09-11-camera-f32-half-ulp-sightline-nudge-at-arm-flip.md b/docs/backlog/2026-09-11-camera-f32-half-ulp-sightline-nudge-at-arm-flip.md new file mode 100644 index 0000000000..b3049349d8 --- /dev/null +++ b/docs/backlog/2026-09-11-camera-f32-half-ulp-sightline-nudge-at-arm-flip.md @@ -0,0 +1,33 @@ +# f32 half-ulp sightline nudge at the body-arm flip + +**Raised:** 2026-09-10/11, wave-end fixes on PR #647 +(`.superpowers/sdd/2026-09-01-camera-pivot/tilt-band-fix-report.md` concern 1). +User ruled: backlog, not this PR. Pre-existing, not introduced by this branch. + +At most start altitudes, converting a pose across the absolute↔body-fixed flip +(`toBodyArm`, `src/services/engine/camera/poseFrameConversion.ts`) reproduces +the input pitch to ~4e-13. In two narrow windows of start `h/R` — measured at +≈ 0.800–0.805 and ≈ 0.8775–0.878 on the 0.45/0.9 tilt band — the round-tripped +pitch is off by exactly 1.490e-8 or 2.107e-8 rad: `2^-26` and `2^-26·√2`, +half-ulp quantities for a float32 in `[0.25, 0.5)`. That points at a +`Float32Array` intermediate somewhere on the flip path (`wgpu-matrix` defaults +to `Float32Array`). + +## Observable + +The eye position is bit-identical across the flip in every case measured — the +only observable is a ~4 mas sightline nudge on the one frame the flip occurs. +Confirmed **not** the Pop-2 bug `tests/services/engine/frame/poseFold.test.ts` +guards, and not introduced by the camera-pivot branch; it is the reason that +test's start fraction is pinned at `SURFACE_REGIME.disengageHR * 0.9` rather +than `* 0.975` (the closer fraction lands in one of the two knife-edge +windows and fails the test's `1e-9` continuity bound at `2.0e-8`). + +## Fix shape + +Needs verification before a fix: identify which `wgpu-matrix` call on the +`toBodyArm`/flip path routes through a `Float32Array` intermediate rather than +staying f64 end to end, and replace it with an f64-safe equivalent (or accept +the residual explicitly if none exists at reasonable cost). Not urgent — the +nudge is sub-mas and momentary — but worth confirming the exact call site +before deciding whether it is fixable cheaply. diff --git a/docs/backlog/2026-09-11-camera-radar-residuals-h3-m4-m5.md b/docs/backlog/2026-09-11-camera-radar-residuals-h3-m4-m5.md new file mode 100644 index 0000000000..555193b70d --- /dev/null +++ b/docs/backlog/2026-09-11-camera-radar-residuals-h3-m4-m5.md @@ -0,0 +1,82 @@ +# Camera radar residuals: wake vote, channel expiry, authoredOverride + +**Raised:** 2026-09-10/11, wave-end entanglement radar on PR #647 +(`.superpowers/sdd/2026-09-01-camera-pivot/wave-end-radar.md`, findings H3, +M4, M5). User ruled: backlog, not this PR. + +Three separate residuals from the camera-pivot branch's driver-table +architecture, each a case of one fact re-derived by hand in a second place. + +## H3 — `selectCameraActive` restates five driver `isActive` predicates by hand + +**Where** — `src/state/camera/selectors.ts:29-38` (the `selectCameraActive` +body) hand-copies each driver's activity test against the table rows at +`src/services/engine/camera/cameraDrivers.ts:187` (clip), `:216` (orbitDrag), +`:258` (tween), `:285` (autoRotate, arm gate included). The follow rows are +covered separately by a third term in a third file +(`src/services/engine/helpers/shouldKeepTicking.ts:26-32`). + +**The braid** — "is any camera driver authoring motion this frame?" restated +independently by two watchers, the same shape as the pre-existing +`selectionWakeSaga`/`selectionRowsSaga` knot in `simplicity.md`'s known list. +`selectors.ts:26-28` carries a comment explaining that the auto-rotate term +"carries the same arm gate as the driver it stands for" — the comment is +standing in for the un-braid. + +**The cost** — adding a driver row, or changing one's gate (as this branch did +for autoRotate's arm gate), means remembering to edit a selector two +directories away; missing it either pins the loop at 60fps in a body arm or +lets it sleep mid-motion, and no existing test catches either. + +**Un-braided shape** — a `wakesLoop` boolean field on each driver-table row, +then `drivers.some((d) => d.wakesLoop && d.isActive(s))`. `runFrame` already +holds `deps.drivers` and already passes `rootState` into `shouldKeepTicking`, +so the wiring exists. ~25 lines; care needed because `selectCameraActive` is +also read from React. No golden-trace impact (wake vote, not pose). + +## M4 — two hand-written channel-expiry branches, plus a third rule in `clipPlayer` + +**Where** — `src/services/engine/camera/stepCameraRuntime.ts:144-149` +(frameTween → `clearFrameTween`) and `:153-159` (tween → `cancelCameraTween`). +The clip driver's equivalent expiry rule lives separately in +`src/services/engine/subsystems/clipPlayer.ts`. + +**The braid** — the generic rule "a timed camera channel whose elapsed has +reached its descriptor's `durationMs` must clear itself" is open-coded per +channel instead of read off `CameraEpochs` (already a five-row record with +`EpochRow` as a derived key type). The two existing branches also differ +silently — the tween branch gates on `winnerId === 'tween'`, the frameTween +branch does not, and the reason lives in a different file +(`EpochRow.d.ts:3-4`). + +**The cost** — a third timed channel would add a third copy and a third +undocumented gate choice. + +**Un-braided shape** — an expiry row per epoch: `{ epoch, descriptorOf(intent), +onExpire, requiresWin }`, folded once. Three rows replace two branches plus +the `clipPlayer` special case. ~30 lines; no behavior change if the +`requiresWin` flags are transcribed faithfully. Re-run the golden traces — +`stepCameraRuntime`'s header names the two pushed actions' order as contract, +and that ordering must survive the fold. + +## M5 — `commitOnEdge.authoredOverride` carries two pipeline stages in one nullable field + +**Where** — `src/services/engine/camera/commitOnEdge.ts:29-31,43-48` returns +`authoredOverride: FramedCameraPose | null`; `src/services/engine/frame/projectFramePose.ts:87` +reads it as `register = authoredOverride ?? displayed`. + +**The braid** — non-null means the **pre-pin** register; null means "take the +**post-pin** displayed" — the two arms of one `??` are values from opposite +sides of `applyFocusedBodyPivot`. One nullable field is doing "which pose the +frame draws" and "at which pipeline stage that value was captured" at once. + +**The cost** — inserting a stage between the pin and `projectFramePose.ts:87` +would only need to update one arm, and the type gives no warning if that's +missed. `commitOnEdge`'s header needs nine lines to explain a two-line +function body because of the two-question return. + +**Un-braided shape** — return both poses at the same pipeline stage: either +both pre-pin (let `projectFramePose` pin both), or move the pin inside +`commitOnEdge` so both are post-pin. Either way the `??` disappears. ~15 +lines. If the pin moves, re-run both golden traces — expected bit-identical, +but the pin is a pose writer and its ordering is the spec's contract. diff --git a/docs/backlog/2026-09-11-clip-per-endpoint-tag-authoring-validation.md b/docs/backlog/2026-09-11-clip-per-endpoint-tag-authoring-validation.md new file mode 100644 index 0000000000..0e70aa3071 --- /dev/null +++ b/docs/backlog/2026-09-11-clip-per-endpoint-tag-authoring-validation.md @@ -0,0 +1,39 @@ +# Frame-tagged clip keyframes have no per-endpoint authoring validation + +**Raised:** 2026-09-10/11, task 20 (T20) on PR #647 +(`.superpowers/sdd/2026-09-01-camera-pivot/task-20-report.md` concern 2). User +ruled: backlog, not this PR. + +`CameraAction`'s `set`/`setVec` arms carry an optional `readonly frame?: +PoseFrame` tag per endpoint (absent ⇒ `'absolute'`). `evaluateClip.ts` derives +**frame legs** from these tags — a leg is a maximal run of clip time under one +`PoseFrame` — but the tag is per-endpoint while a channel's straddling tween is +authored at the pose level, with nothing that rejects a leg boundary landing +mid-tween on a channel the leg doesn't own. + +## The failure mode + +If an absolute `distance` tween spans `[0, 10)` while a body-framed `target` +opens a new leg at `t = 5`, the distance segment is silently skipped from `t = +5` onward and the channel holds its already-converted value — the honest +consequence of one channel authored across a frame boundary another channel +just crossed. `task-20-report.md` names this "an authoring pathology, but +nothing rejects it loudly." + +## Where + +`src/services/engine/camera/evaluateClip.ts`'s frame-leg derivation (the +`legStartCache`/`legsCache` machinery documented in `task-20-report.md`'s "Where +the once-per-leg conversion state lives" section) walks compiled base tracks +and opens a new leg at any `set`/`setVec` endpoint whose `frame` tag differs +from the frame in force — with no check that every channel active across that +boundary agrees on the new frame. + +## Fix shape + +A `validateSingleWriter`-style compile-time check (in `compileClip` or a pass +over its output) that walks channels crossing a frame-leg boundary and throws +if any channel has an in-flight tween that does not also close or re-open at +that boundary. This is authoring-time validation, not a runtime behavior +change — it should catch the pathology at clip-compile time rather than let it +silently freeze a channel. diff --git a/docs/backlog/2026-09-11-derive-body-states-scene-body-id-type.md b/docs/backlog/2026-09-11-derive-body-states-scene-body-id-type.md new file mode 100644 index 0000000000..b5b1e74e25 --- /dev/null +++ b/docs/backlog/2026-09-11-derive-body-states-scene-body-id-type.md @@ -0,0 +1,60 @@ +# `deriveBodyStates`' key type is a pre-existing lie (14 casts) + +**Raised:** 2026-09-10/11, task 20 (T20) on PR #647 +(`.superpowers/sdd/2026-09-01-camera-pivot/task-20-report.md`, "Could not do" +item 13). User ruled: backlog, not this PR. + +`deriveBodyStates` (`src/services/engine/frame/deriveBodyStates.ts`) returns a +map keyed by SCENE body ids — string literals like `'mars'`, `'titan'`, +`'moon'`, `'io'` — but every call site casts the result to `ReadonlyMap`, where `BodyId` is the SOURCE-registry body-type union (`'planet' | +'earth' | 'sun' | 'sgr-a-star' | 's-star'`). The two id domains are unrelated; +the cast is a type lie load-bearing enough that retyping the return to its +honest `string` key produced 30+ compile errors across 14 files. + +## The 14 cast sites + +``` +src/state/camera/watchFlyToLonLatSaga.ts:37 +src/services/engine/engine.ts:608 +src/services/engine/frame/runFrame.ts:100 +src/services/engine/helpers/liveWorldPose.ts:17 +tests/helpers/camera/simulateCameraFrame.ts:39 +tests/services/engine/camera/frameAlignedRoll.test.ts:35 +tests/services/engine/camera/clipKeyframeFrames.test.ts:50 +tests/services/camera/singularLocusRecession.test.ts:33 +tests/services/engine/camera/orientTargetSymmetry.test.ts:37 +tests/helpers/camera/makeDriverCtx.ts:38 +tests/services/camera/northUpToggle.test.ts:91 +tests/services/engine/camera/stepCameraRuntime.test.ts:48 +tests/services/engine/camera/stepCameraRuntime.test.ts:69 +tests/services/engine/camera/replayInput.test.ts:42 +``` + +All read `as ReadonlyMap` (or a `?? (... as ...)` variant). + +## Why the try-it revert happened + +Retyping `deriveBodyStates`' declared return to its real scene-body-id key +broke `bodyRegions`, `earthFlyout`, `approachTiltedPose`, `assetWiring`, +`extractSelectionRow`, and several test files — all of which genuinely depend +on the loose `string` key today (arbitrary lookups by scene body name that a +narrow `BodyId` union would reject at the call site). Fixing this honestly +needs a real scene-body-id type introduced across those consumers, not a +one-line annotation — out of scope for a fix round. + +## Fix shape + +1. Introduce a `SceneBodyId` type (or similar) in `src/@types/scene/` covering + the actual scene body keys (`'mars' | 'titan' | 'moon' | 'io' | …`), + distinct from `BodyId`'s source-registry union. +2. Change `deriveBodyStates`' declared return to + `ReadonlyMap` and drop the 14 casts. +3. Work through the ~30 compile errors this surfaces in `bodyRegions`, + `earthFlyout`, `approachTiltedPose`, `assetWiring`, `extractSelectionRow`, + and the affected tests — each is a real call site that needs to either + narrow to `SceneBodyId` or accept it directly. + +Two of the 14 sites above are this branch's own additions and match the +established (wrong) convention rather than inventing a third; fixing this item +resolves those too. diff --git a/docs/backlog/2026-09-11-framing-import-cycle-bodyLikeFraming-focusFraming.md b/docs/backlog/2026-09-11-framing-import-cycle-bodyLikeFraming-focusFraming.md new file mode 100644 index 0000000000..742b281747 --- /dev/null +++ b/docs/backlog/2026-09-11-framing-import-cycle-bodyLikeFraming-focusFraming.md @@ -0,0 +1,35 @@ +# `bodyLikeFraming` ⇄ `focusFraming` import cycle + +**Raised:** 2026-09-10/11, tuning task 4 (T4) on PR #647 +(`.superpowers/sdd/2026-09-01-camera-pivot/tuning-T4-report.md` §3, `madge +--circular`). Pre-existing, not introduced by this branch. User ruled: +backlog, not this PR. + +`madge --circular` over `src/data/camera src/utils/camera src/state/camera +src/services/camera src/services/engine/camera` reports one genuine +intra-folder cycle (its finding #12): + +``` +services/engine/camera/bodyLikeFraming.ts > services/engine/camera/focusFraming.ts +``` + +Neither file is touched by the camera-pivot branch's diff. + +## Where the edges are + +- `src/services/engine/camera/bodyLikeFraming.ts:29` — `import type { +FocusFraming } from './focusFraming'` (type-only). +- `src/services/engine/camera/focusFraming.ts:43` — `import { bodyLikeFraming +} from './bodyLikeFraming'` (value import). + +The cycle is a type-only back-edge from `bodyLikeFraming` into `focusFraming` +purely to name the `FocusFraming` shape, crossed by `focusFraming`'s ordinary +value import of `bodyLikeFraming` going the other way. + +## Fix shape + +Extract the `FocusFraming` type out of `focusFraming.ts` into its own file +under `src/@types/` (per the project's one-type-per-file convention for +`@types/`), and have both `bodyLikeFraming.ts` and `focusFraming.ts` import it +from there. That removes the back-edge entirely rather than reordering the +existing files. diff --git a/docs/grill-sessions/camera-debug-panel-2026-09-10.md b/docs/grill-sessions/camera-debug-panel-2026-09-10.md new file mode 100644 index 0000000000..d3f8064ea7 --- /dev/null +++ b/docs/grill-sessions/camera-debug-panel-2026-09-10.md @@ -0,0 +1,250 @@ +# Grill Session: Camera debug panel — 2026-09-10 + +Source: camera-pivot branch (PR #647). Trigger was F1, a heading-reset defect +(north-up decay per frame instead of per unit zoom) that the DebugPanel's +Camera section did not make visible while it was live — the user's framing: +"the debug panel feedback is quite confusing." Files in scope: +`src/components/DebugPanel/CameraStateSection.tsx`, +`src/components/DebugPanel/OrientationTuning.tsx`, and the snapshot builder +`src/utils/camera/cameraDebugSnapshotOf.ts`. + +Panel today: five 4 Hz text groups (regime 4 rows, altitude 7 rows, +orientation 8 rows including five roll numbers, input 5 rows, epoch 3 rows), +full-JS-precision radians throughout, followed by a tuning block (engage/ +disengage sliders, tilt-blend full/zero sliders, log/lin blend-space toggle, +north-up toggle, remembered-tilt readout, hysteresis readout). Goal: redesign +what the panel answers and how, so the next per-frame-vs-per-step defect is +visible without reading source. + +--- + +## Q1: What should the panel answer first? + +**The question:** Five parallel text groups read as a system dump, not an +answer. What's the top-level question the panel should be organized to answer, +and what falls out as secondary? + +**Considerations:** + +- **Option a (authorship first):** lead with who's driving the pose — arm, + driver, gesture. Useful for "is the right thing in control", but doesn't + show whether that thing is doing the right rotation. +- **Option b (per-DOF state first):** lead with heading/tilt/roll, each as + current / target / residual / pull. This is the shape that would have + caught F1 directly — a residual that never reaches zero, or a pull that + fires every frame instead of every zoom step, is visible as a row, not as + an inference from seven altitude numbers and eight orientation numbers read + together. +- **Option c (band position first):** lead with where the camera sits in the + regime band and what the tuning knobs are doing to it. Relevant context, + but it's the stage the DOFs act on, not the DOFs themselves. + +**Decision:** **(b) as the spine, (a) as a one-line header, (c) as one band +ruler.** Authorship answers "who", DOF rows answer "is it right", the band +answers "where" — in that priority order, with (b) getting the panel's +primary visual weight. + +## Q2: The "pull" column — last-frame delta or next-notch prediction? + +**The question:** F1's failure mode is a decay rate keyed to the wrong clock +(per-frame vs per-unit-zoom). Making that class of bug visible needs a column +that shows *how much moved, and when* — not just where the DOF sits now. + +**Considerations:** + +- **Option A (last-frame delta):** ground truth — literally what changed + between the last two snapshots. Free to compute, reads exactly 0 at rest, + and a one-frame jump shows up as a single nonzero row with nothing else to + explain it. Downside: at 4 Hz it only shows the delta since the last poll, + not the peak within that window. +- **Option B (next-notch prediction):** shows where the DOF is heading before + it gets there — useful when the camera is at rest and delta reads 0. But it + requires guessing which cursor pick / gesture / driver wins next frame, + which can be wrong, and it's meaningfully more code for a value that's an + inference, not a measurement. + +**Decision:** **Last-frame delta, plus a per-DOF peak-hold** (largest single- +frame delta since the last clear, with a clear button). No prediction. The +peak-hold recovers what a next-notch prediction was trying to give (visibility +during periods that look at-rest at 4 Hz) without inferring anything — it's +still a measured value, just held across the polling gap. + +## Q3: Where is the per-frame delta computed? + +**The question:** Q2's delta needs to be exact — averaging it away defeats +the point. Where does the subtraction live? + +**Considerations:** + +- **Option A (engine-side, in `runFrame` beside `cameraDebugSnapshotOf`):** + exact — a few subtractions per frame, computed at the same rate the camera + actually moves. Always on; no gating knob to remember to flip. Peak-hold + reset becomes a small debug action the panel calls, not new engine state + the panel owns. +- **Option B (panel-side, diffing the 4 Hz polls):** zero new engine surface + — the panel already polls a snapshot — but it averages roughly 15 frames of + motion into one delta. That's exactly the defeat Q2 was trying to avoid: + F1's per-frame decay would show up as a smaller, smoother number instead of + the spike that reveals it. + +**Decision:** **Engine-side, always on.** The delta and peak-hold are computed +in `runFrame` next to `cameraDebugSnapshotOf`, at frame rate; peak reset is a +debug action the panel calls, not a value the panel computes itself. + +## Q4: The five roll rows — keep or drop? + +**The question:** Today's orientation group carries five separate roll +numbers (roll vs scene up, roll vs spin axis, plus their residuals and one +more). Under the Q1 per-DOF spine, does roll need its own five-row block, or +does one row suffice? + +**Considerations:** + +- **Option i (keep the endpoint rolls):** vs-scene-up and vs-spin-axis rolls + plus residuals are the two reference frames roll has actually been defined + against across the pivot's roll work (see `globe-camera-pivot-2026-08-24.md` + Q4/Q5) — keeping both endpoints keeps that history visible. +- **Option ii (one roll row against the band target):** collapse to current / + target / residual / Δ / peak, matching the other two DOFs exactly. The + question "where does the target sit between the two endpoints" moves onto + the Q5 band ruler as the `w` blend value, rather than living as five + separate roll numbers. + +**Decision:** **Drop — (ii).** One roll row, same shape as heading and tilt. +`w` on the band ruler shows where the target sits between the endpoints; the +five-number block is gone. + +## Q5: Band ruler — drawn bar or text rows? + +**The question:** The regime/altitude groups today are eleven text rows +(4 + 7) reporting numbers whose actual content is "where in the band am I, +and how far from the edges." Is that a reading task or a looking task? + +**Considerations:** + +- **Option A (drawn horizontal log-scale bar):** four ticks (tilt-full, + engage, tilt-zero, disengage), a marker at the current `h/R` labelled with + altitude in metres, `w` and the tilt ceiling as two small numbers under the + marker. Band sliders move directly under the bar, so dragging an edge moves + its tick live — the control and the readout share one visual object instead + of a slider block and a text block that the reader has to cross-reference. + Distance-in-Mpc drops from this group; it's not a band-position fact. +- **Option B (keep as text rows):** no new rendering work, but keeps the + cross-referencing burden this Q exists to remove. + +**Decision:** **Drawn bar (A).** Sliders move under the bar; distance-Mpc +dropped from the group. + +## Q6: Input + epoch groups — fold into the header, or keep as rows? + +**The question:** Input (5 rows) and epoch (3 rows) are mostly identity and +diagnostic facts — which body, which driver, which gesture, anchor +coordinates, cursor hit, eye-to-anchor distance, sim days. Under the Q1 +"authorship is a one-line header" call, how much of this stays visible by +default? + +**Considerations:** + +- **Fold into the header line:** `body:earth · surfaceStep · gesture: tilt` + as one line, with a red badge appearing only on an arm mismatch or an epoch + mismatch — the two states that are actually actionable, versus the + steady-state identity facts that are just confirmation. +- **Raw rows behind a collapsed toggle:** anchor coordinates, cursor hit, + eye-to-anchor distance, sim days move behind a "raw" disclosure, off by + default. Copy-all still dumps everything regardless of what's expanded. + +**Decision:** **Yes — fold the header, collapse the raw rows.** Header line +plus mismatch badge; raw group collapsed by default; copy-all unaffected. + +## Q7: Units and precision — degrees on screen, or full-precision radians? + +**The question:** Every number in the panel today is full-JS-precision +radians. That's exactly what you'd want to paste into a bug report, but it's +a bad reading surface for "is this row moving." + +**Considerations:** + +- **Option A (radians everywhere):** paste = exactly what you saw, no + transcription loss between the screen and a bug report. +- **Option B (degrees, one decimal, on screen; `h/R` to three decimals; full- + precision radians in the copy-all dump):** the visible rows become fast to + scan at a glance — a heading that should hold steady reads as a stable + one-decimal number instead of a radian value whose sixth significant digit + is drifting. The copy-all dump keeps Option A's exactness for anyone who + needs to paste a bug report. + +**Decision:** **(B).** Degrees at one decimal on screen (`h/R` at three +decimals), full-precision radians in the copy-all dump. + +## Q8: Heading/roll targets when north-up is unchecked + +**The question:** With north-up off, the field's heading/roll target is not +being tracked — does the row show "—", or does it keep showing the field's +target with a marker? + +**Considerations:** + +- **Option A (show "—"):** honest about what's not currently engaged — the + row isn't lying about being tracked. Loses the field readout entirely while + north-up is off. +- **Option B (keep showing the target and residual, with an `(off)` + marker):** the target is a property of the field itself, not of whether + north-up is currently applying it — it exists and moves regardless of the + toggle. Seeing it move while the pose holds steady is exactly how a + reference-frame flip (the F1 family of bug) gets caught: if the target is + doing something unexpected even while nothing is consuming it, that's a + field-computation bug, not an application bug. Δ and peak stay wired to + actual pose movement regardless of the marker, so they don't falsely imply + something is being pulled. + +**Decision:** **(B) — keep showing, with the `(off)` marker.** The target is +a field property; watching it while disengaged is a diagnostic, not noise. + +## Q9: Where does this land? + +**The question:** camera-pivot (#647) is already a large PR. Does the debug +panel redesign ride it, or ship separately? + +**Considerations:** + +- **Option i (on camera-pivot directly):** zero branch overhead, but grows an + already-large PR further. +- **Option ii (own branch off camera-pivot, `--base camera-pivot`, merged + before T22):** keeps the panel change reviewable on its own, lands ahead of + the parent PR's remaining work. Recommended. +- **Option iii (own PR off main, after #647 merges):** cleanest separation + from the pivot's own diff, but the panel change is the direct response to a + bug the pivot work surfaced, and would sit unlanded the longest. + +**Decision:** **This PR (i) — user overruled the recommendation.** The panel +redesign lands inside #647, not as a follow-on branch. + +--- + +## Outcome + +**Visible panel**, after this redesign: + +- Header line: arm · winning driver · gesture, with a mismatch badge (arm + mismatch or epoch mismatch only). +- Three DOF rows — heading / tilt / roll — each: current, target, residual, + Δ last frame, peak (with clear); `(off)` marker on heading/roll when + north-up is unchecked. +- Band bar: drawn horizontal log-scale ruler, four ticks, current-position + marker labelled in metres, `w` and tilt ceiling underneath; band sliders + live directly under the bar. +- The two toggles (log/lin blend-space, north-up) and the remembered-tilt + readout. +- Collapsed "raw" section: anchor coordinates, cursor hit, eye-to-anchor + distance, sim days. +- Copy-all dumps everything, full precision, radians. + +**Engine-side:** a per-frame orientation-delta record with peak hold, computed +in `runFrame` beside `cameraDebugSnapshotOf` (Q3); reset is a debug action the +panel calls. + +**Deletion:** the two endpoint roll rows and their residuals (Q4); the +distance-Mpc row from the band group (Q5). + +**Next:** implement against `CameraStateSection.tsx`, `OrientationTuning.tsx`, +and `cameraDebugSnapshotOf.ts`, inside #647 per Q9. diff --git a/docs/grill-sessions/globe-camera-pivot-2026-08-24.md b/docs/grill-sessions/globe-camera-pivot-2026-08-24.md index 955e1faea4..b332491318 100644 --- a/docs/grill-sessions/globe-camera-pivot-2026-08-24.md +++ b/docs/grill-sessions/globe-camera-pivot-2026-08-24.md @@ -413,3 +413,16 @@ cross-check); rulings taken at and after its checkpoint: survives narrowed to star spheres (S4 keeps stars out of body slabs). Spec 1: `docs/superpowers/specs/2026-08-25-body-render-slabs.md`. + +## Addendum 2 — spec-review rulings (2026-09-01) + +- **T2 RULED: the union.** `camera.base` becomes a framed pose, one arm + authoritative, lossless conversion at the fold. Both independently authored + spec variants converged on it. +- **Q4 mechanism revised.** Q4-iii's "heading maps exactly onto yaw" does not + hold at nadir — heading surfaces as image roll there. The minimal completion + is Q4-ii's field (`roll?` on `CameraPose`, prep P5), whose original + rejection premises are stale: no XR path exists; `OrbitCameraInit.roll` + + `computeViewProj` already support roll; tours unaffected, optional default 0. Q4-iii's ceiling mechanism stands as the feel/entry-pose half; exactness + now comes from the conversion being lossless for any pose. +- Spec 2 lives at `docs/superpowers/specs/2026-09-01-camera-pivot.md`. diff --git a/docs/superpowers/plans/completed/2026-09-01-camera-pivot.ledger.md b/docs/superpowers/plans/completed/2026-09-01-camera-pivot.ledger.md new file mode 100644 index 0000000000..29c9c45dab --- /dev/null +++ b/docs/superpowers/plans/completed/2026-09-01-camera-pivot.ledger.md @@ -0,0 +1,1417 @@ +# SDD ledger — plan: docs/superpowers/plans/2026-09-01-camera-pivot.md + +Spec: docs/superpowers/specs/2026-09-01-camera-pivot.md (binding authority). +Branch: camera-pivot, worktree .claude/worktrees/camera-pivot, draft PR #647. +Plan commit: 630993b50 (includes controller's pre-flight fix: T20 files header +path corrected animation/evaluateClip.ts → camera/evaluateClip.ts). +Dev server: port 5173 (bg shell bfu6emena) — leave running; perf tasks MUST use +`--url http://localhost:5173`. +Harness note: no TodoWrite tool in this session — this ledger is the task list +(Rule 1 satisfied here; rebuild from this file + git log after compaction). + +## Pre-flight scan (2026-09-01) + +Shared-file / interface pairs: + +| Tasks | Producer → consumer | Finding | +|---|---|---| +| T2 → all | PoseFrame/BodyFixedPose/FramedCameraPose/SURFACE_REGIME | verbatim from spec §3; consistent | +| T3 → T13 | poseFrameConversion: T3 to/from arms, T13 adds resolveWorldArm | consistent; same module licensed by §10 | +| T3 ↔ T4 | one-seam sweep excludes poseFrameConversion as seam | consistent | +| T5 → T17 | maxTiltRad consumed by ceiling enforcement | consistent (orientation-only, eye fixed) | +| T7 → T10/T16 | cursorRayBodyLocal ray shape {originM,dir} | matches anchoredDragRotation params | +| T9 → T11/T16 | surfaceFloorM consumed by zoom + controller | ordered correctly | +| T12 → T15 | regimeArmFor pure; gesture-in-flight rule at caller | both ends agree (T12 text assigns to T15) | +| T13 ↔ T15 | runFrame: T13 mechanical, T15 fold | serial, disjoint concerns | +| T13 → T14 | frameContext threads FramedCameraPose beside resolved pose | consistent | +| T13 ↔ T16 | drainInput/cameraDrivers touched by both | serial; T16 adds body-arm routing only | +| T13 ↔ T19 | watchFlyToLonLatSaga: T13 type-mechanical, T19 semantic | serial | +| T13 ↔ T20 | tween/clip rows absolute in T13, tagged in T20 | explicit in both tasks | + +Per-task self-consistency: all tasks' tests match their stated code; T5's +descending smoothstep edge order flagged in-plan as deliberate; T1/T21 use the +same worktree-URL trap wording. Ground facts (files, constants, primitives, +seam sites) verified by controller before execution — all exist. + +Watch item (not a conflict): T12's noStoredRegimeFlag scan +(/surface|regime|engaged/i boolean declarations) vs T16's SurfaceGesture fields +— if spec §3's SurfaceGesture carries a boolean matching the regex, the scan +needs a seam exclusion. Resolve at T16 review if it bites. + +## Pre-execution rulings (plan-author open questions) + +Ruling: packaging — moot; P5/P6 landed as #648 before execution. No cost. +Ruling: engageable roster stays body-blind (SCENE_BODIES ∩ bodyStates, Sun and +Sgr A* included) — literal spec §4 + §12-R2; harmless by construction (no +observable on engage). Cost if wrong: one-line filter in regimeArmFor + test +tweak. Surfaced to user; narrow later if they object. +Ruling: T13 stays one mechanical task — splitting produces non-compiling +intermediates; large diff accepted as review cost. Cost if wrong: one big +review round. +Ruling: feel constants (tiltFullHR 0.02, grazing 0.05, zoom clamp) stay open +until T22 user gate — per spec Q5. No cost; T22 owns them. + +## Model plan + +opus: T3, T10, T13, T15, T16 (+T17 rides same seat's area), final review. +sonnet: T1, T4–T9, T11, T12, T14, T17–T20 implementers; task reviewers scale +sonnet default, opus for T13/T15/T16 packages. +T21 = measurement (sonnet), T22 = USER. + +## Task log + +### Task 1 baseline + +Command: `npm run perf -- --url http://localhost:5173` (default poses, worktree +dev server confirmed reachable via `curl` before the run — HTTP 200). + +``` +> skymap@0.5.0 perf +> tsx tools/perf/measurePerf.ts --url http://localhost:5173 + + +measuring 'earth-surface' (30 frames @ dpr 2) ... +earth-surface (1400×900 @dpr2, tier medium, 30 frames, median ms | p90) + TOTAL (merged, production) 25.0 ms/frame | 43.2 p90 + → ~40 fps GPU-bound ceiling (timed passes only; excludes CPU/present/vsync) + TOTAL (per-layer, instrumented — not representative) 54.1 ms/frame | 115.8 p90 + MERGED (production pass shape) + group median p90 share % + ──────────────────────────────────────────────────────── + hdr·NEAR0 3.5 6.1 ██▎ 15% + star-aggregates·NEAR0 3.2 5.8 ██▏ 14% + foreground:0·NEAR0 3.2 6.0 ██▏ 14% + bloom 2.4 5.4 █▌ 10% + swap·COSMO 2.1 3.4 █▍ 9% + hdr→swap 2.1 3.3 █▍ 9% + swap·NEAR0 2.1 3.2 █▍ 9% + foreground:0→hdr 2.0 4.7 █▎ 9% + foreground:0·BODY[0] 1.8 4.2 █▏ 8% + hdr·COSMO 0.8 2.8 ▌ 4% + PER-LAYER (attribution; each row includes ~FLOOR pass overhead) + layer median p90 share % + ────────────────────────────────────────────────────────── + star-upsample 3.8 8.7 █▏ 7% + star-catalog 3.6 8.0 █ 7% + near0-selection-ring 3.4 8.6 █ 7% + foreground-labels 3.4 8.5 █ 7% + labels 3.3 8.5 █ 6% + bloom 3.3 8.2 █ 6% + hdr→swap 3.3 8.4 █ 6% + marker-lines 3.3 8.5 █ 6% + star-points 3.1 5.9 ▉ 6% + body-glints 3.1 6.0 ▉ 6% + star-aggregates 3.0 5.9 ▉ 6% + orbit-trails 3.0 7.5 ▉ 6% + foreground:0→hdr 2.7 6.2 ▊ 5% + atmosphere-shell·BODY[0] 2.5 6.1 ▊ 5% + cloud-shell·BODY[0] 2.4 5.9 ▋ 5% + earth·BODY[0] 2.3 5.9 ▋ 4% + star-spheres 2.0 5.4 ▋ 4% + procedural-disks 0.2 2.9 0% + point-sprites 0.2 2.6 0% + textured-disks 0.2 2.9 0% + EST. PER-PASS FLOOR ≈ 0.0 ms (hdr·COSMO) + → point-sprites ≈ 0.2 ms real + → procedural-disks ≈ 0.2 ms real + → textured-disks ≈ 0.2 ms real + EST. PER-PASS FLOOR ≈ 2.6 ms (hdr·NEAR0) + → star-points ≈ 0.5 ms real + → orbit-trails ≈ 0.4 ms real + → body-glints ≈ 0.4 ms real + → star-catalog ≈ 1.0 ms real + → star-upsample ≈ 1.2 ms real + EST. PER-PASS FLOOR ≈ 1.8 ms (foreground:0·BODY[0]) + → earth·BODY[0] ≈ 0.5 ms real + → cloud-shell·BODY[0] ≈ 0.6 ms real + → atmosphere-shell·BODY[0] ≈ 0.7 ms real + EST. PER-PASS FLOOR ≈ 2.3 ms (swap·COSMO) + → marker-lines ≈ 1.0 ms real + → labels ≈ 1.1 ms real + EST. PER-PASS FLOOR ≈ 2.4 ms (swap·NEAR0) + → near0-selection-ring ≈ 1.0 ms real + → foreground-labels ≈ 1.0 ms real + SUMMARY + ⚠ Over the 60fps budget (25.0 of 16.7 ms — ~40 fps ceiling). + Hottest pass: hdr·NEAR0 — 3.5 ms, 15% of MERGED GPU time. + Per-pass floor ≈ 1.8 ms; instrumented per-layer total inflated ~29.1 ms over merged. + ⚠ 2 page error(s): console.error: Failed to load resource: the server responded with a status of 404 (NOT FOUND. Requested (ra, dec) is outside the SDSS footprint.) + +measuring 'solar-system' (30 frames @ dpr 2) ... +solar-system (1400×900 @dpr2, tier medium, 30 frames, median ms | p90) + TOTAL (merged, production) 27.3 ms/frame | 56.8 p90 + → ~37 fps GPU-bound ceiling (timed passes only; excludes CPU/present/vsync) + TOTAL (per-layer, instrumented — not representative) 34.5 ms/frame | 67.2 p90 + MERGED (production pass shape) + group median p90 share % + ──────────────────────────────────────────────────────── + hdr·NEAR0 3.2 8.3 ██▎ 15% + foreground:0·NEAR0 2.8 7.8 ██ 13% + star-aggregates·NEAR0 2.7 8.3 █▉ 13% + swap·NEAR0 2.2 7.4 █▋ 11% + bloom 2.2 7.1 █▌ 10% + hdr→swap 2.2 7.1 █▌ 10% + swap·COSMO 2.2 7.1 █▌ 10% + foreground:0→hdr 1.6 6.7 █▏ 8% + foreground:0·BODY[0] 1.3 6.5 █ 6% + hdr·COSMO 0.6 4.1 ▍ 3% + PER-LAYER (attribution; each row includes ~FLOOR pass overhead) + layer median p90 share % + ─────────────────────────────────────────────────────── + star-upsample 3.4 6.6 █▋ 11% + star-catalog 3.2 6.7 █▋ 11% + star-aggregates 2.8 8.0 █▍ 9% + star-points 2.8 8.1 █▍ 9% + orbit-trails 2.7 5.7 █▍ 9% + body-glints 2.7 6.2 █▎ 9% + near0-selection-ring 2.0 6.1 █ 6% + foreground-labels 1.9 4.7 █ 6% + bloom 1.9 6.6 ▉ 6% + labels 1.9 6.1 ▉ 6% + marker-lines 1.9 6.0 ▉ 6% + hdr→swap 1.8 6.0 ▉ 6% + point-sprites 0.6 3.9 ▎ 2% + procedural-disks 0.6 3.0 ▎ 2% + textured-disks 0.4 4.5 ▎ 1% + EST. PER-PASS FLOOR ≈ 0.3 ms (hdr·COSMO) + → point-sprites ≈ 0.3 ms real + → procedural-disks ≈ 0.3 ms real + → textured-disks ≈ 0.1 ms real + EST. PER-PASS FLOOR ≈ 2.3 ms (hdr·NEAR0) + → star-points ≈ 0.5 ms real + → orbit-trails ≈ 0.4 ms real + → body-glints ≈ 0.4 ms real + → star-catalog ≈ 0.9 ms real + → star-upsample ≈ 1.1 ms real + EST. PER-PASS FLOOR ≈ 0.8 ms (swap·COSMO) + → marker-lines ≈ 1.1 ms real + → labels ≈ 1.1 ms real + EST. PER-PASS FLOOR ≈ 0.8 ms (swap·NEAR0) + → near0-selection-ring ≈ 1.1 ms real + → foreground-labels ≈ 1.1 ms real + SUMMARY + ⚠ Over the 60fps budget (27.3 of 16.7 ms — ~37 fps ceiling). + Hottest pass: hdr·NEAR0 — 3.2 ms, 15% of MERGED GPU time. + Per-pass floor ≈ 1.1 ms; instrumented per-layer total inflated ~7.2 ms over merged. + ⚠ 2 page error(s): console.error: Failed to load resource: the server responded with a status of 404 (NOT FOUND. Requested (ra, dec) is outside the SDSS footprint.) + +measuring 'star-field' (30 frames @ dpr 2) ... +star-field (1400×900 @dpr2, tier medium, 30 frames, median ms | p90) + TOTAL (merged, production) 25.3 ms/frame | 56.1 p90 + → ~39 fps GPU-bound ceiling (timed passes only; excludes CPU/present/vsync) + TOTAL (per-layer, instrumented — not representative) 33.9 ms/frame | 62.1 p90 + MERGED (production pass shape) + group median p90 share % + ───────────────────────────────────────────────────────── + star-aggregates·NEAR0 2.9 5.1 ██▏ 14% + hdr·NEAR0 2.9 7.0 ██▏ 14% + foreground:0·NEAR0 2.7 6.9 ██ 13% + swap·NEAR0 2.3 7.9 █▋ 11% + bloom 2.1 11.4 █▌ 10% + swap·COSMO 2.1 7.7 █▌ 10% + hdr→swap 2.0 8.0 █▍ 10% + foreground:0→hdr 1.6 6.8 █▎ 8% + foreground:0·BODY[0] 1.5 6.0 █▏ 7% + hdr·COSMO 0.5 2.2 ▍ 2% + PER-LAYER (attribution; each row includes ~FLOOR pass overhead) + layer median p90 share % + ──────────────────────────────────────────────────────── + star-catalog 3.8 10.7 ██▍ 15% + star-upsample 3.8 6.3 ██▎ 15% + star-points 3.3 7.3 ██ 13% + star-aggregates 3.3 7.4 ██ 13% + bloom 1.7 7.7 █ 7% + foreground-labels 1.6 7.9 █ 7% + hdr→swap 1.6 6.9 █ 6% + marker-lines 1.6 7.6 █ 6% + near0-selection-ring 1.6 7.8 █ 6% + labels 1.6 7.8 █ 6% + textured-disks 0.3 2.3 ▎ 1% + point-sprites 0.3 2.9 ▏ 1% + procedural-disks 0.3 1.4 ▏ 1% + EST. PER-PASS FLOOR ≈ 0.1 ms (hdr·COSMO) + → point-sprites ≈ 0.1 ms real + → procedural-disks ≈ 0.1 ms real + → textured-disks ≈ 0.2 ms real + EST. PER-PASS FLOOR ≈ 2.7 ms (hdr·NEAR0) + → star-points ≈ 0.6 ms real + → star-catalog ≈ 1.2 ms real + → star-upsample ≈ 1.1 ms real + EST. PER-PASS FLOOR ≈ 0.5 ms (swap·COSMO) + → marker-lines ≈ 1.1 ms real + → labels ≈ 1.0 ms real + EST. PER-PASS FLOOR ≈ 0.5 ms (swap·NEAR0) + → near0-selection-ring ≈ 1.1 ms real + → foreground-labels ≈ 1.1 ms real + SUMMARY + ⚠ Over the 60fps budget (25.3 of 16.7 ms — ~39 fps ceiling). + Hottest pass: star-aggregates·NEAR0 — 2.9 ms, 14% of MERGED GPU time. + Per-pass floor ≈ 1.0 ms; instrumented per-layer total inflated ~8.6 ms over merged. + ⚠ 2 page error(s): console.error: Failed to load resource: the server responded with a status of 404 (NOT FOUND. Requested (ra, dec) is outside the SDSS footprint.) + +measuring 'milky-way' (30 frames @ dpr 2) ... +milky-way (1400×900 @dpr2, tier medium, 30 frames, median ms | p90) + TOTAL (merged, production) 36.4 ms/frame | 54.4 p90 + → ~27 fps GPU-bound ceiling (timed passes only; excludes CPU/present/vsync) + TOTAL (per-layer, instrumented — not representative) 53.3 ms/frame | 92.5 p90 + MERGED (production pass shape) + group median p90 share % + ──────────────────────────────────────────────────────── + hdr·NEAR0 3.6 8.8 ██▎ 15% + foreground:0·NEAR0 3.5 8.5 ██▎ 15% + star-aggregates·NEAR0 2.7 7.0 █▊ 12% + bloom 2.5 7.2 █▋ 11% + swap·COSMO 2.4 7.6 █▌ 10% + swap·NEAR0 2.4 7.6 █▌ 10% + hdr→swap 2.3 7.4 █▌ 10% + foreground:0→hdr 1.7 6.5 █▏ 7% + foreground:0·BODY[0] 1.5 6.4 █ 7% + hdr·COSMO 0.7 3.9 ▌ 3% + PER-LAYER (attribution; each row includes ~FLOOR pass overhead) + layer median p90 share % + ──────────────────────────────────────────────────────── + point-sprites 10.6 19.3 ███▋ 24% + bloom 2.7 8.9 ▉ 6% + milky-way 2.3 5.0 ▊ 5% + milky-way-upsample 2.3 4.3 ▊ 5% + star-catalog 2.3 7.2 ▊ 5% + star-upsample 2.2 7.5 ▊ 5% + marker-lines 2.2 8.5 ▊ 5% + star-points 2.2 5.4 ▊ 5% + hdr→swap 2.2 8.5 ▊ 5% + near0-selection-ring 2.2 8.5 ▊ 5% + foreground-labels 2.2 8.5 ▊ 5% + labels 2.2 8.5 ▊ 5% + milky-way-aggregate 2.1 4.7 ▊ 5% + scalar-volume 1.9 4.4 ▋ 4% + star-aggregates 1.2 3.2 ▍ 3% + procedural-disks 1.1 2.0 ▍ 3% + structure-markers 0.9 2.9 ▎ 2% + volume-upsample 0.7 2.5 ▎ 2% + textured-disks 0.4 1.4 ▏ 1% + EST. PER-PASS FLOOR ≈ 2.6 ms (hdr·COSMO) + → point-sprites ≈ 8.0 ms real + → procedural-disks ≈ -1.5 ms real + → textured-disks ≈ -2.2 ms real + → volume-upsample ≈ -1.9 ms real + → structure-markers ≈ -1.7 ms real + EST. PER-PASS FLOOR ≈ 1.5 ms (hdr·NEAR0) + → milky-way-upsample ≈ 0.7 ms real + → milky-way ≈ 0.8 ms real + → star-points ≈ 0.6 ms real + → star-catalog ≈ 0.7 ms real + → star-upsample ≈ 0.7 ms real + EST. PER-PASS FLOOR ≈ 1.0 ms (swap·COSMO) + → marker-lines ≈ 1.2 ms real + → labels ≈ 1.2 ms real + EST. PER-PASS FLOOR ≈ 1.0 ms (swap·NEAR0) + → near0-selection-ring ≈ 1.2 ms real + → foreground-labels ≈ 1.2 ms real + SUMMARY + ✗ Over the 30fps budget (36.4 ms — ~27 fps ceiling). + Hottest pass: hdr·NEAR0 — 3.6 ms, 15% of MERGED GPU time. + Per-pass floor ≈ 1.5 ms; instrumented per-layer total inflated ~16.9 ms over merged. + +measuring 'milky-way-outside' (30 frames @ dpr 2) ... +milky-way-outside (1400×900 @dpr2, tier medium, 30 frames, median ms | p90) + TOTAL (merged, production) 45.9 ms/frame | 70.3 p90 + → ~22 fps GPU-bound ceiling (timed passes only; excludes CPU/present/vsync) + TOTAL (per-layer, instrumented — not representative) 75.1 ms/frame | 110.4 p90 + MERGED (production pass shape) + group median p90 share % + ───────────────────────────────────────────────────────── + hdr·COSMO 11.6 17.7 ████▎ 29% + hdr·NEAR0 4.6 9.2 █▋ 11% + mw-aggregate·NEAR0 4.5 8.5 █▋ 11% + bloom 4.4 10.3 █▋ 11% + hdr→swap 4.2 10.0 █▌ 10% + swap·COSMO 4.1 10.0 █▌ 10% + swap·NEAR0 4.1 10.0 █▌ 10% + volume·COSMO 1.7 6.2 ▋ 4% + star-aggregates·NEAR0 1.5 5.4 ▌ 4% + PER-LAYER (attribution; each row includes ~FLOOR pass overhead) + layer median p90 share % + ─────────────────────────────────────────────────────── + point-sprites 10.5 17.4 ██▏ 14% + milky-way-upsample 5.6 7.8 █▏ 8% + bloom 5.4 8.6 █▏ 7% + labels 5.1 8.9 █ 7% + foreground-labels 5.1 8.9 █ 7% + hdr→swap 5.0 8.8 █ 7% + marker-lines 5.0 8.9 █ 7% + milky-way-aggregate 5.0 7.5 █ 7% + star-upsample 4.9 7.6 █ 7% + star-points 4.9 7.4 █ 7% + star-catalog 4.8 7.6 █ 7% + milky-way 4.8 7.9 █ 7% + scalar-volume 2.0 5.5 ▍ 3% + procedural-disks 1.2 3.2 ▎ 2% + star-aggregates 1.0 2.8 ▎ 1% + structure-markers 1.0 3.2 ▎ 1% + volume-upsample 0.8 3.2 ▏ 1% + textured-disks 0.7 2.9 ▏ 1% + EST. PER-PASS FLOOR ≈ 0.5 ms (hdr·COSMO) + → point-sprites ≈ 10.0 ms real + → procedural-disks ≈ 0.7 ms real + → textured-disks ≈ 0.2 ms real + → volume-upsample ≈ 0.3 ms real + → structure-markers ≈ 0.4 ms real + EST. PER-PASS FLOOR ≈ 4.1 ms (hdr·NEAR0) + → milky-way-upsample ≈ 1.5 ms real + → milky-way ≈ 0.7 ms real + → star-points ≈ 0.8 ms real + → star-catalog ≈ 0.7 ms real + → star-upsample ≈ 0.9 ms real + EST. PER-PASS FLOOR ≈ 3.0 ms (swap·COSMO) + → marker-lines ≈ 2.0 ms real + → labels ≈ 2.1 ms real + SUMMARY + ✗ Over the 30fps budget (45.9 ms — ~22 fps ceiling). + Hottest pass: hdr·COSMO — 11.6 ms, 29% of MERGED GPU time. + Per-pass floor ≈ 2.5 ms; instrumented per-layer total inflated ~29.3 ms over merged. + ⚠ 2 page error(s): console.error: Failed to load resource: the server responded with a status of 404 (NOT FOUND. Requested (ra, dec) is outside the SDSS footprint.) + +measuring 'milky-way-close' (30 frames @ dpr 2) ... +milky-way-close (1400×900 @dpr2, tier medium, 30 frames, median ms | p90) + TOTAL (merged, production) 50.5 ms/frame | 79.9 p90 + → ~20 fps GPU-bound ceiling (timed passes only; excludes CPU/present/vsync) + TOTAL (per-layer, instrumented — not representative) 87.3 ms/frame | 121.7 p90 + MERGED (production pass shape) + group median p90 share % + ───────────────────────────────────────────────────────── + hdr·COSMO 11.0 24.3 ███▊ 25% + hdr·NEAR0 5.5 10.2 █▉ 13% + bloom 5.5 10.9 █▉ 13% + mw-aggregate·NEAR0 5.2 10.2 █▊ 12% + hdr→swap 4.5 10.4 █▌ 10% + swap·COSMO 4.5 10.4 █▌ 10% + swap·NEAR0 4.5 10.4 █▌ 10% + star-aggregates·NEAR0 1.8 6.5 ▋ 4% + volume·COSMO 1.5 5.9 ▌ 3% + PER-LAYER (attribution; each row includes ~FLOOR pass overhead) + layer median p90 share % + ─────────────────────────────────────────────────────── + point-sprites 8.8 16.2 █▋ 10% + marker-lines 6.9 10.3 █▎ 8% + foreground-labels 6.9 10.3 █▎ 8% + hdr→swap 6.9 10.3 █▎ 8% + labels 6.8 10.3 █▎ 8% + bloom 6.8 10.6 █▎ 8% + milky-way-upsample 6.3 7.9 █▏ 7% + milky-way-aggregate 6.3 8.4 █▏ 7% + star-upsample 5.8 9.3 █ 7% + star-catalog 5.7 9.2 █ 7% + milky-way 5.6 8.6 █ 7% + star-points 5.4 9.1 █ 6% + scalar-volume 1.7 4.9 ▎ 2% + star-aggregates 1.1 2.7 ▎ 1% + procedural-disks 1.0 2.3 ▏ 1% + structure-markers 0.9 3.1 ▏ 1% + volume-upsample 0.8 2.0 ▏ 1% + textured-disks 0.6 5.6 ▏ 1% + EST. PER-PASS FLOOR ≈ 0.2 ms (hdr·COSMO) + → point-sprites ≈ 8.6 ms real + → procedural-disks ≈ 0.8 ms real + → textured-disks ≈ 0.3 ms real + → volume-upsample ≈ 0.6 ms real + → structure-markers ≈ 0.7 ms real + EST. PER-PASS FLOOR ≈ 4.7 ms (hdr·NEAR0) + → milky-way-upsample ≈ 1.7 ms real + → milky-way ≈ 0.9 ms real + → star-points ≈ 0.7 ms real + → star-catalog ≈ 1.1 ms real + → star-upsample ≈ 1.2 ms real + EST. PER-PASS FLOOR ≈ 4.7 ms (swap·COSMO) + → marker-lines ≈ 2.3 ms real + → labels ≈ 2.2 ms real + SUMMARY + ✗ Over the 30fps budget (50.5 ms — ~20 fps ceiling). + Hottest pass: hdr·COSMO — 11.0 ms, 25% of MERGED GPU time. + Per-pass floor ≈ 3.2 ms; instrumented per-layer total inflated ~36.8 ms over merged. + ⚠ 2 page error(s): console.error: Failed to load resource: the server responded with a status of 404 (NOT FOUND. Requested (ra, dec) is outside the SDSS footprint.) + +measuring 'galactic-centre' (30 frames @ dpr 2) ... +galactic-centre (1400×900 @dpr2, tier medium, 30 frames, median ms | p90) + TOTAL (merged, production) 18.9 ms/frame | 31.2 p90 + → ~53 fps GPU-bound ceiling (timed passes only; excludes CPU/present/vsync) + TOTAL (per-layer, instrumented — not representative) 14.0 ms/frame | 16.9 p90 + MERGED (production pass shape) + group median p90 share % + ───────────────────────────────────────────────────────── + hdr·COSMO 8.9 17.2 ████████▌ 57% + volume·COSMO 1.9 5.3 █▊ 12% + bloom 1.3 6.7 █▎ 9% + swap·COSMO 1.0 1.8 █ 6% + swap·NEAR0 1.0 1.8 ▉ 6% + hdr→swap 0.9 1.8 ▉ 6% + hdr·NEAR0 0.7 2.8 ▋ 4% + star-aggregates·NEAR0 0.0 1.5 0% + PER-LAYER (attribution; each row includes ~FLOOR pass overhead) + layer median p90 share % + ──────────────────────────────────────────────────── + point-sprites 5.0 5.7 █████▎ 35% + scalar-volume 1.4 1.5 █▌ 10% + procedural-disks 1.1 1.3 █▎ 8% + bloom 0.7 0.9 ▊ 5% + hdr→swap 0.7 0.9 ▊ 5% + marker-lines 0.7 0.9 ▊ 5% + labels 0.7 0.9 ▊ 5% + foreground-labels 0.7 0.9 ▊ 5% + star-upsample 0.6 0.7 ▋ 4% + orbit-trails 0.5 0.6 ▌ 4% + star-catalog 0.5 0.7 ▌ 4% + structure-markers 0.5 0.6 ▌ 3% + star-points 0.5 0.5 ▌ 3% + volume-upsample 0.4 0.5 ▍ 3% + textured-disks 0.3 0.3 ▎ 2% + star-aggregates 0.0 0.0 0% + EST. PER-PASS FLOOR ≈ 0.0 ms (hdr·COSMO) + → point-sprites ≈ 5.0 ms real + → procedural-disks ≈ 1.1 ms real + → textured-disks ≈ 0.3 ms real + → volume-upsample ≈ 0.4 ms real + → structure-markers ≈ 0.5 ms real + EST. PER-PASS FLOOR ≈ 0.4 ms (hdr·NEAR0) + → star-points ≈ 0.1 ms real + → orbit-trails ≈ 0.2 ms real + → star-catalog ≈ 0.2 ms real + → star-upsample ≈ 0.2 ms real + EST. PER-PASS FLOOR ≈ 0.2 ms (swap·COSMO) + → marker-lines ≈ 0.5 ms real + → labels ≈ 0.5 ms real + SUMMARY + ⚠ Over the 60fps budget (18.9 of 16.7 ms — ~53 fps ceiling). + Hottest pass: hdr·COSMO — 8.9 ms, 57% of MERGED GPU time. + Per-pass floor ≈ 0.2 ms; instrumented per-layer total inflated ~0.0 ms over merged. + +measuring 'local-group' (30 frames @ dpr 2) ... +local-group (1400×900 @dpr2, tier medium, 30 frames, median ms | p90) + TOTAL (merged, production) 18.1 ms/frame | 66.8 p90 + → ~55 fps GPU-bound ceiling (timed passes only; excludes CPU/present/vsync) + TOTAL (per-layer, instrumented — not representative) 29.0 ms/frame | 72.4 p90 + MERGED (production pass shape) + group median p90 share % + ──────────────────────────────────────────────────────── + hdr·NEAR0 2.8 5.8 ██▍ 15% + star-aggregates·NEAR0 2.7 4.9 ██▎ 15% + foreground:0·NEAR0 2.7 4.2 ██▎ 15% + bloom 1.8 8.7 █▌ 10% + swap·NEAR0 1.8 9.1 █▌ 10% + hdr→swap 1.7 8.8 █▍ 9% + swap·COSMO 1.7 9.0 █▍ 9% + foreground:0→hdr 1.4 5.7 █▏ 8% + foreground:0·BODY[0] 1.2 5.6 █ 7% + hdr·COSMO 0.3 2.5 ▎ 1% + PER-LAYER (attribution; each row includes ~FLOOR pass overhead) + layer median p90 share % + ──────────────────────────────────────────────────────── + point-sprites 11.8 36.7 ███████▋ 51% + scalar-volume 2.0 7.9 █▍ 9% + foreground-labels 1.3 2.5 ▉ 6% + procedural-disks 1.1 3.6 ▊ 5% + bloom 1.1 6.6 ▊ 5% + labels 1.0 2.6 ▋ 5% + near0-selection-ring 1.0 2.6 ▋ 5% + hdr→swap 1.0 2.5 ▋ 4% + marker-lines 1.0 7.5 ▋ 4% + structure-markers 0.7 3.3 ▍ 3% + volume-upsample 0.5 2.6 ▍ 2% + textured-disks 0.4 5.1 ▎ 2% + EST. PER-PASS FLOOR ≈ 2.8 ms (hdr·COSMO) + → point-sprites ≈ 8.9 ms real + → procedural-disks ≈ -1.7 ms real + → textured-disks ≈ -2.4 ms real + → volume-upsample ≈ -2.3 ms real + → structure-markers ≈ -2.2 ms real + EST. PER-PASS FLOOR ≈ 0.2 ms (swap·COSMO) + → marker-lines ≈ 0.8 ms real + → labels ≈ 0.9 ms real + EST. PER-PASS FLOOR ≈ 0.3 ms (swap·NEAR0) + → near0-selection-ring ≈ 0.8 ms real + → foreground-labels ≈ 1.0 ms real + SUMMARY + ⚠ Over the 60fps budget (18.1 of 16.7 ms — ~55 fps ceiling). + Hottest pass: hdr·NEAR0 — 2.8 ms, 15% of MERGED GPU time. + Per-pass floor ≈ 1.1 ms; instrumented per-layer total inflated ~10.9 ms over merged. + ⚠ 2 page error(s): console.error: Failed to load resource: the server responded with a status of 404 (NOT FOUND. Requested (ra, dec) is outside the SDSS footprint.) + +measuring 'full-survey' (30 frames @ dpr 2) ... +full-survey (1400×900 @dpr2, tier medium, 30 frames, median ms | p90) + TOTAL (merged, production) 26.1 ms/frame | 62.5 p90 + → ~38 fps GPU-bound ceiling (timed passes only; excludes CPU/present/vsync) + TOTAL (per-layer, instrumented — not representative) 88.5 ms/frame | 143.2 p90 + MERGED (production pass shape) + group median p90 share % + ───────────────────────────────────────────────────────── + hdr·NEAR0 3.2 15.8 ██▎ 15% + star-aggregates·NEAR0 3.0 16.9 ██▏ 14% + foreground:0·NEAR0 2.9 11.8 ██ 13% + swap·NEAR0 2.2 4.7 █▌ 10% + hdr→swap 2.2 4.3 █▌ 10% + swap·COSMO 2.2 4.5 █▌ 10% + bloom 2.1 4.2 █▍ 9% + foreground:0→hdr 1.6 3.5 █▏ 7% + foreground:0·BODY[0] 1.4 3.4 █ 7% + hdr·COSMO 0.9 11.3 ▋ 4% + PER-LAYER (attribution; each row includes ~FLOOR pass overhead) + layer median p90 share % + ──────────────────────────────────────────────────────── + point-sprites 15.5 31.4 █▋ 11% + foreground-labels 13.7 14.4 █▌ 10% + labels 13.2 14.1 █▍ 9% + marker-lines 13.2 14.1 █▍ 9% + near0-selection-ring 11.5 19.5 █▎ 8% + bloom 11.5 19.4 █▎ 8% + hdr→swap 11.5 19.5 █▎ 8% + volume-upsample 10.9 14.8 █▏ 8% + textured-disks 10.5 10.5 █▏ 7% + procedural-disks 10.3 10.3 █▏ 7% + structure-markers 9.8 13.9 █ 7% + horizon-shell 9.4 14.0 █ 7% + scalar-volume 0.9 3.7 ▏ 1% + EST. PER-PASS FLOOR ≈ 10.9 ms (hdr·COSMO) + → point-sprites ≈ 4.6 ms real + → procedural-disks ≈ -0.6 ms real + → textured-disks ≈ -0.4 ms real + → volume-upsample ≈ 0.0 ms real + → horizon-shell ≈ -1.5 ms real + → structure-markers ≈ -1.1 ms real + EST. PER-PASS FLOOR ≈ 12.1 ms (swap·COSMO) + → marker-lines ≈ 1.1 ms real + → labels ≈ 1.1 ms real + EST. PER-PASS FLOOR ≈ 11.5 ms (swap·NEAR0) + → near0-selection-ring ≈ 0.0 ms real + → foreground-labels ≈ 2.2 ms real + SUMMARY + ⚠ Over the 60fps budget (26.1 of 16.7 ms — ~38 fps ceiling). + Hottest pass: hdr·NEAR0 — 3.2 ms, 15% of MERGED GPU time. + Per-pass floor ≈ 11.5 ms; instrumented per-layer total inflated ~62.4 ms over merged. + ⚠ 2 page error(s): console.error: Failed to load resource: the server responded with a status of 404 (NOT FOUND. Requested (ra, dec) is outside the SDSS footprint.) + ALL SCENARIOS (merged median ms | fps ceiling) + scenario total fps verdict + ───────────────────────────────────────── + earth-surface 25.0 40 ⚠ 30–60fps + solar-system 27.3 37 ⚠ 30–60fps + star-field 25.3 39 ⚠ 30–60fps + milky-way 36.4 27 ✗ <30fps + milky-way-outside 45.9 22 ✗ <30fps + milky-way-close 50.5 20 ✗ <30fps + galactic-centre 18.9 53 ⚠ 30–60fps + local-group 18.1 55 ⚠ 30–60fps + full-survey 26.1 38 ⚠ 30–60fps +``` + +Task 1: complete (no commits — measurement only; baseline verbatim above). +Ruling: no task review for T1 — nothing to diff; the verbatim-transcription +check was done by the implementer (diffed ledger vs raw stdout). Cost if +wrong: a corrupt baseline surfaces at T21's diff. +Note for T21: Apple Silicon slot-sum inflation visible (identical adjacent +medians); 7/9 poses log benign SDSS-footprint 404s (pre-existing). + +Task 2: implementer DONE, commit 1a4675a16 (types + SURFACE_REGIME); reviewer +dispatched (sonnet, package review-630993b50..1a4675a16.diff). +Task 3: implementer dispatched (opus, BASE 1a4675a16) — pipelined per Rule 2 +(T3 files disjoint from T2's frozen review). + +Task 2: complete (commits 630993b50..1a4675a16, review clean). Minor +(deferred, not actionable): leaf-type comment density exceeds half-of-code +ratio because spec §3 doc comments are mandated verbatim; surfaceRegime.ts +one-line header consistent with src/data convention. + +Task 3: implementer DONE, commit b3844fa38 (conversions, 11 tests, round-trip +4.7e-10 m vs 14 µm bar; mutation-verified). Reviewer dispatched (opus, +package review-1a4675a16..b3844fa38.diff). Implementer concerns to reviewer: +poseBasis cast, miss-fallback orientation loss, tolerance sizing. +Task 4: implementer dispatched (sonnet, BASE b3844fa38) — pipelined; touches +only oneMpcSeam.test.ts, disjoint from T3's frozen review. + +Task 4: implementer DONE, commit b25bb0b56 (camera-path sweep, probe-verified +gate, suite 7953). Reviewer dispatched (sonnet, +package review-b3844fa38..b25bb0b56.diff). +Task 5: implementer dispatched (sonnet, BASE b25bb0b56) — pipelined; new +files only, disjoint from open T3/T4 reviews. + +Task 4: complete (commits b3844fa38..b25bb0b56, review clean — reviewer +independently verified no unlisted SCALE_UNITS violator in swept dirs). + +Task 3: review returned 2 Important (fix loop opens; freeze rule: no NEW +implementers until closed; T5 in flight runs to completion, fix queues +behind it): + I1 DIR_FLOOR comment/constant mismatch (1e-9 vs derived 5e-11; tighten to + 1e-10 or amend comment). + I2 composition unbound to updatePosition — add one fixture assertion using + updatePosition as fixture source. +Ruling: reviewer's derivation accepted — brief/spec "~14 µm" is really the +2-ulp-at-1AU figure (~50 µm); spec lines ~337/~605 to be corrected in the fix +round so the bar isn't re-litigated. Cost if wrong: a loosened bar hides real +drift — the measured 4.7e-10 m says it can't bite today. +Task 3 minors (deferred): poseBasis as Mat3 cast (widen +orbitAnglesLookingAlong to Readonly, kills cast); BODY_CENTRE not Readonly; +LABELS parallel array index-coupled; body→world→body direction untested +(~1e-6 m bar would make "lossless" load-bearing); spec §5.2 stale +"roll hard-coded 0" text. + +Task 5: implementer DONE, commit 0af3434ca (maxTiltRad, 4 tests, suite 7961). +Reviewer dispatched (sonnet, package review-b25bb0b56..0af3434ca.diff). +Task 3: fix round 1 dispatched (resumed original opus implementer): I1 +DIR_FLOOR, I2 updatePosition-bound assertion, + sanctioned spec 14µm→2ulp +correction. T6+ dispatch frozen until T3 loop closes. + +Task 5: complete (commits b25bb0b56..0af3434ca, review clean; reviewer +re-derived all asserted values from smoothstep clamp semantics). + +Task 3: fix round 1/5 (2 addressed per implementer + spec correction; commit +eff9a06bf, impl module byte-identical). Implementer landmine note: exact +world-space assertions are BLIND at heliocentric magnitude (~25 µm grid) — +near-origin fixtures required to make them bite. Scoped re-review dispatched +(sonnet, package review-0af3434ca..eff9a06bf.diff). + +Task 3: complete (commits 1a4675a16..b3844fa38 + fix eff9a06bf, re-review +clean — all findings addressed, spec correction verified minimal). Freeze +lifted. + +Task 6: implementer dispatched (sonnet, BASE eff9a06bf). Board refreshed +(5/22). + +Task 6: implementer DONE, commit a2d6b3685 (reanchoredPose, 5 tests, suite +7971; TRIGGER_FRACTION=1e-3 reasoned-not-spec-given, revisit with real +deep-zoom caller). Reviewer dispatched (sonnet, +package review-eff9a06bf..a2d6b3685.diff). +origin/main 0b1787ae4 (#649 black-hole ground prep P1-P3) merged in as +f8f6d3f3f at the T6/T7 boundary; typecheck:fast clean, full-suite gate +running. T7 dispatch waits on that gate. + +Merge gate GREEN: suite 7998 / 1175 files after f8f6d3f3f (main #649 in). +Task 7: implementer dispatched (sonnet, BASE f8f6d3f3f). + +Task 6: review returned 1 Critical + 1 Important (fix loop opens; freeze: +T7 in flight runs to completion, T6 fix queues behind it): + C1 tests blind to quantization BASIS — ulpAt(rangeM) or a fixed 1e-6 grid + passes all 5 tests; fix = hand-derived exact expected value for + eyeRelAnchorM/d from ulpAt(anchorMagM) in ≥1 fixture. + I1 comment budget overrun (20 comment vs 30 code lines) + report's + compliance claim wrong; trim to ≤ half. + Minor (deferred): no power-of-two anchor fixture guarding ulpAt's own + log2 landmine. +Note: reviewer verified C1 by substitution but left tree clean (verified +git status empty; T7 uncontaminated). + +Task 7: implementer DONE, commit 71c460af6 (cursorRayBodyLocal, 4 tests, +suite 8006; y-flip matched mcpm-workbench screenToRay precedent). Reviewer +dispatched (sonnet, package review-f8f6d3f3f..71c460af6.diff) — asked to +check the pick-path convention agreement specifically. +Task 6: fix round 1/5 dispatched (resumed implementer): C1 quantization-basis +pinning test, I1 comment trim. +Ruling: freeze rule relaxed for worktree-ISOLATED implementers — T8/T9/T12 +dispatched in parallel isolated worktrees (sonnet ×3, branched at 71c460af6) +while T6's fix runs in the main tree. Rationale: the freeze guards tree +contention + building on defective ground; isolation removes contention, and +none of T8/T9/T12 touch reanchoredPose. Cost if wrong: cherry-pick +conflicts / a wave rebuilt. Merge-back = controller cherry-picks reported +SHAs serially, then one gate; reviews run on the cherry-picked ranges. + +## In-flight + handling (compact checkpoint 2026-09-01) + +Live agents and what to do with each result: +- T6 fix round 1 (resumed T6 implementer, main tree): on DONE → scoped + re-review of the fix commit only (sonnet, review-package ..) + verifying C1 basis-pinning test + I1 comment trim; clean → Task 6 complete. +- T7 reviewer (sonnet): clean → Task 7 complete; Important+ → fix loop, + resume T7 implementer. +- T8 / T9 / T12 implementers (isolated worktrees, sonnet): each reports a + FULL 40-char SHA; cherry-pick reported SHAs onto camera-pivot serially + (order: 9, 8, 12 — any order works, files disjoint), run + typecheck:fast + full npm test as one gate, then per-task review packages + over each cherry-picked commit and 3 reviewers (sonnet). Reports land in + scratchpad/task-{8,9,12}-report.md. + +Queued sequence after the above: T10 ‖ T11 as second isolated wave (both +depend on T7 [cursor ray] + T9 [floor]; T10 = opus per model plan) → +serial T13 (opus, big migration) → T14 → T15 → T16-T18 → T19-T20 → T21 → +T22 USER gate. +Standing user directives this session: ping the user "something to test" +at the T16/T17 boundary (first hands-on feel check, dev server :5173); +merge origin/main at task boundaries when it moves (last merged 0b1787ae4 +as f8f6d3f3f, gate 7998 green); parallel isolated implementers approved +(user asked for more parallelism). +Board artifact (refresh on completions, same source file): +https://claude.ai/code/artifact/d7b9e10c-386f-49e9-a222-673b5f7bfae0 +source /private/tmp/claude-501/-Users-rulkens-Development-js-skymap/479e7522-f79c-4713-a329-cff75ebf382e/scratchpad/camera-pivot-board.html +Push policy: branch not yet pushed since plan commit — push accumulated +commits to origin camera-pivot (PR #647) at the next quiet boundary. + +Task 7: complete (commits f8f6d3f3f..71c460af6, review clean; y-flip +convention verified against forwardProjectPoint/composeOrbitConic/ +horizonShell — engine-side corroboration recorded for T16's benefit). +Minors (deferred): hypot lacks the precedent's ||1 guard (can't fire — +forward unit ⇒ norm ≥ 1); unit-length test tautological (brief-mandated). + +LANDMINE (isolation waves): Agent isolation:worktree branches from origin/ +MAIN, not this branch — T9 built on main (worked; self-contained), T8/T12 +were messaged mid-flight to `git merge 71c460af6` before proceeding. Future +isolated dispatches must include that merge step in the dispatch prompt. +Task 6: fix round 1/5 (2 addressed + bonus fixtures; commit 8bd6eff1c, +discriminating fixtures substitution-verified by implementer). Scoped +re-review dispatched (sonnet, review-71c460af6..8bd6eff1c.diff). +Task 9: implementer DONE in isolated wt (main-based, self-contained); +cherry-picked b22248db9 → 5524e1282 on camera-pivot. Reviewer dispatched +(sonnet, review-8bd6eff1c..5524e1282.diff). + +Task 9: complete (cherry-picked 5524e1282, review clean). + +Task 6: complete (commits eff9a06bf..a2d6b3685 + fix 8bd6eff1c, re-review +clean — re-reviewer independently recomputed both fixtures under three grid +bases; power-of-two bonus fixture included). + +## Compact checkpoint 2 (2026-09-01) + +Done through: T1-T7, T9 complete; HEAD 5524e1282 (pushed to #647). Board +current (8/22). +Live agents: T8 (surface readout) + T12 (regime predicate) in ISOLATED +worktrees, both corrected mid-flight to `git merge 71c460af6` (they branched +from main — see landmine above). Handling on completion: each reports a full +SHA → cherry-pick onto camera-pivot (T8 then T12, any order), one +typecheck:fast+`npm test` gate, review packages over each cherry-picked +commit, sonnet reviewers. Their editor diagnostics leak into the main +session's IDE — ignore, they're worktree-local. +Then: T10 ‖ T11 isolated wave (deps T7+T9 satisfied; T10=opus, T11=sonnet; +DISPATCH PROMPTS MUST INCLUDE the `git merge ` setup +step) → serial from T13 (opus). Everything else per checkpoint 1 above. + +Task 12: implementer DONE in isolated wt (merged camera-pivot first); +cherry-picked e67c55fd2 → e53f97e30; typecheck:fast + focused 135 green. +Reviewer dispatch next (sonnet, review-5524e1282..e53f97e30.diff). +WATCH ITEM (for T13/T20): BodyId is a 5-value settings-category union, not +per-row ids — camera-pivot code (poseFrameConversion etc.) uses the existing +`id as BodyId` cast convention; serialization/frame-tag work must not assume +one BodyId per SCENE_BODIES row. Flagged in task-12-report.md. + +Task 8: implementer DONE in isolated wt (self-corrected the main-branch +issue before the coordinator message); cherry-picked 6a198f5aa → 2ab0e98c8; +typecheck:fast + focused 6/6 green. Reviewer dispatched (sonnet, +review-e53f97e30..2ab0e98c8.diff) — asked to judge the standpoint +forward-ray/nadir-fallback interpretation against spec §14 + T16/T17 needs. +T10 ‖ T11: isolated wave dispatched (T10 opus, T11 sonnet; BASE for both = +2ab0e98c8 via in-prompt `git merge 2ab0e98c8` step). Handling: cherry-pick +reported SHAs (either order, disjoint), gate, review each. + +Task 12: complete (cherry-picked e53f97e30, review clean). Minors (deferred): +header ~12-14 lines vs 10 budget (load-bearing content); disengageHR only +exercised at ±0.1 margins; roster-dropout hold branch untested. + +Task 8: review 1 Important — no-hit standpoint fallback is DISCONTINUOUS at +tangency (~24.6° snap at h/R=0.1) and the code/report claim "reduces +continuously" is false. Fix round 1 dispatched (resume T8 agent in its +worktree): strike/reword the claim; document the discontinuity + display-only +scope. Ruling: surfaceReadoutOf is the READOUT currency; T16 gestures use +the latched anchor's ENU (spec §6) and T17 enforcement must derive its ENU +from the eye's footprint local vertical, NOT this function's forward-ray +standpoint — verify against spec §6 wording at T17 dispatch and carry this +ruling in T16/T17 briefs. Cost if wrong: T17 re-work; display snap is +cosmetic. Minor (deferred): shared localEastNorth extraction once a second +ENU consumer appears (watch at T16 review). + +Task 8: fix round 1/5 (comment-only; cherry-picked 5d1ac4074 → 908c7d6b2, +typecheck clean). Scoped re-review dispatched (haiku, +review-2ab0e98c8..908c7d6b2.diff). + +Task 8: complete (commits e53f97e30..2ab0e98c8 + fix 908c7d6b2, re-review +clean). + +Task 10: implementer DONE_WITH_CONCERNS; cherry-picked 19740e19e → +b75456ee0, gate green (8/8 + typecheck). Opus reviewer dispatched +(review-908c7d6b2..b75456ee0.diff) to independently adjudicate the three +concerns: (1) quaternion direction inverse of spec §6a prose (implementer +argues p̂₁→p̂₀ is the FW-I-correct sign; if confirmed → sanction spec +reword); (2) reorthonormalise handedness landmine (right×up=−forward ⇒ +(forward,up,right) argument order); (3) grazing test on both rays. + +Task 11: implementer DONE_WITH_CONCERNS; cherry-picked 09c3862a8 → +bb603ebc4, gate green (6/6 + typecheck). Opus reviewer dispatched +(review-b75456ee0..bb603ebc4.diff) to adjudicate: (1) FW-H round-trip test +uses null anchor on BOTH legs (implementer's mixed-legs-can't-cancel proof +— does the flagship test still catch the stored-pivot bug class?); (2) +13-line header w/ tangent-plane proof; (3) 0.5/2.0 factor clamps feel-open. +Phase 1 implementation now FULLY LANDED (T2-T12 all on branch); +Phase 2 serial T13 waits on T10/T11 reviews closing. + +Task 10: review APPROVED (opus independently derived all three concerns in +the code's favour; FW-I pole fixture confirmed non-back-fittable). +Ruling: spec §6a quaternion-direction prose SANCTIONED for amendment (pose +rotates by the INVERSE of the p̂₀→p̂₁ map — reviewer + implementer agree by +independent derivation). Cost if wrong: a reversed-drag feel, caught by 4 +FW-I tests. +Fix round 1 dispatched (resume T10 agent): spec §6a amendment, +reorthonormalise right-handed header warning, export MIN_INCIDENCE_COS +(single home for T16), comment overclaim trim, PickRay unit-dir note. +CARRY TO T16 BRIEF: import MIN_INCIDENCE_COS from anchoredDragRotation +(never restate); miss ⇒ trackball stickily vs grazing ⇒ strafe-in-anchor- +plane must be distinguished by the CONTROLLER re-checking incidence — the +null return does not name its reason (brief-mandated shape). +Minor (deferred): anchor rotation FP random-walk ~25 nm/10 s — below §5.3 +visibility floor, no action. + +Task 11: review NEEDS FIXES — 2 Important: I1 all cursor-path assertions +call-vs-call (latched-anchor/ignores-cursor implementations pass 6/6; fix = +closed-form expectations, with-cursor call kept first); I2 module-level +mutable CENTRE cell (inline it). Ruling: centre/centre FW-H round-trip +reading CONFIRMED correct by reviewer's independent derivation (mixed legs +residual ~|A| — geometry). Fix round 1 dispatched (resumed T11 agent) incl. +header trim to ≤10 w/ algebra one-liner + centre-measured-factor contract. +CARRY TO T22: MIN_FACTOR=1/MAX_FACTOR reciprocity is load-bearing for +clamped fling round-trips — a non-reciprocal feel pick (0.6/2.0) breaks it +with no test firing. + +Task 10: fix round 1/5 (5 follow-ups; cherry-picked da5304032 → 08d989193, +gate green). Scoped re-review dispatched (sonnet, +review-bb603ebc4..08d989193.diff). Extra T22 note from implementer: grazing +threshold measured on the frozen radius, not the surface (diverges only for +below-surface picks). + +Task 11: fix round 1/5 (I1 closed-form pins, I2 const inlined, header 10 +lines; cherry-picked 32a5e3b4a → 21881f477, gate green). Scoped re-review +dispatched (sonnet, review-08d989193..21881f477.diff). + +Task 10: complete (commits 19740e19e→b75456ee0 + fix da5304032→08d989193, +re-review clean; §6a amendment verified geometrically accurate). + +Task 11: complete (commits 09c3862a8→bb603ebc4 + fix 32a5e3b4a→21881f477, +re-review clean). Minor (deferred): header 11 lines vs claimed 10 — +justifiable, park for comment-audit. +PHASE 1 COMPLETE (T1-T12). Full-suite gate before T13 next; then serial +Phase 2. + +Phase-2 gate GREEN: full suite 8182 at 21881f477. +Task 13: implementer dispatched (opus, MAIN tree, BASE 21881f477) — union +migration; dispatch carried the BodyId watch item, driver/commit/one- +resolution rulings, and the stop-on-assertion-change rule. + +## Compact checkpoint 3 (2026-09-01) + +Done: T1-T12 (Phase 0-1) complete + reviewed; HEAD 21881f477 pushed to #647; +suite 8182 green at that HEAD; board current (12/22). +ONE live agent: T13 implementer (opus, MAIN tree, BASE 21881f477, union +migration). Handling on DONE: review-package 21881f477..HEAD → OPUS task +reviewer (large diff; check behaviour-identical claim, no renderer files, +driver isActive gating, one-resolution-per-frame); DONE_WITH_CONCERNS → +read concerns first; BLOCKED/NEEDS_CONTEXT → likely a reader needing real +behaviour change: rule against spec §9 and re-dispatch. Then T14 (sonnet, +provider B, frameContext seam) → T15 (opus, the fold) → T16-T18 → T19-T20 → +T21 perf (--url :5173) → T22 USER gate (ping user; also settle: roster +narrowing?, grazing sym/asym + frozen-radius note, MIN/MAX_FACTOR +reciprocity, tiltFullHR). +All standing directives + carries: see checkpoints 1-2 + T10/T11/T8 carry +notes above. Briefs exist through task-13; generate later ones with +task-brief script as needed. + +## Task 13 — implementation landed, review in flight +- Implementer (opus, main tree): DONE_WITH_CONCERNS, commit a668f3b64. Suite 8192 green, typecheck green. Report: task-13-report.md. +- Review package: review-21881f477..a668f3b64.diff. OPUS reviewer dispatched; verdict file task-13-review.md. +- CARRY → Task 15: implementer concern #1 — resolveWorldArm THROWS on unresolved engaged body. Unreachable in T13 wiring; T15's fold must confirm regimeArmFor can't hold a body arm after the body leaves the roster (roster-dropout window). Rule there. +- Noted deviations (reviewer adjudicating): eyeMpcOf poseBasis widened to `Readonly | undefined`; `as BodyId` casts (incumbent convention); liveWorldPose reads LIVE cameraRuntime.upBasis (6 harnesses grew upBasis field). +- Baseline drift note: brief said 8182; pre-diff actual 8190 (main merge f8f6d3f3f added tests). Not a defect. +- origin/main moved to bfbbbcb70 (bullseye-famous) — merge scheduled at T13 close, before T14 dispatch. +- REVIEW VERDICT: Spec PASS · Quality APPROVED. 0 Critical / 3 Important / 6 Minor (task-13-review.md). Behaviour-identical claim independently confirmed (all 22 test hunks mechanical); one-resolution-per-frame holds incl. drainInput memo check; live upBasis ruled CORRECT (reproduces runFrame.ts:309/316; steady diverges in roll mid-slerp) — 6 harness upBasis fields are a true dependency; both contract deviations (optional poseBasis, `as BodyId`) accepted as incumbent convention. +- CARRY → Task 15 (epoch ruling): watchFlyToLonLatSaga calls resolveWorldArm against a FRESH clock sample — a second off-frame resolution site with a different epoch policy than liveWorldPose. Behaviour-identical today; rule the epoch policy at the fold. +- CARRY → Task 15/16: arm gates decouple driver activity from selectCameraActive (raw store flags) — in a body arm, autoRotate.active stays true: loop never sleeps, UI toggle reads "on" while nothing spins. Unreachable today; rule when gestures/fold make body arms constructible. +- Fix round 1 (comment-only): liveWorldPose docblock premise false at the in-loop followBody.pose call site — implementer resumed to rewrite docblock, new commit (no amend). Controller verifies diff directly (comment-only ⇒ no re-review agent). +- Fix round 1 CLOSED: 8eb47b28c5d2f488a61ac529cb242836b10e935c, comment-only (7+/7-, liveWorldPose module header), controller-verified by direct diff. Docblock now records the two epoch classes; T15 to confirm the in-produce-step epoch decision. + +Task 13: complete — commits a668f3b64 + 8eb47b28c. Suite 8192 green, typecheck green, review PASS/APPROVED. +- Task 14 dispatched: sonnet, main tree, BASE 897ff38b2 (post-merge origin/main bfbbbcb70 famous-assets-only, typecheck gate green, pushed). + +## Task 14 — implementation landed, review in flight +- Implementer (sonnet): DONE, commit d3468eff2716f8fdeb9be57cbc983b27e9b2f79b. Focused 55 tests + broader sweep 2466 green, typecheck:fast green. Report: task-14-report.md. +- Tolerance ruling applied: 5e-5 m (50 µm), matching poseFrameConversion.test.ts's EYE_FLOOR_M — brief's "~14 µm" superseded per T3 ruling. +- runFrame.ts + pickFrameContext.ts threading = mechanically forced (only production callers of deriveFrameContext), sanctioned by brief. +- OPUS reviewer dispatched on review-897ff38b2..d3468eff2.diff; verdict file task-14-review.md. +- REVIEW VERDICT: Spec PASS · Quality REVISE. 0C/3I/4m (task-14-review.md). Clean: inertness (toBodyArm zero src/ callers), one-resolution-per-frame, §5.2/S1 per-body branch + mode-switch mutant killed, anchor-fold test closed-form, threading mechanical. +- The 3 Importants = one defect: boundary-agreement test blind — providers bit-identical on fixture (reviewer measured), 5e-5 m tolerance ~13 decades below 1 ulp at 3e24 m, duplicate of poseFrameConversion.test.ts:338-359. Deleting provider-B branch fails NO test. +- Fix round 1 dispatched (resume implementer): replace agreement test with a ROUTING test (distinguishable hand-built body arm through deriveFrameContext; branch-delete must fail), drop duplicate numeric coverage, Minors at implementer judgment. +- LANDMINE (recurring, 3rd occurrence after T6/T11): agreement/equality tests at heliocentric magnitude are blind — demand a mutant-killing routing assertion, not numeric agreement. +- Fix round 1 landed: 2ceba02f1defd465a8e7478d054a8fd2f0580b98 — routing test (hand-built near-origin anchor split, provider A structurally can't produce it), EYE_FLOOR_M comparison deleted, duplicate coverage dropped; implementer mutation-verified by hand (seam-branch delete → only new test fails). Minor 6 applied; 4/5/7 skipped per reviewer's own out-of-scope notes. Sonnet scoped re-review dispatched on review-d3468eff2..2ceba02f1.diff. +- Re-review verdict: ALL ADDRESSED — independent mutation kill confirmed (seam-branch delete → exactly the routing test fails, expected value is a hand-computed literal). Full suite 8200 green. + +Task 14: complete — commits d3468eff2 + 2ceba02f1. Provider B inert behind the seam; routing mutant-killed; one-resolution-per-frame intact. + +## Task 15 — dispatched +- Implementer: OPUS, main tree, BASE 2ceba02f1 (T14 closed, suite 8200, pushed). The fold in runFrame — riskiest task. Dispatch carries the three rulings (resolveWorldArm throw/roster-dropout, saga epoch policy, autoRotate-active-in-body-arm) + the liveWorldPose epoch confirmation + the 3×-hit magnitude-blind-test landmine. +- Handling on DONE: review-package 2ceba02f1..HEAD → OPUS reviewer (fold placement vs runFrame ordering contract, one-resolution-per-frame, gesture-in-flight skip, no-snap test validity at magnitude, the three ruling resolutions). +- Implementer (opus): DONE_WITH_CONCERNS, commit d21ae5ab8c8b616695569fc7632f39d275f8fdb4. Full suite 8208 green (+8). Report: task-15-report.md. +- Ruling resolutions (implementer, pending review ratification): (1) unresolvable-body throw UNREACHABLE (deriveBodyStates key set time-invariant; regimeArmFor names only those ids) — throw left, dropout-hold branch now dead-from-frame-path, flagged not deleted. (2) saga keeps fresh clock sample — coherent at one epoch, can't reach lastRenderedSimDays store-side; RE-EXAMINE AT T19 (rewrites that file). (3) selectCameraActive auto-rotate term gated on absolute arm; selectAutoRotate preserves UI intent. liveWorldPose epoch docblock: confirmed correct. +- DEVIATION under adjudication: fold dispatches commitCameraPose on flip EDGE (else task inert — arm gates read s.camera.base.frame, resting re-emits absolute base). Spec §4/§12-T2 cited. OPUS reviewer told to give explicit verdict. +- INTERIM GAP (expected): engaged camera frozen to gestures until T16/T17 — branch NOT user-testable between 15 and 16; T22 feel gate after 16/17 (matches standing directive to ping user at that boundary). +- CARRY → T16 review lens: FramedCameraPose.frame.body vs BodyFixedPose.bodyId = one fact in two fields (T14 finding 5); fold sets both consistently; radar item. +- OPUS reviewer dispatched on review-2ceba02f1..d21ae5ab8.diff; verdict file task-15-review.md. +- REVIEW VERDICT: Spec FAIL · Quality REVISE. 1C/1I/5m (task-15-review.md). Deviation adjudication: commit-on-flip-edge SANCTIONED IN PRINCIPLE, implementation wrong — edge keyed on produced pose's arm (held.frame) not stored regime, so tween/clip (always absolute) inside band → re-commit EVERY frame (probe 4/4) and §4 hysteresis collapses to engage threshold during animated motion. +- C1 fix: key predicate current + dispatch edge on rootState.camera.base.frame; test: tween in band commits ONCE. I1: drainInput gestureEnd commits absoluteArm(poseOf(cam)) ungated — body-arm drag lands as snap on release (yaw 0.7→2.43 probe); interim fix = release no-op in body arm, T16 owns real behaviour. +- Verified sound: fold placement/FW-G order, one resolution per frame, gesture skip + no-double-fire, rulings 1+3, §14 inertness, no-snap tolerance HONEST at fixture magnitude (1 ulp ≈ 25 µm, 5e-5 ≈ 2 ulp = spec floor). +- Fix round 1 dispatched (resume implementer). Ruling: interim body-arm gesture release = no-op (freeze without snap) — honest until T16's anchored gestures; cost if wrong: none, T16 replaces the path. +- Fix round 1 landed: 8c742d98f2aead4bb650a91d6f9c8f51ced2b354. C1: edge + predicate keyed on stored regime (rootState.camera.base.frame), normalization stays unconditional; once-not-per-frame test mutation-verified (4≠1 on revert). I1: gestureEnd commits only in absolute arm; body-arm drag test pins base by reference, mutation-verified. Full suite 8210 green. Minors: M1 removed (load-bearing post-C1), M2 requestRender added, M4 comments 26→14; M3 = report correction + T16/19 flag (writers can leave base absolute while lastPose is body arm — watchFlyToLonLatSaga window); M5 → /feature-done deletion audit. +- INTERIM (corrected): in-band camera gives NO gesture response until T16 (freeze, no snap) — T16 acceptance item. +- Sonnet scoped re-review dispatched on review-d21ae5ab8..8c742d98f.diff. +- Re-review: ALL ADDRESSED (C1 hand-traced 4/4→1; I1 gated, zoom path already safe via applyWheelZoom null; no leak; tree clean). + +Task 15: complete — commits d21ae5ab8 + 8c742d98f. Fold live, body arms reachable, hysteresis honest, full suite 8210 green. + +## Task 16 — dispatched +- Implementer: OPUS, main tree, BASE 8c742d98f (T15 closed, suite 8210, pushed; main unchanged). Surface controller — first gesture task; carries: MIN_INCIDENCE_COS import-never-restate, controller re-checks incidence (miss⇒trackball vs grazing⇒strafe), driver keys on stored regime not lastPose (T15 M3 window), one-fact/two-fields consistency, magnitude-blind-fixture landmine. +- Handling on DONE: review-package 8c742d98f..HEAD → OPUS reviewer (mode latch/stickiness, ZXZ tilt order vs hand-computed pose, FW-C/FW-D tests, driver gating, no persistent target). After review closes: T17 next; USER PING due at T16/T17 boundary per standing directive (first hands-on feel check on :5173). +- Implementer (opus): DONE_WITH_CONCERNS, commit 3522458364e4c6e23d0c188e9bcfc7e226092aad. Full suite 8224 green (claims pre-task 8218 vs ledgered 8210 — reviewer told to reconcile). All 6 mandated tests mutation-verified per report. Report: task-16-report.md. +- Ruling: concern 1 (zoom-to-cursor centre-anchored at REST; InputStep has no pixel on zoom arm) ACCEPTED AS INTERIM — cursor-anchored during gestures; gap needs recognizer/aggregator surface outside this task; present at T22 feel gate for user ruling. Cost if wrong: one more task's worth of plumbing later. +- Extra surface (reviewer adjudicating): rotateBasisByQuat.ts extraction; cameraRuntime.surface home; engageHR tiebreak reuse; one-frame handoff at driver deactivation (claimed benign); tilt = secondary drag only, ceiling deferred to T17 (T17 carry). +- Prettier drift landmine: `npm run format` touched 654 unrelated files — reverted; future tasks use `npx prettier --write ` only (already standing instruction). +- OPUS reviewer dispatched on review-8c742d98f..352245836.diff (62KB); verdict file task-16-review.md. +- T17 CARRY: tilt ceiling application (implementer deliberately left to T17); zoom-at-rest pixel gap; one-frame handoff note. +- REVIEW VERDICT: Spec FAIL · Quality REVISE. 1C/2I/6m (task-16-review.md). C1: body-arm gesture latches vs camera.base not live pose (grab mid-tween = ordinary path via wireInput pointerdown cancel); fix = route lastPose.current when arm matches, keep base.frame gate. I1: NO collision floor on position-writing modes (only anchoredZoomStep has one; T17 is orientation-only → no owner) — assigned to this fix round via surfaceFloorM. I2: clip row re-wins at pointerup, commit-on-edge overwrites the gesture; comment promises opposite. +- Adjudications: extraction + cameraRuntime.surface home + engageHR tiebreak (1.71≈1.70, unpinned → M1 test) + rewritten-test coverage all UPHELD; concern 4 understated → C1. +- Test-count reconciled: true delta +14 (6 authored + 8 auto-generated via directory-swept it.each); baseline 8210 → 8224 correct. No skips/only anywhere; real tsc green. +- Fix round 1 dispatched (resume implementer): C1 + I1 + I2 + M1, minors at judgment. +- Fix round 1 landed: b5db931ee76677a83a6cc5f75fd28cb0c851d1bc, +5 tests → 8229 full-suite green. C1: lastPose.current routing + per-drain local chaining. I1: flooredPose at single drag exit (radial push). I2: clip===null on surface row + early return under clip; T22 PRODUCT FLAG — grabbing globe during tour beat does nothing until beat ends; hands-win remedy = clipPlayer.stop() at gesture start (user call at feel gate). Minors M1/M2/M3/M6 applied, M4/M5 skipped with recorded reasons. Corrected task-16 test accounting: 8210 → 8229 (+19: 6+8 round 1, +5 round 2). +- Sonnet scoped re-review dispatched on review-352245836..b5db931ee.diff. +- Re-review: ALL ADDRESSED (C1/I1/I2 each independently mutation-verified; both I2 halves load-bearing; scope clean; absolute path untouched). + +Task 16: complete — commits 352245836 + b5db931ee. Surface gestures live (pan/trackball/look/tilt/zoom + floor + clip arbitration), suite 8229 green. T22 product flags: zoom-at-rest centre-anchored; hands don't win during tour beat. + +## Task 17 — dispatched +- Implementer: sonnet, main tree, BASE b5db931ee (T16 closed, suite 8229, pushed; main unchanged). Ceiling enforcement, orientation-only, same file as controller. Carries: own eye-anchored ENU ruling, floor/ceiling composition order (ceiling at FINAL standpoint), no duplicate of T16's engageHR pin test. +- Handling on DONE: review-package b5db931ee..HEAD → sonnet or opus by diff size (small expected → sonnet with geometry lens; escalate if the ENU/composition maths look subtle). USER FEEL-CHECK PING right after T17 closes. + +## Side-track (user-directed, 2026-09-01): layer-split bug + camera debug panel +- USER BUG REPORT while testing T16 gestures live: (a) star layer and galaxy layer move independently when zooming in on Earth; (b) Moon drifts off its orbit trail. Suspected: divergent pose/epoch sources post-fold (M3 window / epoch split). OPUS read-only investigator running → findings to bug-layer-split-investigation.md. NO FIX until user sees the finding. +- USER ASK: camera debug section in debug toolbar (old closed PR #623 had one), accurate for FramedCameraPose. Sonnet implementer in ISOLATED worktree (merge b5db931ee first) → cherry-pick by SHA onto camera-pivot when done. Shows: stored vs rendered arm + mismatch flag, h/R + hysteresis band, epoch delta, anchor/gesture/driver info. +- T17 (ceiling) still running in main tree, unaffected (surfaceController only). +- INVESTIGATION VERDICT (bug-layer-split-investigation.md): defect A = NEAR0 slab basis hard-codes roll 0 (slabs.ts:351) vs COSMO/body slabs honouring cam.roll; toWorldArm is sole roll producer → engage ⇒ stars/MW/trails shear vs galaxies/Moon (symptoms 1+2). Defect B = toWorldArm limb-miss branch re-aims world pose at body CENTRE → 15.8°/883 km jump at h/R 2.665 (symptom 3, feeds A). Symptom 4 (zoom asymmetry) = BY-DESIGN hysteresis, disproves threshold mixup; regimeArmFor correct. Corrections: earthSubCamera = earth-tile VT readout not RTC; debug dump is a faithful reader. FIXES PROPOSED TO USER, awaiting go (A trivial; B = design care in conversion pair). +- Camera debug panel (user ask): DONE in isolated worktree, cherry-picked as 4b4? — commit 23ccfe01a onto camera-pivot, typecheck + 15 focused tests green, pushed. Shows stored-vs-rendered arm MISMATCH flag, h/R vs 1.7/3.4, epoch delta, anchor/driver. Gesture-mode latch OMITTED (surfaceController private closure, T17 owns file) — T17/T18 candidate: add read accessor. Agent worktree removed. +- T17 implementer: DONE, commit ea0822957f710c153b7e45309e67d691fc43497a. Full suite 8247 green. Ceiling after floor (final standpoint), own eye-anchored ENU, true no-op below ceiling (roll preserved — rebuild-always broke 2 tests, reverted). USER tilt-inversion folded in (negate at tilt use site only; 2 direction tests updated; re-pick test now computes anchor via cursorRayBodyLocal/raySphereRoots = potential call-vs-call, review lens #4). Report: task-17-report.md. +- OPUS reviewer dispatched on review-b5db931ee..ea0822957.diff (2 commits — includes debug-panel 7d7ef9b36 for defect-scan only). Verdict → task-17-review.md. +- Pushed through ea0822957. Awaiting user go on layer-split fixes A (NEAR0 roll) + B (limb-miss continuity). +- REVIEW VERDICT: Spec PASS · Quality REVISE. 1C/4I/6m (task-17-review.md). C1: heading-through-clamp unpinned (heading→0 survives 1236 tests; all fixtures polar/heading-0). I1: ENU derivation dead under test (east hardcode survives 1090). I2: roll snaps 45°→0 in one tick at ceiling crossing — deviation stands, record header line + T22 FEEL FLAG. I3: ENU/heading-tilt duplicated verbatim (2× NADIR_ESCAPE_SIN) → extract utils/camera/headingTiltAt.ts. I4: debug panel epoch eps wrong currency (wall-clock vs sim days — permanently "stalled" at fast rates). Point 3 (tilt sign) + point 4 (re-pick test NOT blind) verified clean. hOverR export behaviour-neutral. +- Fix round 1 dispatched (resume implementer): C1+I1 via off-pole non-zero-heading fixture, I3 extraction, I2 header+flag, I4 currency fix, minors at judgment. +- T22 FEEL FLAGS now: zoom-at-rest centre anchor · hands-vs-tour-beat · zoom asymmetry in band (by-design check) · roll snap at ceiling crossing · tilt-above-ceiling standpoint slide (review m). +- Fix round 1 landed: 0579c5af3720290192d4484fab9de0dc1b87c153, suite 8255 green (real tsc too). C1+I1 via I3 extraction (utils/camera/headingTiltAt.ts, single NADIR_ESCAPE_SIN home) + off-pole non-zero-heading test, both mutants killed. I2 = header line + T22 flag. I4 = epoch tolerance derived from deriveSimDays/TimeState, fast-rate test mutation-verified. M1/M2/M3 applied (M2 caught its own fixture bug), M4/M5/M6 skipped per review scoping. Sonnet re-review dispatched on review-ea0822957..0579c5af3.diff. +- Re-review: ALL ADDRESSED (C1 mutant re-killed independently; I4 discriminator confirmed vs old constant; scope clean). + +Task 17: complete — commits ea0822957 + 0579c5af3. Ceiling enforcement live, tilt direction per user, headingTiltAt extracted, debug-panel epoch fixed. Suite 8255 green. +- MAIN TREE QUIET. USER FEEL-CHECK WINDOW OPEN (the ledgered T16/T17 boundary ping). Holding T18 dispatch until user finishes hands-on pass + rules on layer-split fixes A/B (both touch the render path under test). +- USER: shear persists (expected — fix A was held for go); "have a fable agent look at it" = go for fix A. FABLE FORK dispatched (user-directed exception to never-Fable rule): verify defect A at HEAD, thread roll into NEAR0 basis, audit ALL imagePlaneBasis call sites for roll-blindness, mutation-verified test, full-suite gate, commit (no push — controller gates). Defect B still NO CODE (re-confirm diagnosis only). Handling on DONE: verify diff, push, user re-tests shear; B remains awaiting explicit go. +- FIX A LANDED + PUSHED: a676cdc97e8e30cebf571d24d1fe6d77c48a2ba7 (NEAR0 slab honours cam.roll; 11 call sites audited: 1 fixed, 7 correct, 3 left with rationale; roll-parity test mutation-verified; suite 8256). Defect B re-CONFIRMED live at poseFrameConversion.ts:110-115 — STILL awaiting explicit user go ("roll permanent after disengage" residual also waits on B). +- USER RULINGS (zoom feel, supersede T22 flags 1+3): at-rest wheel zoom anchors at CURSOR surface pick (pixel plumbing through recognizer/aggregator/InputStep); zoom-out + miss anchor moves body centre → SUB-EYE surface point (FW-H preserved: cursor never anchors zoom-out; recession stays centre-directed). Spec §6b amendment sanctioned. OPUS implementer dispatched (zoom-feel wave, BASE a676cdc97) → report zoom-feel-wave-report.md. Handling on DONE: opus review (FW-H pin, 260-notch round trip, reciprocity test honesty), then push + user re-test. +- USER GO: defect B pop fix DEFINITELY wanted. USER ASK: research Cesium/Google Earth implementations first (limb-miss target fallback + zoom anchoring) — OPUS research agent dispatched → prior-art-cesium-ge.md. B implementer dispatches AFTER zoom wave lands + research in (serial tree), design informed by prior art. +- User symptom during zoom-wave HMR churn: Earth drifts off view-centre on zoom-out — acceptance test relayed to zoom-wave implementer (angular invariance of body centre across zoom-out notches; anchor must sit on the eye radial) + probe-file cleanup ordered. +- USER RULING (zoom wave fix 3): heading converges to NORTH-UP during zoom as a smooth per-notch blend (GE behaviour), never a snap. Constraints relayed: zoom-writes only (drags/tilt/look untouched — T17 C1 heading-preservation test stays true, blend ≠ clamp), single basis rebuild composed with ceiling enforcement, shortest-arc across ±π wrap, blend fraction = named feel-open constant. Research agent asked to confirm GE/Cesium blend shape (Q3). +- USER RULING (zoom wave fix 4, GM base-pose return): zoom-out blends view back to canonical framing (body centred + north up + top-down) — aim blend per notch, monotone, capped, no snap; zoom-IN does NOT re-centre (cursor point pixel-locked, only north blend). SUPERSEDES the angular-invariance acceptance → becomes angular-CONVERGENCE test. Researcher Q4 added (GM/GE re-centre mechanics, Cesium comparison, rotation composition order for pixel-lock + north blend). +- Zoom wave now carries 4 rulings: cursor zoom-in / sub-eye reciprocal zoom-out / north-up blend / base-pose return. All blend fractions = named feel-open constants for T22. +- PRIOR ART IN (prior-art-cesium-ge.md, Q1-Q4 with quoted Cesium source): Q1 Cesium never re-aims at centre (no persistent target; grazingAltitudeLocation = continuous nearest-point fallback; miss changes gesture MODE latched at rest). Q2 zoom rate ∝ (distanceMeasure − minHeight) both ways = log-symmetric; Cesium does NOT solve repelling pivot (bugs #4913/#2968/#4639) — our sub-eye fallback strictly better. Q3/Q4 north-up + base pose = ZOOM-KEYED CLAMPS not blends (emergent convergence, no poppable state) — same architecture as T17 tilt ceiling; pixel-lock composition rule: rotate about ANCHOR axis (commutes with zoom along eye→anchor), never about eye. +- POP FIX DESIGN (from Q1 recommendation): keep target ON the forward ray both branches — rangeM = hit ? nearRoot : max(t*, altitudeM), t* = −dot(eyeLocal, forwardLocal); C⁰ through limb (nearRoot = t* − sqrt(disc)); viewDir stays forwardLocal ⇒ jump zero by construction. Use this for the B implementer brief. +- Clamp-over-blend refinement relayed to zoom-wave implementer (maxHeadingRad(h/R) + aim-offset ceiling in the same composed rebuild as tilt ceiling; ruling unless prohibitively late — implementer reports which shipped). +- ZOOM WAVE LANDED: 87f9f7ed9a5619640562d7c651c64c3e7cff2568 (fixes 1+2: cursorPx plumbing; sub-eye anchor, radius=bodyRadiusM — floor radius would deadlock an eye on the descent floor) + 0e6f0177fc2bbe44cf48599c8816a1d95d16b636 (fixes 3+4 as zoom-keyed CEILINGS per prior art; deviation: heading clamp recession-only, evidence 0.37 rad anchor drift on clamped dive). Suite 8265 (+9), 12 mutants killed. NOT PUSHED (review first). Report: zoom-feel-wave-report.md. +- Key implementer finding: Earth-drift-on-zoom-out NOT producible by either anchor → defect B (confirmed candidate) or world-arm zoom above band. B queued next with ray-continuous design. +- OPUS reviewer dispatched on review-a676cdc97..0e6f0177f.diff → zoom-feel-wave-review.md. Handling: fix rounds as needed → push → dispatch B implementer (poseFrameConversion toWorldArm, rangeM = hit ? nearRoot : max(t*, altitudeM)) → review → push → USER SETTLE PING. +- ZOOM WAVE REVIEW: Rulings PASS · Quality REVISE. 0C/3I/6m (zoom-feel-wave-review.md). I1: clamp trigger widened to h/R 0.03, 90° single-tick spin measured, §12-R4b "cannot pop" false → DELTA-ROTATION clamp form (also retires T17 roll-snap flag). I2: approach has no heading authority (82° north-off after off-centre dive) — violates user's "zooming in, north always up" → implement Q4(c) anchor-axis rotation on approach (gesture-authored write ≠ clamp; pixel-locked, altitude-preserving). I3: ascent figure corrected ~123 notches (report only). Recession-only clamp form UPHELD (0.37246 rad drift reproduced). Point-8: slow ascent + retreat north-up onset = T22 flags; roll-snap trigger = fixed via I1. +- Fix round 1 dispatched (resume implementer): I1 delta-clamp + spec sentence, I2 anchor-axis approach blend, I3 report fix. Ruling: I2 decided by the user's existing verbal ruling, not deferred — cost if wrong: one revert of a bounded feature. +- Zoom-wave fix round 1 landed: c4f82fc4b371017fcdf9494b24e1316bbb121fc3, suite 8268 (+3), 6 new mutants killed, M3/M12 re-verified. I1: delta-rotation enforcement (pop 1.5704→0.0005 rad; RETIRES T17 roll-snap flag; spec §6/§12-R4b corrected). I2: anchor-axis approach blend clamp(0.25·residual, ±0.1) — dive 2.39→0.011 rad north-up, anchor drift 0.0; residual = SCREEN-UP azimuth (forward's fails polar case, test-pinned); recession heading limit load-bearing only h/R≳1.5 (M12 fixture moved). I3 report fixed. m1/m2/m5/m6 applied, m3/m4 skipped per scoping. NEW WATCH: standpoint walks around anchor on approach (exact but check in app); two norths (heading vs screen-up — same when roll-free); cap constant = the no-pop property. +- Sonnet re-review dispatched on review-0e6f0177f..c4f82fc4b.diff. Then: push wave (3 commits) → dispatch B implementer → review → push → USER SETTLE PING. +- Re-review: ALL ADDRESSED (delta form verified no-reconstruction-left; anchor-axis geometry verified; M12 load-bearing at new fixture; T17 C1 tests green). OPEN (T22/pop-fix follow-up): two-norths divergence real+reachable — rolled retreat converges headingRad→0 but not screen-up. + +ZOOM WAVE COMPLETE: 87f9f7ed9 + 0e6f0177f + c4f82fc4b. Pushing; dispatching B (pop fix) implementer. +- ZOOM WAVE PUSHED (through c4f82fc4b). B (pop fix) OPUS implementer dispatched, BASE c4f82fc4b: ray-continuous target (rangeM = hit ? nearRoot : max(t*, floor)), consumer audit mandated, 15.8°/883km regression fixture, limb-sweep continuity test, disengage-path analysis, roll-residual verdict. Handling on DONE: opus review → fix rounds → push → USER SETTLE PING (contents: roll shear fixed, zoom model per 4 rulings, pop fixed, what to test, feel constants list). +- POP FIX LANDED: cc9ac86271e9e494da9cd8584214de4601babd34 (rangeM = max(hit ? nearRoot : t*, altitudeFloor); direction jump zero by construction; suite 8270; restore-mutant fails 3 tests at exactly 0.274 rad/887,668 m). Roll DISCONTINUITY retired (0.3 rad held 12 digits across sweep); permanent-roll two-norths gap remains BY DESIGN. Corroboration: tilt ceiling < tangency angle above h/R 2.79 ⇒ limb reachable only below — user's 2.665 inside the window. Disengage-with-ray-off-body reachable via clip/tween/flyTo (ceiling binds driven writes only) — fix makes those continuous. +- ADJACENT (await review classification): applyInputToCamera.ts:56 world-arm pan roll=0 (one-token, defect-A shape, input feel → visual check); slabs.ts:337 distance−pivotRadius double-subtraction (now uniform 16,081 km, unrepaired). +- OPUS reviewer dispatched on review-c4f82fc4b..cc9ac8627.diff → pop-fix-review.md. On close: push + USER SETTLE PING. +- POP-FIX REVIEW: Correctness PASS · Quality REVISE (0C/2I/3m, pop-fix-review.md). Verified hard: only rangeM branches; roll 0.3 identical 12 digits; mutant re-killed at 0.2738 rad/887,668 m; √ε assertion kills step independently; floor single-sourced + inactive at limb 32% margin. I1: fix EXPOSED a new reachable state — off-ray disengage (absolute arm) + wheel notch → zoomedDistance h≤0 degenerate branch → clampDistance snaps eye out ~R. RULING: overrode reviewer's report-only scoping — code fix round dispatched (continuous first-notch behaviour + regression test); cost if wrong: one small guard. I2: double-subtraction = TWO sites (slabs.ts:338 + scaleBar.ts:103, fails closed, negative below h/R≈1) — single backlog item, design-bearing (who owns cam.distance semantics post-body-arm, 3 consumers re-derive). +- SETTLE-PING QUEUE: pan-roll one-token (applyInputToCamera.ts:56) = user ruling; double-subtraction backlog item = user ask (adjacent-findings convention: offer pick-up, don't silently backlog). +- Pop-fix fix round 1 landed: 35884c6dc4e9792a766450312bc8931f26002978, suite 8271, both mutants verified. I1 guard = min(floorMpc, distance) in zoomedDistance h≤0 (never ratchet outward; owner = cam.distance un-braiding backlog, now THREE sites). IMPLEMENTER REFUTED reviewer's I1 mechanism (altitude floor bounds distance ≥ |eye|−R; same-body tilt route impossible) but found real PRE-EXISTING cross-body route (Moon disengage 3.4·R_moon < Earth pivot floor). Sonnet re-review adjudicating the refutation + verifying guard/mutants. On ALL ADDRESSED: push + SETTLE PING. +- Re-review: ALL ADDRESSED; refutation ruled FOR implementer (same-body route impossible; cross-body Moon/Earth route real + pre-existing). POP FIX COMPLETE (cc9ac8627 + 35884c6dc), typecheck green, PUSHED through 35884c6dc. SETTLE PING SENT. Remaining user items: pan-roll one-token ruling · double-subtraction backlog offer (3 sites) · T22 feel constants. Next: resume T18 after user pass. +- USER: pop STILL present on zoom-out at HEAD 35884c6dc → FORK 2 investigating (candidates: disengage flip edge, zoom-law handoff, aim-clamp cutoff, pan-basis roll-0 snap) → bug-zoomout-pop-2.md. USER ASK: thorough Fable review → FABLE FORK reviewing whole engaged-camera stack (3 lenses from defect history + edges + one-fact-two-homes + simplicity) → fable-deep-review.md. USER FEEL: re-orientation ranges asymmetric in vs out (structural: anchor-axis blend any-altitude vs ceilings h/R≲2.2) — relayed to deep review as design question (one authority curve?). +- USER OBSERVATIONS (north cluster): orbit drag loses north (holonomy roll) · zoom-in restores it (anchor-axis blend working) · RULING: north-up correction must be SMOOTH continuous convergence (same character as backing-off), never a trigger/snap. Design direction under Fable review: north-locked engaged camera (pan = parallel transport in ENU field, roll never enters; tilt = only off-vertical freedom; smooth decay for pre-existing roll; one altitude-keyed orientation authority both zoom directions). All relayed to deep-review fork. + +## Compact checkpoint 4 (2026-09-01, post pop-2 trace) +- BRANCH: camera-pivot @ 35884c6dc (pushed, suite 8271). Plan T2-T17 complete; T18-T22 PAUSED for feel side-quest; briefs beyond 17 not yet generated. +- POP 2 TRACED (bug-zoomout-pop-2.md, high confidence): fold's disengage commit emits ray-target pose (distance=altitude); next at-rest frame applyFocusedBodyPivot.ts:57-63 retargets to body centre keeping yaw/pitch/distance → eye teleports 1 body radius inward. FIX SKETCH: eye-preserving retarget to centre at fold's disengage branch; TEST: two-frame eye-continuity across disengage with pin active. NOT yet dispatched. +- IN FLIGHT: FABLE deep-review fork → fable-deep-review.md. Carries: 3 defect lenses + edges + one-fact-two-homes + north-locked architecture evaluation (pan = parallel transport, roll never enters, tilt only freedom, smooth decay for existing roll) + one altitude-keyed orientation authority + BINDING RULING: all north correction smooth/continuous, never triggered. Do NOT duplicate its work. +- HANDLING when deep review lands: compose ONE fix wave = pop-2 eye-preserving retarget + review's Criticals/Importants + north-lock architecture IF review upholds it (else present to user). Opus implementer, main tree (free), full-suite gate, opus review, push, USER PING. +- USER ITEMS still open: pan-roll one-token ruling (applyInputToCamera.ts:56) · cam.distance un-braiding backlog offer (3 sites) · T22 feel constants. +- Watch: probe-clamp.ts diagnostic = stale editor noise from a fork's restored probe (tree clean at last check; verify `git status --porcelain` before next commit). +- USER RULING (5th, approach-nadir): zoom-in converges smoothly to looking straight down at the dived-on point (tilt→nadir), GM character — today tilt ceiling binds recession only. Relayed to deep review with the pixel-lock-vs-tilt-convergence tension to resolve (both can't be exact; recommend which yields). Full orientation target = north-up + nadir, one altitude-keyed authority, both directions, no triggers. + +## Fix wave (post deep review) — 2026-09-01 +- Fable deep review LANDED → fable-deep-review.md: 1 Critical (C1 one-tick 153° ceiling snap, probe-measured), 4 Important (I1 dual return mechanisms, I2 pan holonomy roll, I3 45° azimuth-source flip, I4 dual orientation writers), 5 minors. R1 (north-locked engaged camera: gestures never create roll, ONE altitude-keyed orientation target heading-north/tilt-nadir, ONE bounded decay ORIENT_SHARE/ORIENT_CAP both zoom directions, tilt pivots about anchor → pixel-lock preserved) UPHELD — retires C1+I1–I4 and satisfies all 5 user rulings. +- Ruling: fix wave = pop-2 disengage retarget (bug-zoomout-pop-2.md sketch) + R1 in full + m2 large-residual test. Deferred: m1 pinch midpoint (T22), m3 (T19), m4 (pre-existing), cam.distance un-braid (backlog). +- Fix-wave implementer DISPATCHED (opus, this worktree). BASE=35884c6dc. Brief: fix-wave-brief.md. Report → fix-wave-report.md. +- Handling when report lands: review-package BASE..HEAD → opus reviewer (fix-wave-review.md) → fix rounds as needed → full suite (baseline 8271) → push to #647 → USER PING (cover: pop-2 fix, north-lock/nadir behaviour change, trackball deleted, open items: pan-roll one-token ruling, cam.distance backlog offer, T22 constants tuning). Then board refresh; plan T18–T22 resume after user feel pass. +- 2026-09-02 USER: "build it with fable" — opus implementer STOPPED mid-R1; its pop-2 commit 87d24cfa8 + uncommitted R1 partials DISCARDED (reset --hard 35884c6dc; commit recoverable via reflog). Fresh FABLE implementer dispatched, same brief (fix-wave-brief.md), BASE unchanged 35884c6dc. Handling plan unchanged (opus review → fix rounds → suite 8271 → push → user ping). +- 2026-09-02 USER: "so much code, still doesn't work, why can't it be simplified" → adversarial simplicity audit DISPATCHED (fable, read-only, isolated worktree snapshot; greenfield-justify protocol vs docs/superpowers/conventions/intent.md + spec). Report → adversarial-simplicity-audit.md. Handling: fold its verdict into the fix-wave adjudication — if it indicts the architecture beyond R1, present options to user BEFORE further waves. +- Adversarial simplicity audit LANDED → adversarial-simplicity-audit.md (recovered via re-emit after isolation worktree auto-clean ate the original). Verdict: ~18,100 LOC (7,250 src/10,900 test); 60-65% essential, ~10% accidental mechanisms, ~20% comments (~700 over budget), 5% debug. Diagnosis: N competing correctors instead of one constructor + live pose has 4+ homes synced by edge protocol → fixes land as new mechanisms; defect rate = mechanism count not LOC. R1 fixes half; does NOT touch pose-home duality/commit-on-edge compensators/60Hz body-arm dispatch/follow fragments — next defect family predicted there. Candidates: (1) dead reanchoredPose −194, (2) dead surfaceReadoutOf −281, (3) world-arm-returns-pose + ONE pose home −540/−4 mechanisms, (4) comment pass −600-700, tween self-commit −190, strategic body-relative focus −750 high-risk gated. Net near-term ≈ −1,800 (~10%). Relayed to user, recommended candidates 1-3 as follow-up wave post fix-wave. AWAITING user ruling on candidates (leanness convention — deletions are user's call). +- 2026-09-02 USER RULING: audit candidates 1-3 APPROVED — execute immediately after the fix-wave implementer finishes. Sequencing: fix-wave report → dispatch simplification wave (1: delete reanchoredPose, 2: delete surfaceReadoutOf+type, 3: world-arm-returns-pose + ONE live-pose home) as its own implementer (fable) with adversarial-simplicity-audit.md as authority → then ONE review over both waves → full suite → push → user ping. Candidate 4+ (comment pass, tween self-commit, body-relative focus) NOT approved yet. +- Fix wave DONE_WITH_CONCERNS (fable): 0165e2e3f pop-2 disengage-convention retarget + eye-continuity test; 89edd0988 R1 full (ORIENT_SHARE/ORIENT_CAP one authority both directions, anchor-pivoted dives/eye-pivoted recessions, pan north-locked per-step capped transport, DELETED trackball/clampHeading/northedOverAnchor/engageHR-tiebreak, new util cappedRotationToward, m2 test). Suite 8279 green, 9 mutations verified. Concerns for user ping + review: (1) disengage tilt asymptotic ~0 not exact — violent recession can carry ≤0.5 rad bounded one-frame re-aim, T22 lever = cap scales with notch; (2) pan north-lock = meridian-convergence corrective, degenerate near poles (feel gate); (3) dives re-centre tilt toward nadir per notch — old "never for centring" test consciously deleted. Report: fix-wave-report.md. +- Simplification wave DISPATCHED per user ruling (fable, BASE=89edd0988, brief simplification-wave-brief.md, re-verify audit claims post-R1). Report → simplification-wave-report.md. Then: ONE opus review over BOTH waves (BASE 35884c6dc..HEAD), fix rounds, suite (baseline 8279), push #647, USER PING (incl. fix-wave concerns 1-3). +- 2026-09-02 USER RULING 6 (live test of fix wave): zoom-out must NOT converge tilt toward nadir — GM/Cesium lerp smoothly back to the off-body pose over the recession range. Diagnosis confirmed at surfaceController.ts canonicalledPose: tilt residual measured against nadir both directions. Fix ruled: recession tilt residual = max(0, tilt − maxTiltRad(h/R)) (converge into the altitude-keyed band; ceiling→0 at disengage makes the return a distributed lerp); dive keeps nadir target (ruling 5). Heading/roll decay unchanged. QUEUED as fix round for the fix-wave implementer AFTER the simplification wave finishes (shared worktree, sequential). Test to add: below-ceiling tilted pose + one recession notch ⇒ tilt unchanged; above-ceiling ⇒ capped decay toward ceiling only. +- 2026-09-02 USER RULING 7 (same test pass): zoom-out from centre feels weird — recession must be SYMMETRICAL with the cursor-anchored zoom-in (GM: wheel zoom pins the cursor point both directions). Fix: recession uses the same cursor-anchored zoomStep/anchor pick as dive (drop the anchorM-null/sub-eye radial special case where a cursor hit exists; sub-eye stays the miss fallback). Interacts with ruling 6: recession orientation settle then pivots about the cursor anchor like the dive does, targets = into-the-band tilt + north heading. Both rulings = ONE fix round, queued behind simplification wave. +- 2026-09-02 USER RULING 8 (same test pass): the tilt from the main camera orientation frame to Earth's orientation frame (equatorial) currently TRIGGERS at a height — must blend smoothly over an altitude band, with zoom (user believed this was already implemented). Fix-round implementer must first locate the triggering mechanism (world-arm aim clamp / up-frame alignment on approach — check applyWheelZoom aim path, poseFrameConversion entry, any engage-keyed up switch), then key it to an altitude band with the same bounded-decay discipline as the orientation authority. Rulings 6+7+8 = ONE fix round, queued behind simplification wave. +- USER clarification on ruling 8: "same lerp" — the frame transition uses the SAME lerp as the other orientation settles (the one authority's bounded decay / band), not a parallel mechanism. One curve, one decay, everywhere. +- Simplification wave LANDED (fable): 475c12559 reanchoredPose −194; bd30fdfe8 surfaceReadoutOf+type −281; be218c930 one live-pose home (pure fold → cameraRuntime.lastPose, store commits on gesture-end/wheel-notch/regime edges; seedCameraFromBase, per-step body-arm dispatch, commit-before-endDrag asymmetry, surface driver row, accumulateFollowPan/lastPanTarget all DELETED, 6 mechanisms retired) −234. Net −709 (src −246/tests −463). Suite 8249 green (−31 with features, +1 strafe-fold), typecheck:fast verified clean by controller (editor diagnostics were stale). Concerns: c3 actual −234 vs audit ≈−540 (R1 pre-consumed mass); mid-gesture camera.base in body arm now updates at release (URL-hash/debug readers one gesture later — feel-gate item); m3 fly-to window remains (T19); state.cam survives as boot proxy (future retire candidate). Report: simplification-wave-report.md. +- Fix round rulings 6+7+8 DISPATCHED: fix-wave implementer RESUMED with brief fix-round-rulings-678.md @ BASE be218c930, baseline 8249. Then: ONE opus review 35884c6dc..HEAD (both waves + fix round), suite, push #647, USER PING. +- Fix round 6+7+8 DONE_WITH_CONCERNS (fable): b4c166847 rulings 6+7 fused (recession tilt residual = excess over ceiling; anchoredZoomStep cursor-anchors BOTH directions, FW-H carve-out overruled); 4a2e4628e ruling 8 (trigger was the engage edge itself — followBody dropped roll; new frameAlignedRoll extends the ONE authority to at-rest world-arm notches, decay pair homed in data/camera/orientDecay.ts, rollFromScreenUp extracted, follow pose lerps base.roll). Suite 8266 green, +7 tests all mutation-verified, typecheck verified clean by controller. Concerns: (1) ruling-7 anchor-pivot-recession prediction REFUTED by measurement (self-cancel ~h/(R+h), 40-notch stall at 1.46 rad) — shipped position-anchored both ways + orientation eye-pivoted on recession w/ toNadir discriminant, review to re-adjudicate; (2) convergence RATE steps at engage edge (≈0.5→1.0) — left alone, review judges; (3) alignment rides at-rest wheel notches only, no pinch path; followBody writes fresh base identity per notch; (4) baseline discrepancy 8249 vs 8259 pre-round. +- COMBINED OPUS REVIEW DISPATCHED: 35884c6dc..4a2e4628e (7 commits, all three units), package review-35884c6dc..4a2e4628e.diff, adjudications a-d requested → combined-review.md. Then: fix rounds if REVISE → push #647 → USER PING (ping covers rulings 6-8 behaviour, simplification timing change, fix-wave concerns). +- Combined review LANDED: REVISE/REVISE (rulings C1/I2/m2; quality I2/m5) → combined-review.md. C1: below-ceiling exemption + fixed cap ⇒ recession reaches disengage ~45-91° off nadir ⇒ one-frame re-aim (pop class at disengage edge). Adjudications: (a) self-cancel measurement CORRECT (φ·R/(R+h) re-derived), toNadir shape right but ruling-6 intent unmet; (b) engage rate step acceptable; (c) camera.base clear BUT real regression found: at-rest body-arm wheel notch swallowed when drag starts same drain; (d) 8259 = accounting error (+7 explicit +8 it.each, 2 residual). Gates independently re-run green (8266/1187). +- Ruling: recession tilt RIDES THE CEILING (wall, min(tilt, maxTiltRad) per notch) — distributed with zoom progress, tilt structurally 0 at disengage; capped decay only for non-zoom-authored excess; below-ceiling still untouched. FIX ROUND 2 dispatched to fix-wave implementer (resume #2): all Criticals+Importants, swallowed-notch regression, minors trivial-or-park. Baseline 8266. Then scoped re-review (resume opus reviewer) → push #647 → USER PING. +- Fix round 2 DONE (fable): b693e4a43 C1 wall (ceiling-ride per notch, inheritedTiltRad decay-only, crossing test, dead anchor param deleted); 77a3baa9a I1 register-write both arms + swallow regression tests; 0e20a17fa I3 spec sync (10 edits) + M2/M4/M5/M6/M7. Suite 8273 green. Dispositions: all C/I/M fixed except I2 disclosed-no-code (engage rate step, feel gate) and M3 PARKED (sky-dive settle asymmetry — needs user feel ruling). Concerns: fast zoom-out re-levels fast (proportionate, GM-correct — feel-gate watch); inherited clip/tour excess can cross disengage with bounded remainder (comment names it). +- Scoped re-review DISPATCHED (opus reviewer resumed) on 4a2e4628e..0e20a17fa. On APPROVED: push #647 → USER PING (rulings 6-8 + wall behaviour + I2/M3 open feel items + simplification timing change). +- Scoped re-review APPROVED (independent probe re-run: tilt 0.0° at crossing across 5 rates × 4 tilts; count model confirmed). Non-blocking N1 for feel gate: wall = commanded per-notch turn, max 16° at default notch (h/R≈2.2), 97° at MAX_FACTOR clamp — right trade, stated property. +- origin/main MERGED at boundary (007314379, 5 commits incl. #651 polyphorm workbench): one integration fix folded into the merge commit (createViewportInput wheel event now spreads xPx/yPx our branch added). typecheck clean, suite 8349 green. PUSHED to #647 @ 007314379. USER PING issued. Open feel-gate items: N1 wall turn rate, I2 engage rate step, M3 sky-dive asymmetry, pan meridian-convergence, simplification base-at-release timing, disengage inherited-excess remainder. Board refresh pending. +- 2026-09-02 USER RULING 9 (feel gate): roll toward the globally defined up axis (default ecliptic, must work with ANY configured axis) does NOT fully return on zoom-out — must follow the same lerp as the other parameters. Diagnosis (controller): frameAlignedRoll's correction is scaled by the band authority which → 0 above the band, so the decay stalls with residual left (same class as the tilt C1). Ruling: the altitude curve defines the roll TARGET (blend body-spin-axis ↔ global scene up across the band); zoom notches RIDE the target's change (wall) + capped decay for non-zoom excess — the tilt discipline exactly; global up read from the configured scene up axis, never hardcoded. Fix round 3 dispatched to fix-wave implementer; scoped re-review after; then push + ping. +- USER (mid round 3): "we end up in a completely different up vector" — possibly HMR-transient (implementer mid-edit on the live-served tree) but relayed to round 3 as candidate real symptom: check sign/hemisphere flip of the projected roll reference near the axis, quat double-cover, followBody fresh-base path; fixture = recede past the reference axis, converged screen-up ≈ configured global up (not negation/perpendicular). Implementer to state whether approved HEAD could exhibit it. +- Fix round 3 DONE (fable): 503efdbb9 bandRollTarget — curve-keyed screen-up target (RAW spin-axis projection at band floor ↔ frameUp(upBasis) at band top, verified routed not hardcoded), at-rest notches ride the target delta in full, capped decay only for non-zoom deviation; drainInput feeds pre+post poses. Suite 8351 green (+2 exact), 3 mutation kills. KEY: user's wrong-up-vector was REAL at approved HEAD (normalized pole projection unstable 2° off axis → −1.49 rad chase + stall −0.259 rad frozen) — both now unrepresentable. Concerns: in-band ~18° off scene-up near axis at h/R 1 = the honest blend (feel-gate); hardcoded-frame mutation only observable in-band; followBody roll lerp long-way-across-±π pre-existing noted. +- Scoped re-review 007314379..503efdbb9 DISPATCHED (opus resumed, 5 verification points incl. raw-projection continuity re-derivation). On APPROVED: push #647 → USER PING. +- Round-3 re-review APPROVED (analytic band-top completeness any frame; near-axis step OLD 164° → NEW 0.44° measured, user symptom reproduced independently; no-stall verified both paths — ride carries zero deviation by construction, genuine deviation 0.259→0.026 rad at band top vs 100% freeze; in-band 18.4° = correct ruled blend with monotone ladder; drainInput/register/followBody all clear; roll ride 2° default/11.4° max — no feel-gate disclosure needed). PUSHED #647 @ 503efdbb9. USER PING issued (ruling 9 resolved). NOTE for tests: `equatorial` frame is degenerate for Earth roll fixtures (its up IS the spin axis) — correct geometry, tests avoid it. +- 2026-09-02 USER: ruling 9 REOPENED after live test — zoom-out does NOT return to configured frame (equatorial persists); zoom-in first rolls to configured up then back to equatorial (= band blend working, localizes defect to zoom-out return not firing). Controller hypothesis: follow driver (Earth FOCUSED = default) gets (live,live) pre/post poses in round 3's drainInput feed ⇒ zero target delta ⇒ ride dead on the follow path; round-3 fixtures tested the resting driver only; re-review's "notch moves no distance there" claim suspect. FIX ROUND 4 dispatched (same implementer): PART A debug-panel expansion first (full orientation pipeline, copy-all button, user-requested) then PART B probe follow-path zoom-out + fix ride on every driven path + focused regression test. Baseline 8351. +- Fix round 4 DONE (fable): 23a2b31aa debug-panel camera section (full orientation pipeline + copy-all, live-path helpers no mirror); 21a460425 ruling-9 root cause CONFIRMED = controller's suspect (follow path pre/post identical ⇒ ride dead in default config; fix rides followDistanceTarget delta; gesture-held wheel — third driven path with NO alignment — also wired). Probe: 0.0478 rad frozen before, <1e-4 after. Suite 8354 (+3 exact), typecheck verified. Concerns: roll leads distance ease ~600ms (feel gate; riding eased altitude = the freeze); fixture simulates saturated ease (wall-clock runFrame sim = next step if residual persists); debug distance rows read rendered pose which lags target during ease. Scoped re-review DISPATCHED (told to re-examine its own round-3 blessing of the dead path). On APPROVED: push → USER PING. +- Round-4 re-review APPROVED (reviewer re-derived on own harness, admitted round-3 error class: judged instantaneous frame vs commanded altitude; residual got WORSE with faster zoom — 7.1° frozen at factor 1.5, now ~e-17 across rates; follow capture race-free; gesture-wheel double-apply structurally impossible; roll-lead worst mid-ease gap 0.23° only in 600ms after focus change — acceptable). PUSHED #647 @ 21a460425. PARKED one-line minors for next commit wave: R4-1 debug panel re-derives authority (would silently keep old formula if I2 lever changes — read snapshot's authority instead); R4-2 third nearest-body scan copy (fold into nearestBodyHR). FLAGGED R4-3 (not a defect): animated approach (focus tween + follow ease) enters band with no alignment — fails safe (arrives at scene up) but is the one uncovered path; first suspect on any third reopen. USER PING issued. +- 2026-09-02 USER: "the fix made it worse" (round 4, 21a460425). Asked user for copy-all dumps (mid-zoom + at-rest) + symptom description. Implementer dispatched to build wall-clock-faithful runFrame sim (ease UNSATURATED between notches) testing hypotheses: (a) roll rides commanded target while rendered altitude lags ease (bursty lead), (b) overlapping capture windows double-count, (c) gesture-wheel fold on trackpad duringGesture, (d) ride+crumbs both applying ⇒ overshoot. NO code changes until sim + user dump adjudicate. Round 5 investigation → fix-wave-report.md. +- Round 5 sim REPRODUCED the real mechanism (hypothesis e, off-list): recession stays body-armed through the whole 1.7-3.4 band (hysteresis), engaged settle norths to Earth ENU only (scene frame ignored), disengage bakes −0.2592 rad (14.85°) at h/R 3.68 — ABOVE the world ride's band top where authority=0 + above-band guard ⇒ frozen forever. Round 4 "worse" = inbound ride now fully tracks (deeper twist in), outbound freeze unchanged. Zoom-in symptom also reproduced by mechanism. Hypotheses a/b/c/d all cleanly refuted with traces. +- RULING (ruling 8 applied where missed): the frame blend belongs in the ENGAGED settle — canonicalledPose's north/up reference = band blend (Earth ENU at floor → scene frame up at disengageHR), same authority curve + ride discipline; disengage bakes ≈0 scene roll by construction; world-arm ride unchanged for never-engaged paths; band = the hysteresis window, one home. Fix round 5 dispatched: round-trip sim test <1e-4 at fast cadence, S2 control stays 0, no-double-authority pin, __round5probe cleanup. Baseline 8354. +- Fix round 5 DONE (fable): 846450405 — bodyUpWeight one-home band blend (pole at engageHR → scene up at disengageHR, hysteresis window IS the band), sceneUpLocal threaded body-fixed through SurfaceController.apply, engaged recession azimuth gets ride discipline. Sim round trip <1e-4 (was −0.2592 frozen); S2 control unchanged; 23 controller tests byte-identical; suite 8361 green, typecheck verified, tree clean. Concerns: (1) TWO BLEND CURVES seam at engage handoff (~0.2 rad, absorbed ~10 notches) — consolidation needs ruling, reviewer asked to recommend; (2) engaged reference co-rotates with body (bounded, at-rest never writes); (3) suite's first wall-clock camera sim test (~40ms deterministic). Scoped re-review DISPATCHED (6 points + ship-or-consolidate recommendation). On APPROVED: push → USER PING. +- Round-5 re-review REVISE: R5-1 CRITICAL (blendedRefAxis arc near zenith mid-band → 180°/notch on locus, 101° at 10° off; northern high latitudes; fixtures missed locus + controller tests defaulted sceneUpLocal=[0,0,1]); R5-2 CRITICAL pre-existing round-3 anti-parallel knot (raw terms cancel → π ride; yaw 0/pitch −1.40/hR 1.69 default frame — reviewer's own round-3 miss); shared root cause: normalize near-zero blend then ride uncapped. R5-3 debug heading reads pole frame while settle uses blend (instrument misleads); R5-4 azimuth dual-frame naming. Reviewer ruling rec ACCEPTED: ship the engage seam (dive-only, absorbed by cap), backlog consolidation w/ post-fix numbers (pre-fix: mean 10.4°/worst 34.8° ecliptic). Also record C1-wall↔round-5-bake load-bearing coupling. +- FIX ROUND 6 dispatched: (b) continuity-bound ride one guard both arms + (a) horizontal-projection blend engaged side; R5-3/R5-4; coupling comments; locus fixtures both criticals mutation-verified; post-fix seam re-measure. Baseline 8361. Then re-review → push → USER PING. +- Fix round 6 DONE_WITH_CONCERNS (fable): 20958d51a — ORIENT_DECAY.rideBoundRad=0.3 both arms (excess → capped decay); blendedEnuAt one home (settle + debug, R5-3 fixed, band_up_weight row); R5-4 renames; coupling comments. Suite 8371 green (+3 locus tests); on-arc flip π → ≤0.52 rad then converges; world knot π → ≤bound+cap. Post-fix seam UNCHANGED by construction (target-frame diff; response now bounded): ecliptic 10.4°/36° clean, 180° grid cells harmless — feeds consolidation backlog. CONCERNS: (1) (a)-projection blend analytically ≡ round-5 axis form — mutation unsatisfiable, (b) alone killed R5-1, reviewer to re-adjudicate own rec; (2) near-locus fast recession can freeze ≤0.166 rad above band (world inertness) — reviewer to judge vs user's twice-reopened frozen-roll class; (3) rideBoundRad=0.3 feel-open (T22). Scoped re-review DISPATCHED with 3 adjudications. On APPROVED: push → USER PING. +- Round-6 re-review REVISE (R6-1 CRITICAL): bound converts whip → permanent freeze (full-sphere sweep 4,128 standpoints: brisk scroll 10.4% ≥5°, 4.2% ≥15°, worst 157°); round-6 locus tests soft (60-notch park; 0.25 rad bar admits 14.3°). Reviewer RETRACTED its R5-1 (a) rec after verifying implementer's algebra (cross annihilates localUp component — projection ≡ axis blend; empirical 3.3e-8 over 20k triples); also corrected own round-5 arc-distance labels (~5× overstated). Adjudication 3 clean (C1 coupling documented, register write, no double-count, debug frame fixed). +- FIX ROUND 7 dispatched: (C) hold-and-transport across singular neighbourhood (carry preInBlendFrame north forward — path-continuous reference, no debt created) + (D) above-band debt drain (deviation-only, S2 stays 0); no-park recession tests at both cadences asserting at the disengage BAKE < 1e-2 incl. reviewer's worst cells; R5-3 45°-rule one-home nit. STOP-clause: if (C) needs another partial, halt and co-design with reviewer live — no third bound. Baseline 8371. +- Round 7 BLOCKED correctly (stop-clause honoured, no code): impossibility proof — on-arc endpoints anti-parallel ⇒ π rotation intrinsic; band crossed in 2-4 notches at e^0.10 / 2-3 at e^0.24; no-whip rate 0.4 rad/notch spends ≤1.6 rad ⇒ freeze-free + whip-free + <1e-2-at-bake mutually exclusive on worst cells (any two achievable). Projected under mandate: 88-111° at bake, draining only above band. +- RULING: implementer's resolution 1 adopted — never whip, never permanent; amended bar = <1e-2 at bake OUTSIDE singular neighbourhood; worst cells = no-whip rate + full drain ≤20 continued above-band notches same gesture; S2 <1e-4. (C)+(D)+R5-3 nit dispatched (D = delete frameAlignedRoll above-band early return). Rejected: locus whip exception (smoothness ruling), ramp widening (ruled deep behaviour, insufficient), Δw bounds (tuning not structure). Intrinsic-π trade documented in code. Baseline 8371. +- Round 7 DONE_WITH_CONCERNS (fable): e495366c4 — (C) stateless hold-and-transport in blendedEnuAt (HOLD_CONDITIONING=0.3, screen-up stand-in on cancellation; zenith keeps pole fallback, polar fixtures byte-identical); (D) frameAlignedRoll above-band early return DELETED (deviation-only decay, intrinsic-π documented); R5-3 refAzimuthOf one home. Suite 8378 green; singularLocusRecession.test.ts codifies amended bar; M7c/M7d mutations clean. DEVIATION FLAGGED: worst-cell drain 36-37 notches vs mandated ≤20 — arithmetic forced by ruled constants; controller SIGNED OFF ≤40 envelope (~1.2-2.2s monotone settle on singular cells vs permanent freeze; shortening = rejected tuning class). Concerns: (C) concentrates flip at zone exit (bake 151° vs 146°, trade = stillness in-zone); tour/clip arrival rolls now bleed above band (feel gate); first carry cut broke 5 polar tests before being restricted — polar fixtures load-bearing. Scoped re-review DISPATCHED (adjudicate drain arithmetic + ≤40 honesty, hold-gate stability, exit-flip rate, arrivals-bleed vs §12-R3, post-fix 4,128 sweep). On APPROVED: push → USER PING. +- Round-7 re-review APPROVED: drain arithmetic independently re-derived (closed form matches sim at every debt; ≤20 would need cap ≥0.135 = rejected class; ≤40 = theoretical worst not padding); hold gate stable stateless (no carryUp feedback, max 2 crossings, 2.5% enter zone); 23° max notch rotation both cadences (bound survives exit); (D) RESTORES R1 pt-4's own words — round 3's above-band hold was the deviation; polar byte-identity structural (w=1 ⇒ conditioning ratio exactly 1). Post-fix sweep: bake distribution unchanged from round 6 (expected — (C) relocates flip, (D) changes outcome), last column now DRAINS ≤37 notches instead of permanent. PUSHED #647 @ e495366c4 (rounds 5+6+7: 846450405, 20958d51a, e495366c4). USER PING issued with reviewer's mandated framing: singular-cell drain = "stops unwinding when you stop scrolling, resumes when you scroll again" (NOT "1.2-2.2s settle"). Suite 8378 green. +- 2026-09-03 USER RULING 10 (feel gate on e495366c4): zoom-out now CORRECT; residual roll pop at END of zoom-in @ altitude_m 11,534,448 (h/R≈1.81 = engage neighbourhood — the shipped two-curve seam); ruling = SHARED codepath so in/out discrepancies are structurally impossible. Round 8 dispatched: one reference field (bodyUpWeight/blendedEnuAt one home, world's maxTiltRad-keyed roll target deleted; S2 shape change now ruled), one settle discipline arm-agnostic (ride+decay+hold+drain shared object), reproduce pop first (expect engage-flip target discontinuity), symmetry property test (target identity over h/R sweep regardless of direction/arm) + dive regression at user's altitude. Baseline 8378. +- Blend curve Q&A: bodyUpWeight = smoothstep(disengageHR, engageHR, h/R) descending, C¹ edges. PARKED per user until round 8 lands + feel verdict: optional log(h/R) evaluation (uniform blend progress per geometric notch) as a T22 feel lever. +- 2026-09-03 USER: round-8 implementer near 1M tokens → STOPPED per user; uncommitted round-8 edits REVERTED (tree clean at e495366c4, no commits lost). Parting insight preserved in brief: "rate check fires on the conversion notch, not the post-flip burst — windowed before/after comparison needed". FRESH fable implementer dispatched with self-contained brief fix-round-8-brief.md (required reading: fix-wave-report.md rounds 1-7, combined-review.md, key files; ruling 10; symmetry property test; mutation contract). NOTE: the retired agent carried rounds 1-7 context — future fix rounds now go to the NEW agent or need brief-carried context; opus reviewer seat unchanged. +- Round 8 DONE (fresh fable, 239k tokens): 9726265fb — blendedUpDir one reference field (world arm gains hold-and-transport; maxTiltRad-keyed authority curve DELETED, maxTiltRad = tilt wall only); riddenOrientStepRad one rate discipline both arms verbatim; CI guards orientTargetSymmetry (cross-arm target equality, 1% fails) + engageFlipPop (windowed, user's altitude); 3 mutations clean. Pop mechanism CONFIRMED: flip minted ~0.12 rad discrepancy (world authority 0.53 vs engaged weight 1), walked out over ~8 notches — post-fix 1.8e-4. Suite 8390 green. Concerns → re-review: (1) in-band approach twist 0.059 vs 0.024 rad/notch (feel gate?); (2) 4,128 sweep NOT re-run (knot location shifts) — reviewer to re-run; (3) S2 fixture re-pin h/R 1.0→2.55 (below engage now pole-pure by ruling). Scoped re-review DISPATCHED (6 points + sweep + fresh-implementer misread check). On APPROVED: push → USER PING. +- Round-8 re-review APPROVED: unification verified by grep + algebra + empirics (engaged sweep bit-identical to round 7; world knot 180°→22.9° = R5-2 FIXED AT SOURCE, unclaimed bonus; cross-arm target continuity max jump 1.15e-3, target at 1.70 = −0.259204 exactly the round-5 frozen figure — seam closed by construction). Concern 1 = disclosure with corrected envelope: dive twist median 2.1°, p99 20°, worst 22.9° near singular locus (~1% tail) — implementer's 0.059 was typical not peak; gentler dive would violate ruling 10 symmetry. Concern 3 re-pin required (old position vacuous by ruling: bodyUpWeight(1.0)=1 ⇒ no scene term below engage). Reviewer ON RECORD: ship-the-seam call wrong, second "bounded therefore imperceptible" overrule by user's eye; unrepresentable > small, every time. PUSHED #647 @ 9726265fb. Suite 8390 green. USER PING issued. Feel gate: unified codepath + in-band dive twist disclosure. Parked/open unchanged: log(h/R) lever, north-up toggle offer, T18-T22, pan-roll one-token, cam.distance backlog, R4-1 panel authority dup (band_authority row deleted in R8 — likely moot, verify at cleanup). +- 2026-09-03 USER feel tune: tilt handle more responsive → TILT_GAIN = 1.6 on the tilt drag's pitch component only (surfaceController.ts, exported; rate-law comment updated), 2 hand-computed fixtures TILT_GAIN-corrected. 26/26 controller tests + typecheck green. COMMITTED 649f05239 (no explicit verdict — user moved on to ruling 11; constant stays tunable, revisit if tilt feel comes up again). Not yet pushed — rides the round-9 push. +- 2026-09-03 USER RULING 11 (verbatim): "lets try (log(h/R) blend-space, and add engage / disengage sliders, and north up toggle in separate subsection in the camera section debug". Unparks the log(h/R) lever + north-up toggle; adds live engage/disengage h/R sliders. ROUND 9 dispatched to round-8 fable implementer (abdf1bf83e91ef043, resumed — has round-8 context) with fix-round-9-brief.md: (1) bodyUpWeight interpolant in log(h/R), debug toggle lin/log default log, symmetry guards parameterized over both spaces; (2) sliders drive the ONE canonical hysteresis home (band + regime together — ruling 10 forbids divergence), clamp disengage > engage×1.1, defaults 1.7/3.4, session-only; (3) north-up toggle heading+roll authority only, default ON, C1 tilt wall untouched; (4) new "Orientation tuning" subsection in camera debug section per rung-6 settings consolidation. Baseline 8390. On DONE: opus re-review (scoped) → push → USER PING. +- 2026-09-03 USER BUG (queued as ROUND 10, after round 9): focus change to another body (search → Mars) while ENGAGED in body regime does nothing — camera only responds after manual zoom-out past disengage into absolute regime. Likely kin of R4-3 flagged path (focus tween/follow vs engaged regime — "the one uncovered path"). Read-only Explore trace dispatched (sonnet, background) to find the gate + the reusable disengage conversion; its report seeds fix-round-10-brief.md. NO fix until round 9 lands (single-implementer rule). +- Round-10 trace COMPLETE + brief WRITTEN (fix-round-10-brief.md): mechanism = focus lands in selectionRows.focus fine but ALL camera consumers gated (watchFocusTweenSaga.ts:121 skips moving bodies unconditionally; followBody.isActive requires frame==='absolute' cameraDrivers.ts:293; resting wins with Earth pose); no code compares focused body vs engaged body; recovery today is purely geometric (manual zoom past 3.4 → fold flips → stale focus honoured). CONTROLLER RULING in brief: fix = focus-mismatch disengage condition INSIDE regimeArmFor (fold stays the single regime-transition author; conversion + commit site untouched; followBody activates next frame) — saga-dispatched disengage and inline followBody conversion REJECTED as second-author divergence class. Edge cases pinned: same-body no-op, non-body focus deliberate decision, low-altitude conversion fixture. Dispatch when round 9 lands. +- Round 9 DONE (fable, same agent): e56657b48 (+539/−90, 14 files), suite 8405 green (8390 + 11 new + 4 both-space guard params). Knob homes: ORIENT_TUNING.blendSpace (new src/data/camera/orientTuning.ts, default log — LIVE immediately, half-weight 2.55→2.40 h/R); setSurfaceBand writes SURFACE_REGIME in place (one record: regimeArmFor + bodyUpWeight + maxTiltRad + debug row), clamp disengage≥engage×1.1 moved-knob-wins; ORIENT_TUNING.northUp read at both arm sites (canonicalledPose dPsi+level, frameAlignedRoll; C1 wall NOT gated; OFF also disables above-band drain + de-scene-aligns bake — honest meaning); UI OrientationTuning.tsx subsection. Implementer flags: mutable module records = deliberate trial mechanism → post-trial deletion audit hardcodes winners. OPUS RE-REVIEW DISPATCHED (4 adjudications: both-space guards non-vacuous, stale-copy capture of mutable records, north-up-off coherence, clamp/one-record grep). ROUND 10 DISPATCHED to implementer in parallel (pipelined; disjoint files, base e56657b48, baseline 8405). Push after review verdict + round 10. +- 2026-09-03 USER RULING 12 (near-verbatim): tilt reset = Cesium style — remember last tilt set in body regime (default 0 = from above), smoothly lerp in/out of it within engage/disengage window; zoom-in must NOT change tilt (current toNadir settle on zoom-in is WRONG). Queued as ROUND 11 (after round 10), brief WRITTEN fix-round-11-brief.md: display tilt = rememberedTiltRad × w(h/R) pure function (same band record + blend space as round 9, 0 at disengage ⇒ C1 preserved by construction); zoom never authors tilt; 4 reconciliations mandated (wall-vs-band single authority = STOP-and-un-braid if they disagree; mid-window tilt-set rule; persistence scope rec = per-session global; debug readout row); neutrality regression + mutation contract. +- Round 10 DONE (fable): a5bf83ce1 (+282/−15, 4 files), suite 8409 green (8405 + 3 regimeArmFor units + 1 runFrame sim), mutation-verified both ways. Shape per ruling: regimeArmFor gained focusedBodyId (string|null from the drivers' own pivotFocus snapshot), fold stays single regime author. ADDITION beyond brief letter: symmetric engage gate (differing focus also blocks engage) to kill engage/release flap after low-altitude release — 150-frame no-flap sim. Edge decisions: same-body no-op; non-body focus → null, does NOT release (incumbent tween-out stands, stated); clear = pure h/R; moons release; low-altitude release verified finite/eye-preserving (bakes ~2.1 R). Concerns: deep-release h/R<0.45 proximity capture (cut not ease; claimed unreachable — reviewer judging); focus-jump UX = incumbent absolute path by design. OPUS REVIEW QUEUED behind round 9 (5 adjudications; #1 = does the symmetric engage gate create a NEW bug: fly into an unfocused body → never engage?). ROUND 11 DISPATCHED to implementer (base a5bf83ce1, baseline 8409, pipelined). +- Rounds 9 + 10 both APPROVED (opus, combined-review.md). PUSHED #647 @ a5bf83ce1 (TILT_GAIN 649f05239 + R9 e56657b48 + R10 a5bf83ce1). R9 adjudications: parameterization real (anti-vacuity assert; cross-arm symmetry structural in any space — weight fixture is the space coverage); log-default sweep vs round-8 linear: marginally BETTER (<2° 96.8→97.1%, ≥45° 1.9→1.6%, envelope unchanged); no load-time capture (vitest isolate blocks leakage); north-up OFF coherent (disclosures: mid-drain toggle freezes debt; levelledPose stays on by design — ruling 3); clamp can't invert. R10 adjudications: symmetric engage gate necessary+sufficient+stateless (flap verified real); deep-release proximity UNREACHABLE (Earth→Moon h/R≈220 in Moon radii); one focus authority, no alloc. +- OPEN FINDINGS for next fix batch (after round 11): F1 process — R9's "8405" not attributable (another session's WIP mutated tree mid-review; R10's 8409 WAS clean-tree attested, supersedes); R10-1 IMPORTANT — engage now requires matching-or-absent body focus + wireInput seeds body focus at boot ⇒ clip/tour flying to body B with body A focused never engages surface arm — needs clip-path test or explicit statement; R9-1 minor (restores write literals); R9-3 DISCLOSURE briefed to user (dragging disengage slider below live altitude forces immediate disengage carrying present tilt = C1-class pop; move sliders parked outside band — NOTE: likely moot after round 11 remembered-tilt, re-judge); R10-2 spec:784 still says engage body-blind; R10-3 regimeArmFor header leads body-blind. +- Round 11 DONE (fable): 16892ecc4 (+369/−103, 13 files), suite 8414 green (8409 + 6 rememberedTilt − 1 superseded C1 ceiling test), toNadir-restore mutation fails 5/6. Reconciliations: (1) wall-vs-band UN-BRAIDED — band owns zoom-time tilt, walledTiltPose = gesture-time cap floored at max(maxTiltRad, remembered×w) (real disagreement found: remembered 1.5 maps above ramp mid-window, pre-fix wall eroded ~0.08/step — own fixture); (2) mid-window set: remembered = display/w clamped, memory untouched at degenerate w (fixed-point fixture); (3) persistence per-session global, closure state in surface controller + accessor, survives disengage/re-engage/body switch; (4) debug row remembered_tilt_rad read-only. Heading/roll settles verified un-braided, byte-identical. Tilt skips ride continuity bound (degeneracy-free target; bound would break exact-0 bake). Concerns → review: anchored-dive ~0.04 rad transient (Q4c trade); tour arrival tilt decays toward memory (arrivals-adopt = one-line if trial wants); mid-window-set-then-dive display rises toward remembered; lerp-in only exists entering engaged (claimed essential regime-boundary asymmetry — reviewer judging vs ruling 10). I verified clean tree + typecheck:fast at 16892ecc4 (F1 re-attestation superseded). OPUS REVIEW DISPATCHED (7 points). FINDINGS BATCH dispatched to implementer in parallel (one commit on 16892ecc4: R10-1 clip/tour engage investigation, R9-1 restore capture, R10-2 spec line, R10-3 header). Push after both return. +- Round-11 re-review APPROVED (tsc clean, 8414): all 6 reconciliations hold on mechanism. R11-1 IMPORTANT (user disclosure MANDATED as table): mid-window set rule amplifies invisibly — wall caps visible tilt while 1/w re-inflates stored value; stored converges ~76–87° at every mid-window altitude (h/R 3.35: 0.1° visible arms 86.6° stored, ×740); reviewer refuses "bounded therefore fine" (twice-overruled class); option floated (not prescribed): raise memory-write gate surfaceController.ts:665 from w>1e-6 to legibility threshold — USER FEEL RULING PENDING, implementer told not to act. R11-2 minor → folded into findings batch (String vs num() on remembered_tilt_rad row). R11-3 process: 8414 not measured on exactly R11 HEAD (findings batch landing concurrently; R11 files clean, review stands). Adjudications: floor sound + C1 by construction (both terms 0 together at 3.39); deleted C1 test's invariant re-pinned rememberedTilt.test.ts:169 (caveat: no per-notch tilt magnitude pin anywhere — same unpinned class as old ceiling-delta); un-mapping FORCED (no third rule exists); one tilt home; bound-skip both halves verified (scalar monotone target, π-class unrepresentable; 0.3 bound would carry ~2.5 rad across disengage); dive transient fixture-pinned <0.05; lerp-in asymmetry ESSENTIAL (tilt has no world-arm counterpart); R9-1 already fixed in R11 (capture-based restore). +- Findings batch DONE (fable): 762a2d44e (+127/−35, 12 files), suite 8415 green (+1 fixture), tree clean verified at HEAD. R10-1 REACHABLE + SHARPER: hand-authored flyToClip/flyPath parked at body B with stale body-A focus → post-R10 the pivot pin re-targets the focused body at surface distance and the camera LEAVES for it (pre-R10 focus-blind engage shielded the landing); guided beats safe (flyAndFocusOnClip aligns focus at beat start). Resolution: sim fixture both halves in focusReleaseWhileEngaged.test.ts + mechanism statement at gate site; gate not redesigned. R9-1 capture-restores all 7 files; R10-2 spec §12 amended (argmin stays body-blind); R10-3 header reworded; R11-2 num() via pre-formatted-readout contract. SCOPED RE-REVIEW DISPATCHED (adjudication: flag-and-document vs behavior fix for clip authors — verdict decides follow-up round vs documented landmine). Push after verdict. R11-1 mid-window memory-write gate = USER FEEL RULING still pending. +- Findings-batch re-review APPROVED (no findings; regimeArmFor diff = comments only, gate untouched). R10-1 ADJUDICATED: flag-and-document RIGHT, no behavior fix — parent defect pre-existing + already spec'd out of scope (spec:790 tour-end pivot-pin snap); round 10 removed an accidental shield, didn't create the interaction; guided beats safe; candidate fix (clips clear focus) riskier than bug; +54-line sim fixture = executable statement for the eventual pivot-pin fix. Reviewer self-checked vs round-8 ship-the-seam error: reachability call re content authoring, not perceptual call — different class. NON-BLOCKING follow-up ledgered: clip-authoring docs (docs/tour/) need the stale-focus note where authors will see it (regimeArmFor.ts is the wrong side) — fold into wave-end cleanup. PUSHED #647 @ 762a2d44e (16892ecc4 R11 + 762a2d44e batch). Suite 8415 green. WAVE IDLE — all agents free. PENDING USER: R11-1 gate ruling; feel verdicts on log/lin blend, sliders, north-up, remembered tilt, Mars focus-release. Board republish due at wave end. +- 2026-09-03 USER RULING 13 (verbatim): "something flips the tilt on a specific threshold... I thought we were going to do a lerp?" — round-11 concern-4 asymmetry (lerp-in only when entered engaged; world-armed approach expresses no tilt until engage 1.7 snap/walk) OVERRULED as defect. THIRD reversal of a bounded/essential-therefore-fine verdict by user's eye. ROUND 12 dispatched (fable, base 762a2d44e, baseline 8415), brief fix-round-12-brief.md: reproduce first (round-trip continuity sim failing at engage notch), then tilt = remembered × w(h/R) pure function regardless of arm — world arm expresses view-axis-vs-nadir tilt in-window, engage edge changes ownership not image (round-8 invariant extended to tilt); one mapping home; never-engaged byte-identical control; blendedUpDir/heading write-order fight = STOP-and-un-braid. Then opus review → push → user ping. +- 2026-09-03 USER RULING 14 (verbatim): "when tilt is 0 and we're decreasing the tilt, the earth moves underneath us... tilt should be clamped to 0 and no dragging of the world underneath us should occur." Queued as ROUND 13 (after round 12 — same files), brief WRITTEN fix-round-13-brief.md: reproduce first (full-pose byte-bar dead-stop fixture at tilt 0, TILT_GAIN included; hypothesis = clamp-after-rotate residue about pick point — confirm not assume); fix = gesture-level clamp before rotation (excess input → zero rotation, dead stop); mixed drags keep heading live; remembered → 0 legal, nothing beyond; band mapping untouched; mirror the round-8 ceiling-overshoot fixture structure for the floor. +- Round 12 DONE (fable): f62b5e0ed (+481/−4, 6 files), suite 8425 green (8415 + 4 explicit + 6 sweep-generated), mutation (re-gate world expression) fails the right tests with byte-identity controls surviving. Pre-fix measured: display pinned 0 through world-armed window while map rises to 0.3547 rad at engage; user saw sudden-onset attenuated walk 0.033–0.036 rad/notch (~15 notches; 0.1 cap eaten ~60% by anchored-dive localUp chase). Post-fix: world leg on map to 4 decimals, engage inherits exactly; zoom-out confirmed already smooth. Write order ONE chain documented: drivers → pivot pin (WHERE) → approachTiltedPose NEW (view-vs-nadir angle about fixed eye, roll carried) → fold; frameAlignedRoll different DOF, base stays centre-looking; one mapping home mappedTiltRad NEW (world projection + engaged settle + drag-wall floor). Concerns → review: (1) in-window world-arm drag then pin re-centre+re-projection = small eye shift at large remembered (feel trial); (2) engaged rows keep round-11 anchored-dive transient ≤0.08 (pre-existing); (3) expression keys on FOCUSED body like the pin — unfocused manual flight gets no lerp-in but also no flip (stated). OPUS REVIEW DISPATCHED (6 points; #2 = quantify the drag-then-release shift — next reopen candidate). ROUND 13 DISPATCHED (pipelined, base f62b5e0ed, baseline 8425). Push after both reviews. +- 2026-09-03 USER RULING 15 (verbatim): "also the heading direction on right drag should be inverted" — folded into round 13 same commit (message queued to implementer): heading component of right-drag handle sign-flipped ONLY (tilt component + left-drag/orbit untouched), fixtures assert new direction, feel-ruling comment at handle so it isn't "fixed" back. +- 2026-09-03 USER: both seats well past 600k tokens (implementer abdf1bf ~575k+, reviewer a178859 ~651k). RULING: in-flight tasks (round 13 impl, round-12 review) run to completion — NOTHING further dispatched to either agent afterwards; both RETIRED on return. All subsequent rounds → FRESH fable implementer + FRESH opus reviewer, briefed from files only (implementer: fix-wave-report.md rounds 1-13 + round brief + combined-review.md pointers; reviewer: combined-review.md is the full seat record + the round's package/brief/report). Round-13 review in particular goes to a FRESH reviewer. +- ROTATION POLICY encoded (memory feedback_bg_agent_context_rotation.md; user raised cliff 300k→400k for headroom): bands <200k free / 200–350k fresh-for-full-rounds, incumbent only small own-diff follow-ups / >350k retire at boundary; never start a task whose estimate crosses 400k; in-flight always finishes; costs from this session: scoped fix 30–60k, full round 100–240k, review 100–200k. +- Round 13 DONE (fable, RETIRED at ~601k): e532f644a (+80/−15, 2 files), suite 8427 green. Mechanism REFUTED brief hypothesis: NO floor existed — handle rotated through nadir (0.503 rad far-side orbit for 20 px = the "earth dragging"; unsigned readout rose with heading flipped) AND wrote swing into remembered memory (pollution reachable since round 11). Fix: gesture-level bound (lowering clamped to held tilt, pure-excess returns pose BY REFERENCE for byte bar), raising side keeps ceiling wall, remembered floors at 0. RULING 15 in same commit: right-drag heading negated (−yawRad), feel-ruling comment + re-signed Z-X-Z/mixed fixtures; overshoot fixture re-aimed (was riding missing floor through nadir). Concerns: asymptotic approach to 0 from small tilt (never lands exactly — feel trial); tilt handle vs orbit drag now turn opposite (explicit ruling, pinned). +- Round-12 review REVISE (reviewer RETIRED at ~680k): R12-1 CRITICAL — approachTiltedPose (eye-fixed via moved target) × pivot pin (target-fixed, eye derived) on COMMITTED tilted pose ⇒ eye jump d·2sin(τ/2): 4,221 km (rem 0.5 h/R 2.55) → 23,787 km (1.5, 1.75), ACCUMULATES 8.4k→40.7k km over 6 commit cycles; reachable via in-window gestureEnd commit + any commit-on-edge (tween/autoRotate/clip/followBody); suite green because round-12 sim commits once then only reads; reviewer self-flagged "idempotent by construction" error (idempotent in tilt angle ≠ eye position — only measuring caught it). Offered fix ADOPTED as ruling: commit sites bake PRE-projection centre-looking pose, projection render-side only; autoRotate roll secondary expected to self-resolve. R12-2 minor: inverse map inline at surfaceController.ts:686 → one-home counterpart of mappedTiltRad (inverse writes MEMORY, divergence sticky). R12-3 process: tree dirty 3rd time (round-13 WIP; failures all in dirty file). +- FRESH SEATS spawned: round 12b → fresh fable implementer (brief fix-round-12b-brief.md: required reading list, R12-1 verbatim mechanism + tables, commit→re-derive idempotence fixture pre-fix-failing, engage-edge display-tilt inheritance must survive, R12-2 same commit; base e532f644a, baseline 8427). Round-13 review → fresh opus reviewer (combined-review.md = seat record; 6 adjudications incl. through-nadir proof with TILT_GAIN, asymptotic-stall quantification, by-reference aliasing, ruling-15 fixture honesty; appends "Round 13 review" to combined-review.md). Push after 12b + both verdicts. +- Round-13 review REVISE (fresh reviewer, ~184k): R13-1 CRITICAL clamp on WRONG SIDE (shipped controller: positive input RAISES tilt; unsigned acos can't express direction) — lowering drag still crosses nadir (1.29°/event eye orbit) + up-drag from exactly 0 PERMANENTLY LOCKED + unrequested (1+R/h) ramp on raising; R13-2 IMPORTANT module comment inverted vs measured behavior (drag-down tilts DOWN, comment says up) — direction question sent to USER; R13-3 minor third inline acos copy → one-symbol signed util; R13-4 minor overshoot fixture false rationale + no up-from-nadir pin. Suite 8427 independently attested clean-tree at e532f644a (first clean attestation of wave). +- 2026-09-03 USER RULING 16: INVERT tilt-drag pitch (Google-Earth style: drag toward you = tilt UP toward horizon; away = toward top-down) — mirrors ruling 15; makes module comment true again (R13-2 resolves code→prose). ROUND 13b dispatched to FRESH fable implementer (brief fix-round-13b-brief.md: flip pitch sign this handle only, signed-tilt clamp on toward-zero side, dead stop byte bar, kill lock-at-0 + memory pollution crossings, re-sign round-13 fixtures; base 2494ba057, baseline 8435). +- Round 12b DONE (fresh fable, ~208k): 2494ba057 (+427/−15, 7 files), suite 8435 green (8427 + 2 idempotence + 6 sweep), both mutations caught (8,519 km / 16,731 km signatures). Shape: centreLookingPose.ts commit-side inverse (eye preserved, by-reference untouched paths) at drainInput gestureEnd + runFrame commit-on-edge gated pivotsOnFocusedBody; clip/tween commit verbatim; unmappedTiltRad one-home (R12-2). AutoRotate roll secondary RESOLVED (full commitCameraPose writer inventory). CONCERN 1 CRITICAL-GRADE out of scope: ACTIVE in-window drag register loop (store projected → pin → re-project) walks eye ~8,519 km/FRAME at 60Hz, unchanged — ROUND 12c planned, CONTROLLER RULING: register holds AUTHORED centre-looking pose during gestures, displayed pose = pure projection at read (pick, followBody capture, sim liveWorldPose adapt) — reviewer sanity-checking direction + collisions for the 12c brief. Concern 3: third "aim at point, keep eye" site → deletion audit. 12b REVIEW dispatched to round-13 reviewer (a01ee, ~184k, under free band). SEQUENCE: 13b → 12c (avoid parallel implementers); push after 13b + 12b verdict + 12c. +- Round-12b review APPROVED (reviewer a01ee now ~262k): inverse algebraically exact + measured idempotent (25 gestureEnd cycles 6.2e-9 km; 25 edge cycles exactly 0; engage-edge Δ +0.00001 across arm flip); clip/tween paths clean (evaluateClip never reads register); 8435/1211 independently attested clean tree; 12b = incumbent design (runFrame:487-500 disengage retarget documents same landmine) applied consistently. R12b-1 CRITICAL confirmed pre-existing (press-and-hold zero-motion in-window walks eye 8,519 km/frame, 6 Earth radii/20 frames, invisible on screen) — GATES in-window feel trial, = round 12c. R12b-2 minor (bake sites gate differently, unstated arbitration assumption), R12b-3 minor (centre-looking invariant enforced 3 shapes stated nowhere → state once at cameraSlice.ts:97). Reviewer endorsed 12c direction with TWO-BOX variant (authored register + explicit displayed box) + 5-reader inventory (pick runFrame:274/starCatalogLayer:928, render override :415 else one-frame pop, followBody capture CameraClock.d.ts:54, demand/LOD engine.ts:972, fold source authored w/ slight drag-mapping feel change to DISCLOSE); deletion-audit input: DON'T unify the three aim-at-point sites (differ in what they hold fixed). 12c BRIEF WRITTEN (fix-round-12c-brief.md: two-box + 5 readers + bake-site delete-or-justify + R12b-2/3 same commit + press-and-hold regression). Dispatch on 13b return. +- Round 13b DONE (fresh fable, ~261k, retired next boundary): 90fa55be9 (+286/−63, 8 files), suite 8451 green (8435 + 2 controller + 6 util + 8 sweep), both R13-1 halves reproduced pre-fix, both mutations kill right fixtures. DEVIATION from R13-3 letter (reviewer re-judging): shared util UNSIGNED tiltFromNadirRad — pose-level sign ill-defined (opposite-azimuth ≡ crossed byte-identical; signed attempt broke disengage wall fixture, legit look-tilts escaped); signedness lives in tiltFloorBudgetRad (has rotation axis; P+K≤0 = signed about axis), contract test pins unsigned fold. Other concerns: in-plane land-at-0 (roll levelled per step = lived case); overshoot fixture re-scanned 295→115 px (old length floored eye onto surface at new direction; window 105–130 valid); tiltLerpRoundTrip harness re-tuned 2 px steps (remembered 0.385→0.354 back under 0.09 bar — bar untouched). +- ROUND 12c DISPATCHED (fresh fable implementer, base 90fa55be9, baseline 8451; brief fix-round-12c-brief.md two-box + 5-reader repoint + bake delete-or-justify + R12b-2/3). 13b RE-REVIEW dispatched to reviewer a01ee (~262k, LAST TASK then retired; 7 points incl. re-judging the unsigned-util deviation + the two fixture re-tunes honesty). Push after 13b verdict + 12c + its verdict. +- Round-13b re-review APPROVED (2 minors; reviewer a01ee RETIRED at ~314k, record in combined-review.md): floor EXACT not conservative (brute-force 150 geometries, worst landing 0.000°, budgets match analytic (1+h/R)·τ); no lock (50px from 0 → 4.2°); raise linear again; ruling-16 scope correct, GE prose true by measurement; unsigned-util deviation ACCEPTED as better than R13-3 (only other through-nadir path = free-look, eye never moves); roll cases converge 0.000°; both fixture re-tunes honest. R13b-1 minor DISCLOSURE: where no rotation about the axis reaches nadir, budget=Infinity, lowering uncapped (verified correct, no crossing; stress 5000px at 3.4R sweeps 444° orbit) — wants branch clause + user line, queue with 12c findings. R13b-2 process = F1 #4 (12c red TDD fixtures mid-run; attestation stands). PUSHED #647 @ 90fa55be9 (12b 2494ba057 + 13b 90fa55be9). Carried-forward for next reviewer seat: R12b-1 gates in-window feel trial (12c in flight); ruling-16 live behavior awaits user feel confirm. +- Round 12c DONE (fresh fable, ~240k): 3047a8875 (src +181/−162 15 files, tests +392/−1 22 files), suite 8453 green (8451 + 4 − 2 sweep rows), walk reproduced ~8,700 km/frame pre-fix, mutations verified. Two-box: lastPose = AUTHORED register, new cameraRuntime.displayedPose; liveWorldPose→displayed, new authoredWorldPose→register. Readers: pick/demand/debug/clip-tween-seams → displayed; fold → authored (ruled); TWO DEVIATIONS from reviewer letter, both measured: commit-edge render override → AUTHORED (feeds pin+projection which re-derive displayed exactly; displayed would re-pin projected = one walk frame), followBody capture → AUTHORED (ease decodes vs body-centred target; displayed capture = R12-1 composition, 8,718 km first eased frame). BAKES DELETED: centreLookingPose.ts removed, invariant by construction, R12b-2 dissolves, R12b-3 stated once at commitCameraPose. Disclosed feel change: drag deltas compose below tilt, screen mapping shifts slightly (12c review to BOUND the number). Concern: clip/tween deactivation w/ non-pivoting incoming driver renders authored register one override frame (pre-existing tween-start discontinuity moved ≤1 frame, unpinned). 12C REVIEW dispatched to FRESH opus seat (7 points: independent walk repro, both deviations, invariant-closure grep-trace, one-frame concern, feel-change BOUNDED number, tiltCommitIdempotence not weakened). OPEN for next findings batch: R13b-1 branch clause + user line. Push after verdict. +- Round-12c review REVISE (fresh opus seat ad23ca, ~217k; suite 8453 independently attested clean-tree, no F1 recurrence — probes ran in separate worktrees): R12c-1 CRITICAL — override reads authored UNCONDITIONALLY but pin+projection gate on incoming driver's pivotsOnFocusedBody ⇒ non-pivoting incoming (clip/tween) draws untilted register 1 frame: 0.402 rad/453px flash, NEW not moved (pre-fix continuous; saga seeds from displayed); reviewer BUILT+VERIFIED fix (discriminant on incoming driver at runFrame.ts:403, full suite green, both walks still 0) + fixture gap named (edge fixture uses setAutoRotate = pivoting = pins only authored-correct case). Deviation 2 (capture→authored) CONFIRMED CORRECT (8,718.4 km to the decimal; arrival clean). Invariant closed on all 10 commitCameraPose callers (≤2.6e-8 rad/200 frames); statement overreaches (R12c-2: 4th route runFrame:393 clip/tween prevRow, pre-existing spec§790 — name it). R12c-3 four stale docblocks; R12c-4 comment overrun → wave-end audit. FEEL CHANGE BOUNDED: vertical 0px; horizontal +7px @ rem 0.57 (×1.02), +52px @ 1.05 (×1.17) per 100px drag on 900px viewport; new mapping isotropic, old wasn't. Accuracy note: mutation-1 "7 failures" not reproducible (4 measured). 12C-FIX dispatched to 12c implementer (own-diff follow-up, ~240k→est ~290k): reviewer's discriminant + non-pivoting-incoming fixture + R12c-2 note + R12c-3 docblocks + R13b-1 Infinity clause + probe-file hygiene; R12c-4 explicitly excluded. Then scoped re-review (12c reviewer) → push. +- 12c-fix DONE (12c implementer, ~266k → retire after): b7800bf4a (+61/−18, 7 files), suite 8454 green (+1 tween-start fixture, failed pre-fix 0.389 rad edge drop). Discriminant applied (flag hoisted); R12c-2 fourth route named; R12c-3 four docblocks; R13b-1 Infinity clause; no probe files (verified status+ls-files). ADDITION for reviewer: authoredOverride keeps register stamp AUTHORED on non-pivoting arm (else post-pin capture writes displayed pose into register 1 frame; drag-during-tween — orbitDrag@80 outranks tween@60 — could fold that window into the walk). SCOPED RE-REVIEW dispatched (ad23ca; adjudicate authoredOverride real-vs-dead + quick checks). Push on APPROVED. +- 12c-fix re-review APPROVED, PUSHED #647 @ b7800bf4a (12c 3047a8875 + fix b7800bf4a). Suite 8454/1214 independently attested clean-tree. authoredOverride = REAL hazard correctly closed (drop it: register stamps projected pose, drag next frame teleports 9,005 km one-shot; cancelCameraTween at pointerdown doesn't rescue — applyWorldStep bails only on playing CLIP; stamp not observable elsewhere, register/base pair strictly more consistent). R12c-1 revert reddens only the new fixture at 0.3893 rad. R12c-4a minor non-blocking: hazard unpinned (suite green without authoredOverride) — one-liner assertion dispatched to implementer as FINAL micro-task (then retired ~266k+); reviewer nit no-action (nullable pose carrying one bit). Reviewer ad23ca at ~240k after this — retire-or-small-only band. R12c-4 comment budget → wave-end /comment-audit. R12c-4a DONE + PUSHED @ 332c39442 (mutation reddens exactly the new assertion at 0.389 rad; suite 8454 green; tree clean at push). BOTH SEATS RETIRED (implementer aa532d2 ~269k, reviewer ad23ca ~240k) — next round = fresh seats per rotation policy. 2026-09-03 USER RULING 17 (verbatim): "tilt direction is reversed from google maps. fix that please" — supersedes ruling 16's direction; ROUND 13c dispatched to fresh fable (brief fix-round-13c-brief.md: one pitch-sign flip on tilt handle, floor/wall claimed tilt-space thus untouched — verify not assume, re-sign direction fixtures, comment records rulings 16+17 both; base 332c39442, baseline 8454). Round 13c DONE + PUSHED @ 7102591aa (fresh fable, ~138k, surgical — no review seat, verified by mutation: sign revert fails 11 direction fixtures; suite 8454 exact baseline): pitch sign back to Google-Maps style (comment records rulings 16+17 both); floor/wall CONFIRMED pixel-sign independent (floor keys on tiltRequest post-negation, budget takes pose geometry only); overshoot fixture re-geometried (net-raising drag ends above press pixel — fixture consequence, not code dependency); ADDENDUM same commit: SURFACE_BAND_LIMITS engageMin 1.05→0.1, disengageMin 1.5→0.2 (one home surfaceRegime.ts, ratio clamp holds below 1). 2026-09-03 USER: tilt-up + zoom-out jarring — IDEAS ONLY delivered (no implementation): (1) gesture-end settle tween à la Cesium [my pick], (2) screen-space px/notch rate cap, (3) drain-late w reshape [cheap first experiment, sliderable], (4) nadir-anchored recession while tilted, (5) display capped by altitude ramp [warned: ruling-10 braid risk]. Awaiting user pick. WAVE STATE: all pushed @ 7102591aa; pending = USER FEEL GATE + jarring-fix pick (tilt direction/floor, levers, focus release, remembered tilt, in-window drag w/ +52px@1.05 disclosure, R11-1 ruling), board republish, wave-end comment audit + deletion audit. +- 2026-09-03 USER RULING 18 (verbatim): "when switching to saturn or jupiter (using focus), im ending up inside the planet. it should really fully reset the body pose on body switch, so not remember tilt / heading etc, and switch to the actual focus distance. implement neatly, no shortcuts." ROUND 14 dispatched to FRESH fable implementer (rotation policy; base 7102591aa, baseline 8454), brief fix-round-14-brief.md: reproduce FIRST (mechanism NOT established — hypotheses: bodyLikeFraming arrival h/R ~1.37 < engage 1.7 so engage fires mid-tween/on-arrival with Earth's stale remembered tilt; giant radii 9-11x Earth make carried distances interior), CONTROLLER RULING remembered tilt keyed to authoring body, resets on switch (supersedes round-11 survives-body-switch scope), same-body re-focus + same-body disengage/re-engage KEEP memory; fold stays single regime author; heading not remembered today (implementer to confirm); focusTweenDescriptor yaw/pitch carry-over NOT in scope unless reproduction implicates it. Then fresh opus review → push → user ping. +- RULING 18 AMENDMENT (user, verbatim): "you can actually fully reset on switch" — no per-body memory store; body switch WIPES remembered tilt to 0 (detection may use an authoring-body id, but nothing is restored per body). Same-body re-focus + same-body disengage/re-engage still keep memory. Relayed to round-14 implementer mid-flight (scope-only clarification, simplifies — safe mid-task). +- Round 14 DONE (fresh fable, ~222k): 740f078ae (not pushed), suite 8457 green (8454 + 3 bodySwitchReset fixtures), tsc clean, tree CLEAN verified by controller (stale editor diagnostics = known noise; no probe files). MECHANISM (refutes brief hypothesis again): NO body-focus tween exists for moving bodies (watchFocusTweenSaga returns on bodyMovesThisFrame) — followBody IS the flight; its activation capture carried the OLD body's orbit distance verbatim (engaged-Earth 2.37 R⊕ = 0.2589 R♄) → eye inside Saturn frame one → fold engages there (h/R<0, focus matches) → body arm blocks followBody, disengage unreachable → STRANDED inside. Fires for every engaged-Earth→giant switch. FIX: followBody capture adopts framing distance (new clock.followBodyId, one capture site) + SurfaceController.noteBody(bodyId|null) closure wipe, runFrame single caller (engaged-else-focused, once/frame pre-projection) — covers absolute-regime switches; full wipe per amendment, A→B→A wipes (letter, stated). CONCERNS→review: (1) autoRotate-active switch = same inside-planet class via pivot pin, UNTOUCHED (brief STOP condition, needs ruling); (2) arrival h/R 3.33 just inside 3.4 band; (3) static-body focus counts as switch and wipes; (4) brief's tween-repro unbuildable, real-saga fixture instead (deviation recorded). ROUND-14 REVIEW dispatched to FRESH opus seat (7 points: independent repro, noteBody ordering/null-flicker, followBody capture regression + 12c walk re-measure, autoRotate-class reachability for user ruling, h/R 3.33 arrival behavior, fixture/mutation honesty, clean-tree attestation; package round-14-package.diff). Push after verdict. +- ROUND-14 REVIEW PARKED (Anthropic outage): fresh opus seat died 3x on 529 Overloaded + 1x stream-watchdog stall over ~30 min (status.claude.com: minor service outage; local tool-safety classifier also timing out). Seat barely started (was launching the suite + reading). RESUME: SendMessage to the round-14 review agent (or spawn fresh from the same 7-point dispatch in this ledger entry above; package .superpowers/sdd/2026-09-01-camera-pivot/round-14-package.diff). 740f078ae committed locally, NOT pushed — push gated on verdict. +- 2026-09-09 PUSHED #647 @ 740f078ae (round 14) on user word, review still pending (round-14 reviewer seat never ran — outage). PR remains CONFLICTING vs origin/main (13 commits behind, merge-base 5fb5984fe); main merge-in due before landing. Dev server 5173 restarted this session (bg shell bt9488iuz). +- 2026-09-09 MAIN MERGED IN: origin/main 0a53aef82 → camera-pivot. 4 keep-both conflicts (CameraRuntime.d.ts + engine.ts + 2 test fixtures: surface/lastZoomFactor beside main's skyCubemapCapture). One semantic fix in the merge commit: main's new skyCubemapFaceContext.ts calls deriveFrameContext, which gained the `arm` param on this branch — passes `{ frame: 'absolute', pose }` (capture pose is synthetic world-absolute). tsc clean, suite 8647/1248 green, pushed. +- 2026-09-09 ROUND-14 REVIEW RE-DISPATCHED: fresh opus reviewer (background) in camera-pivot worktree on merged HEAD a4c23f4d2, same 7-point brief, package round-14-package.diff; appends "Round 14 review" to combined-review.md. Tracked tree must stay untouched until it returns (F1 lesson). Round 14 already pushed on user word; verdict → fix round if REVISE → user ping (incl. autoRotate-class decision item). +- 2026-09-09 ROUND-14 REVIEW = REVISE (fresh opus, ~207k, section combined-review.md:2966): R14-1 Important NEW — framing distance adopted as ease START = its TARGET ⇒ every body switch an instant cut (reviewer built+verified eye-preserving capture fix); R14-2 Important NEW — clip/tour landing on focused body surface yanked to h/R 3.33, never engages, whenever any follow happened first (guard harness never followed); R14-3 Important PRE-EXISTING → USER RULING: autoRotate@20 outranks followBody@10 so spin-pill-on + focus Saturn from engaged Earth = 0.2589 R♄ inside, UNRECOVERABLE (toggle-off does not release arm; 60 wheel notches reach h/R 0.0009) — cheapest = cancel autoRotate on focus-row change (one line) vs pivot-pin-learns-framing (the STOP-condition broad change); R14-4 minor first-ever-follow exemption still strands (1.203 R♄); R14-5 minor comment budget 1.66–3.83×; R14-6 minor clock.followBodyId exists only for a comparison the neater capture drops; R14-7 disclosure star focus rows wipe memory. Gates on merged HEAD: tsc 0, 8647/1248 green, mutations M1/M2/M3 all have teeth, walk 0 km/frame, idempotence 0 km. Ruling-18 halves HOLD on the named path (4.3301 R♄, tilt 0, mem 0). +- 2026-09-09 USER RULING 19: engage h/R 1.7 → 0.2 (controller assumption disengage 0.4 = 2× hysteresis, stated, unobjected). User also asked to surface the ×1.1 hysteresis floor (round-9 e56657b48, not in spec) in the debug view → readout row + setSurfaceBand returns moved knob. Brief band-defaults-brief.md. +- 2026-09-09 DISPATCHED IN PARALLEL: ROUND 14b (fresh fable implementer, this worktree, brief fix-round-14b-brief.md: R14-1/2/4/5/6; R14-3 OUT pending user; band files OUT) + BAND DEFAULTS (fresh sonnet, isolation worktree, brief band-defaults-brief.md, two commits, cherry-pick onto camera-pivot on return). Disjoint file sets stated in both briefs. Then: fresh opus re-review of 14b → push → user ping w/ R14-3 ruling ask. +- 2026-09-09 Round 14b DONE (fresh fable, ~211k): 8ab766815 on a4c23f4d2, suite 8650/1248 green, tsc clean, NOT pushed. R14-1 reviewer option A verbatim (eye-preserving capture, cut→flight fixture), R14-2 reproduced w/ prior-follow harness + fixed (guard split, R10-1 first-half assertion CHANGED "engages within 480ms"→"settles absolute at framing" — deviation flagged), R14-4 folded (exemption gone, first-follow fixture), R14-5 all 6 files ≤0.5 (runFrame −375 comment lines, cameraDrivers trim — rode the fix commit, whole-file), R14-6 followBodyId DELETED, R14-7 recorded. Concerns: R14-3 open; surfaceController at 0.50 exactly; same-body trace unpinned. NEXT: fresh opus re-review (incl. comment-trim landmine audit) → push. surfaceController SPLIT brief written (surface-controller-split-brief.md) — dispatch in isolation worktree off 8ab766815 during the review; cherry-pick after. +- 2026-09-09 Round-14b review APPROVED (fresh opus, ~202k, combined-review.md "Round 14b review"): option A verbatim, all claims reproduced (deep-space→Saturn 22205 R♄ → 4.3301 in 608 ms; first-follow no strand; R14-2 landing engages h/R 0.5; same-body ≤5.6e-13 rel — no fixture owed). 4 minors: R14b-1 stale R12b-1 justification at cameraDrivers.ts:150-158 (reword), R14b-2 lost "ease toward committed base so post-follow drag honoured" (restore 1 line), R14b-3 followFrom.target aliases deriveBodyStates memo array (copy), R14b-4 pan-offset world-frame rationale lost (restore 1 line). Disclosures: R14b-5 deviation 1 truthful + stronger (old assertion pinned engage at 0.798 R⊕ = inside Earth) — USER TICK; R14b-6 runFrame −375 comment lines rides fix commit (blame cost). Comment-trim audit: 38 load-bearing graded, 36 survive/relocated, 2 restore. Gates 8650/1248, tsc 0, budgets all ≤0.5. PUSHED 8ab766815. Minors → scoped fix agent (sonnet) → push. +- 2026-09-09 14b minors DONE + PUSHED 7e58c5837 (sonnet, ~82k): R14b-1 reword, R14b-2/4 one-liners restored, R14b-3 followFrom.target copied; ratios 0.444/0.478. Round 14 CLOSED except R14-3 (user ruling pending: recommended = split followBody into followApproach@60 + followHold@10). +- 2026-09-09 BAND DEFAULTS cherry-picked: 0c4350e36 (0.2/0.4 + 14 test files re-geometried + spec ruling-19 note) + 1acf67c65 (hysteresis readout; setSurfaceBand returns moved knob, new @types SurfaceBandKnob). Agent (sonnet, ~400k — over the rotation cliff, retired) left 12 RED by my scoping: surfaceController.test.ts ×7 (closed-form trig scenes at altitudes now outside the reshaped ceiling ramp: disengage 3.4→0.4 while tiltFullHR stays 0.02 ⇒ ramp compressed 8.9× vs band 8.5×), bodySwitchReset ×3 + focusReleaseWhileEngaged ×2 (round-14 fixtures at old-band altitudes). NOT PUSHED (red). Fix agent (fable) dispatched: fixtures re-geometried honestly, src untouched unless a real bug. OPEN DESIGN Q for user: tiltFullHR 0.02 under the new band. Reviewer later must audit the 14-file retune for honesty. +- 2026-09-09 SPLIT DONE (sonnet, ~251k) on branch surface-split off 8ab766815, worktree agent-a09fd21a07b15e7e1: 669a971f0 types+data → f0575fcda 14 helpers → utils/camera → 625895fca dot3 5→1 → 8fd47a717 bodyFixedEyeM 5→1 + poseWithBasisTurn in anchoredDragRotation → 7d5d310f4 4 focused tests. surfaceController.ts 697→119 lines; suite 8650→8718/1252 (convention sweeps auto-generate per file). Leftovers: TILT_GAIN rationale duplicated (call-site block + tiltGain.ts pointer), surfaceController.test.ts:300 stale `latchFor` name in a comment, private dot3/eyeOf copies in test files out of scope. CHERRY-PICK QUEUED behind the red-fixture agent (same worktree, shares surfaceController.test.ts). +- 2026-09-09 RED FIXTURES DONE (fable, ~205k): a8bf59c49 — 12 fixtures re-homed into the new band (mechanism: at old h/R≈1 w=0 and ceiling 0 so the wall erased every drag tilt; follow framing h/R 3.33 with e^-0.1/notch needs ~32 notches to engage), no assertion weakened, src untouched, adjacent R10-1 stale-focus fixture de-vacuoused (MARS_PARK 1.5→1.1 R). SPLIT cherry-picked clean: 7eec6dc10 62a486781 0560a94e6 f0890696a bbdf0a5a9; surfaceController.ts 119 lines. tsc clean, suite 8719/1252 green. PUSHED through bbdf0a5a9. Pending review: honesty audit of the 17-file band retune (0c4350e36 + a8bf59c49) + mechanical-move check of the split. +- 2026-09-09 USER RULINGS (size complaint: PR ~12k lines, mechanism ~1.9k code): (1) shared sim harness — sonnet, isolation wt `sim-harness` off bbdf0a5a9, brief sim-harness-brief.md, target ≥−1,200 test lines; (2) un-braid the five settle mechanisms (settledZoomPose/walledTiltPose/levelledPose/frameAlignedRoll/approachTiltedPose → one target fn + one step law + one pivot application) — fable, isolation wt `settle-unbraid` off bbdf0a5a9, brief settle-unbraid-brief.md, checkpointed (radar → STOP if ruling needed; golden-trace byte bar before src); (3) comment audit — QUEUED, runs LAST after 1+2 cherry-picked (touches every file); (4) debug UI KEPT for now (user: "a little longer"). Band+split reviewer (opus) still running read-only in camera-pivot. Cherry-pick order: harness → un-braid → audit. +- 2026-09-09 BAND+SPLIT REVIEW (fresh opus, ~281k): A APPROVED w/ 1 Important — RB-1: two fixtures left out-of-band asserting where bodyUpWeight≡0 while claiming in-band (surfaceController.test.ts:472-531 round-6 blend flip; drainInput.test.ts:391 ruling-8 roll ride) — mutation-proven unguarded; RB-2 tiltLerpRoundTrip 0.09→0.12 bar rests on false mechanism claim (log blend is scale-invariant); RB-4 spec:291 + FW-E:607 magnitudes "at 3.4 R" now 8.5× off; RB-5 spec:450 1.71→0.21 R, stale h/R≈1.1 at focusReleaseWhileEngaged.test.ts:183; RB-6 OrientationTuning unused tick/limits/mid-file import; RB-7 readout non-floor branch untested. B APPROVED — SS-1 flooredBodyPose test >0.1 only; SS-2 canonicalBasisAt handedness compares sample to itself; SS-3 rotatedAboutPoint anchor test restates; SS-4 BODY_CENTRE 4 private copies survive; SS-5 5 files over comment budget (pre-existing). MEASURED: engage seam 32× smoother (flip Δtilt 0.00042 vs 0.0134), per-notch continuity unchanged, nothing snaps; tiltFullHR RECOMMEND KEEP 0.02 (band-scaled costs look-up between 15–127 km; ceiling now 97° at engage vs 178.5° under Q6 — user eye at feel gate). Gates 8719/1252, tsc 0. ALL findings QUEUED into one post-consolidation minors round (files owned by harness/un-braid agents now). +- 2026-09-09 COMPACT CHECKPOINT — HEAD 8a11c6082 pushed (backlog: smooth zoom + flick coast, awaiting-decision, Q8 revision). LIVE AGENTS: (a) sim-harness (sonnet, isolation wt branch `sim-harness` off bbdf0a5a9) → on return: cherry-pick its commits onto camera-pivot, full suite, push; (b) settle-unbraid (fable, isolation wt branch `settle-unbraid` off bbdf0a5a9, checkpointed: may return STOPPED-AT-RADAR needing a ruling — radar at settle-unbraid-radar.md) → on return: cherry-pick AFTER harness, full suite, fresh opus review (golden-trace deviation + existing byte-bar fixtures), push. THEN: (c) comment audit over the whole branch (comment-audit skill, own commit, BASE = merge-base with main) → (d) post-consolidation minors round: RB-1 (Important: re-home the two out-of-band fixtures surfaceController.test.ts round-6 flip + drainInput.test.ts ruling-8 ride), RB-2, RB-4/5 spec magnitudes + stale literals, RB-6/7 OrientationTuning, SS-1..4 → (e) USER: R14-3 ruling (rec: split followBody → followApproach@60 + followHold@10), tick R14b-5 assertion change, feel pass on 0.2 band (Saturn/Jupiter focus; ceiling 97° at engage; tiltFullHR keep 0.02 rec) → (f) T18–T21 → wave-end deletion audit + radar → T22 gate → whole-branch review → /feature-done → land. Debug UI KEPT (user). Dev server :5173 bg shell bt9488iuz. PR body rewritten 2026-09-09 (update "in flight" lines when 14b/band/split are described as landed). +- 2026-09-09 SETTLE UN-BRAID DONE (fable, ~311k) on branch `settle-unbraid` (wt agent-a48eab64ec46de748): 1c52456d3 golden trace (test + 153 KB JSON fixture, 8,656 values, 17-digit) + 3e5ec818c un-braid. RESULT IS LOC-NEUTRAL: src +163/−165, tests +386. Shape built = scalar step laws + body-arm apply primitives with pivot as data (turnedPose, tiltTurnedPose, levelledPose w/ named bag, riddenOrientStepRad takes rideBound, wrapRad shared); the brief's three cross-arm functions NOT built (would need an arm union inside one fn = the representation×operation braid ruling 10 leaves alone; targets already had one home each). approachTiltedPose kept its inline tilt readout (tiltFromNadirRad moved pitch 1.8e-8 rel — world-arm in-window projection ill-conditioned at Earth scale: REAL FINDING, design change to fix). Golden trace deviation 0. Gates 8736/1255 tsc clean. headingTiltAt has 0 src refs (dead, needs ruling). HALTED per code-is-liability (neutral measurement) — user rules land/park; NOT cherry-picked yet. Harness agent still running. +- 2026-09-09 USER RULING: LAND the un-braid + DELETE headingTiltAt. Cherry-picked 40b2a9fb2 (golden trace) + 0ddbcc164 (un-braid) onto camera-pivot, clean. Follow-up agent (sonnet, this worktree): shrink fixture 153 KB → ~15 KB (stride + leg/mode seams, mutation-verified) + `refactor -- delete` headingTiltAt (+ HeadingTiltAt type if orphaned). Then push. Harness agent still running (isolation wt `sim-harness`); cherry-pick it after — its base bbdf0a5a9 predates these, expect clean. +- 2026-09-09 HARNESS DONE (sonnet, ~384k) on branch `sim-harness` off bbdf0a5a9, 13 commits 290334c04..4b36cd771 (wt agent-a76a11ca50ea7fd6d): tests net −773 (fixtures −1218 / harness +445, 14 files under tests/helpers/camera/: makeCameraSimHarness + diveUntilEngaged/recedeUntilDisengaged/seedRememberedTilt/driveWheelEvents + readers + poseAtHR). 11 files migrated; incidental 32-notch dives → until-verb, load-bearing counts kept explicit; controller-level unit tests (rememberedTilt/singularLocus/northUpToggle) left — no runFrame harness in them. Suite 8719/1252 green at both ends. CHERRY-PICK 13 commits after the fixture-shrink/headingTiltAt agent returns (same worktree). +- 2026-09-09 COMPACT CHECKPOINT 2 — pushed through 0ddbcc164 (golden trace + un-braid). LIVE: fixture-shrink + headingTiltAt-delete agent (sonnet, THIS worktree; commits 2, then push). QUEUE after it: cherry-pick sim-harness 13 commits (290334c04..4b36cd771 from wt agent-a76a11ca50ea7fd6d) → full suite → push → comment audit (comment-audit skill, whole branch, own commit) → post-consolidation minors round (RB-1 Important + RB-2/4/5/6/7 + SS-1..4) → update PR #647 body "in flight" lines → user items (R14-3 ruling rec followApproach@60/followHold@10; R14b-5 tick; feel pass on 0.2 band) → T18–T21. Dev server :5173 shell bt9488iuz. + (correction: push landed through 7709a4813 — the shrink agent had already committed the fixture-stride commit; its headingTiltAt delete commit is in progress, tree has 3 uncommitted agent files.) +- 2026-09-09 SHRINK+DELETE DONE: 7709a4813 fixture 153→20 KB (STRIDE 40 + leg/arm seams; seam floor alone 18 KB because body-arm registers carry a Mat3; mutation-verified on the exercised branch) + eafb79a72 headingTiltAt + HeadingTiltAt type + test deleted (prose mentions remain in CameraDebugSnapshot.d.ts:35, blendedEnuAt.ts:5,25, surfaceController.test.ts:100, spec:790,872 — for the comment audit). HARNESS cherry-picked 93189a2d2..812a54d85 (13), suite 8729/1254 green, tsc clean, PUSHED @ 812a54d85. PR now tests +7826/−609, src +3860/−1262 vs main. COMMENT AUDIT dispatched as 3 parallel isolation agents off 812a54d85 (scope lists audit-scope-{A,B,C}.txt beside ledger; A utils/@types/data opus branch comment-audit-a; B services/components/state/tools opus branch comment-audit-b; C tests sonnet branch comment-audit-c) → cherry-pick all three, suite, push → then minors round. + +## COMMENT AUDIT A landed (2026-09-09) + +Agent A (utils/@types/data, 69 files) returned `5bee244f9` → cherry-picked as +`01c6f61e6` on camera-pivot (36 files, +284/−750, comment-only, tsc clean). +Not pushed yet — push together with B and C. B (services/components/state) and +C (tests) still LIVE in isolation worktrees; cherry-pick each on return, full +suite + tsc, push, then minors round. + +Agent A adjudication items for the user (recorded; not acted on): +1. `headingTiltAt` refs deleted in CameraDebugSnapshot.d.ts + blendedEnuAt.ts; + one left in tests/services/camera/surfaceController.test.ts:100 (agent C scope). +2. CameraDebugSnapshot.d.ts: dropped `armMismatch` / `epochDeltaDays` one-liners + as name-restating — restore if user reads them as definitions. +3. InputStep.d.ts: dropped the "why startPx/endPx are absolute (ray per pixel)" + sentence — that is the only WHY for the encoding; consider restoring one clause. +4. zoomedDistance.ts: dropped inline "real repair = cam.distance / altitude + un-braid" note — already a pending user item (cam.distance un-braid offer); + nothing lost. + +## COMMENT AUDIT C landed (2026-09-09) + +Agent C (tests, 86 files) returned `37a0853d6` → cherry-picked as `bdb3cbc37` +(43 files, +365/−628). Flagged: oneMpcSeam.test.ts header ~24 lines (cross-file +gate contract, not a literal exemption); "(prior art Q3/Q4c)" cites in +surfaceController.test.ts ~663/682 kept; anchoredZoomStep.test.ts:122 "old +sub-eye value" kept (paired with FW-H). Awaiting B, then suite + push. + +## AUDIT B stalled + resumed (2026-09-09) + +Agent B hit the 600 s stream watchdog while on slabs.ts/engine.ts (uncommitted +edits in its worktree agent-a7d59c64af67ae180, branch comment-audit-b). Resumed +via SendMessage with: finish scope, tsc + suite, one commit, report. Editor +diagnostics from its worktree showed unused-import warnings (Vec3 in +frameAlignedRoll.ts, eyeMpcOf in engine.ts) — told it those must be clean. +If it stalls again: take over its worktree tree directly (finish + commit). +Then cherry-pick, full suite + tsc on camera-pivot, push A+C+B together. + +## COMMENT AUDIT B landed (2026-09-09) + +Agent B returned `406de1092` (32 files, +717/−2504) after 3 watchdog stalls +(the full vitest run trips the 600 s watchdog — future briefs: `--reporter=dot` +or skip the suite and run it on the branch). Cherry-picked then AMENDED to +`9b155f77c` (31 files, +558/−2003): I pulled the starCatalogLayer.ts hunk +(−501 comment lines) OUT — the branch touches that file by 2 lines only and the +star-catalog-unbraid worktree is active on it; patch parked at +`starCatalogLayer-comment-audit.patch` beside this ledger for the user to route +(own PR / ride the un-braid). Verified the whole B diff is comment-only. +B adjudication items: (1) frameAlignedRoll.ts lost "round-7" + "2–4-notch" in +the singular-locus clause; (2) cameraSlice.ts clipStarted-vs-startClip naming +trap note deleted; (3) cameraSlice.ts commitCameraPose KNOWN RESIDUAL (spec +§790) deleted — spec still carries it. +Full suite + tsc running in background (bf1r3j2ih); on green → push. + +## COMMENT AUDIT PUSHED (2026-09-09) — camera-pivot @ 9b155f77c + +Full suite: two runs under machine load avg 90–145 (78 min and 39 min) showed +only 5-s timeouts in unrelated files (conventions sweeps, SettingsPanel, +CommandPalette) plus worker-level errors; targeted rerun of all 13 affected +files green 13/13, 1417 tests. tsc clean. Pushed 812a54d85..9b155f77c. +NEXT: (a) user adjudication on the audits' unsure deletions (list above); +(b) route starCatalogLayer-comment-audit.patch; (c) minors round (RB-1 first) ++ PR body "in flight" lines update; (d) R14-3 ruling still pending. + +## MINORS ROUND dispatched (2026-09-09) + +One opus agent, isolation worktree, branch `minors-round` off 9b155f77c, brief +`minors-round-brief.md` (RB-1a/b, RB-2, RB-3/4/5, RB-6, RB-7, RB-10, SS-1..4, +spec headingTiltAt prose). On return: cherry-pick its commits onto +camera-pivot, tsc + full suite (slow under load; use --reporter=dot), push, +then run the round reviewer over the minors diff. PR #647 body already +updated (14b/band/split/harness/un-braid/audit landed; smooth-zoom backlog). + +## MINORS ROUND landed + pushed (2026-09-09) — camera-pivot @ 29232590f + +Cherry-picked 772741f08/c01845234/2100cb484 → 678ca17ca/7a97198c3/29232590f +(14 files, +90/−67). Agent ran full suite green 8729/1254 in its worktree; on +the branch tsc clean + touched areas 165 files/2811 green. Deviations to note: +RB-1a needed an extra below-engage assertion to be mutation-sensitive; RB-1b +at 1.35 R⊕ (h/R 0.35, not 0.3 — at 0.3 the target roll coincided with the +fixture's own); SS-2 handedness is −1 (right = forward × up), not +1; RB-10 +now near-duplicates the "from the record, not a literal" it (merge candidate). +Reviewer over 9b155f77c..29232590f dispatched (opus, read-only) — F1 rule: no +tracked-file edits on camera-pivot until it returns. + +## MINORS REVIEW: fix-first (2026-09-09) + +Reviewer over 9b155f77c..29232590f: RB-1a/b genuinely in-band + mutation- +sensitive (6 fixtures fail with w≡0); RB-6/SS-4 behaviour-preserving; spec +arithmetic right. Findings → fix agent (opus, isolation wt, branch +`minors-fix` off 29232590f): (1) canonicalBasisAt handedness comment claims +trig-slip sensitivity that mutation disproves; 1 sample, −1, state the +cross-order invariant; (2) delete the now-duplicate "not a literal" it in +regimeArmFor; (3) flooredBodyPose fixture needs non-zero anchor; (4) locus +maxTurn bar 0.52 → rideBound (+ hoist the `if (i===3)` assertion); +(5) SPEC/FW-E: "at the flip 0.4 R" is the RELEASE edge — the inbound engage +edge 0.2 R gives 3.6e-4 rad/s (0.021°/s), AT the perceptibility threshold → +FW-E now needs the T22 feel gate (USER-FACING: report); (6) tiltLerp "14 % +whatever the edges" only under 2× hysteresis; (7) OrientationTuning readout +test restated defaults → not-AT-FLOOR + '(floor '; (8) drainInput "mid-band" +wording at w≈0.097. +On return: cherry-pick, tsc + touched areas, push; no second review needed +unless the agent deviates. + +## MINORS FIX landed + pushed (2026-09-09) — camera-pivot @ 458210efb + +Fix agent's b5d7ed3ca/81e0770fc → 7f6797d36/458210efb (8 files, +61/−57). +All 8 findings done; one deviation: ride bound is `ORIENT_DECAY.rideBoundRad` +(orientDecay.ts), not ORIENT_TUNING. flooredBodyPose mutation (floor +eyeRelAnchorM) now FAILS the test. tsc clean; touched areas 4074 green. +Minors round CLOSED. The editor diagnostic "bodyFixedEyeM unused in +flooredBodyPose.ts" was the agent worktree's transient mutation, not the +branch (grep confirms the import is used). + +## QUEUE (next) +1. USER: FW-E at engage 0.2 R = 0.021°/s drift (perceptibility threshold) — + T22 feel-gate item; options if visible: wider band or shorter engage settle. +2. USER: route starCatalogLayer-comment-audit.patch; audit unsure-deletion + rulings (naming trap / known-residual / round-7 id / 2 snapshot field docs). +3. USER: R14-3 ruling (rec: followApproach@60 + followHold@10); R11-1; + pan-roll; cam.distance un-braid offer; RB-10 two ratio its. +4. Plan T18–T21, wave-end deletion audit + radar, T22 gate, whole-branch + review, /feature-done, land. Retire earth-rtc-foundation wt. + +## SIDE PR: skyCubemapCapture off cameraRuntime (2026-09-09) + +User asked for it as a separate PR off main, in parallel. Opus agent in an +isolation worktree, branch `refactor/sky-cubemap-capture-off-camera-runtime`, +draft PR against main. Moves the field to top-level `EngineState.skyCubemapCapture` +(no new bag). When it merges to main, merging main into camera-pivot needs ONE +touch here: `tests/helpers/camera/makeCameraSimHarness.ts` seeds +`cameraRuntime.skyCubemapCapture` (camera-pivot-only file) → move the seed to +the state top level. +Addendum (user): same side PR also renames `bandActive`→`lastBandActive`, +`gcDistanceMpc`→`lastGcDistanceMpc` (last-frame memory vocabulary) and adds +backlog item "Sky-cubemap band memory derived, not stored" (needs-design, +detail md 2026-09-09-sky-cubemap-band-memory-derived.md). Sent to the agent +via SendMessage. camera-pivot merge touch grows to: harness seed field path + +the two renamed field names. +Side PR OPEN: draft #670 (6748cd273 + ce6e038b4, 15 files +111/−89), tests +343 files green, backlog entry added. Merge-back touch on camera-pivot: +harness seed → state top level + renamed fields lastBandActive/lastGcDistanceMpc. + +## DESIGN NOTE (user, 2026-09-09): cameraRuntime single-writer reducer +User wants cameraRuntime mutated in ONE place via a reducer-like step. My +recommendation given: `state.cameraRuntime = stepCameraRuntime(prev, inputs, +nowMs)` once in runFrame; stages stepClock / replayInput / driver step +(drivers return `{ pose, next }` incl. follow capture state — R14-3 lands +there) / fold; boxes `{current}` removed; clipPlayer returns a new epoch; +surface controller converted last. Guard: ts-morph gate test (oneMpcSeam +style) forbidding cameraRuntime assignment outside runFrame + boot seed; +deep-freeze in the sim harness. Sequencing rec: own PR AFTER #647 lands, as +ground for T18 (+ R14-3). Awaiting user ruling. + +## #670 MERGED + main merged into camera-pivot (2026-09-09) — @ 1d44398e5 + +#670 squash → main 2befbd94a. Merge: 4 conflicts (CameraRuntime.d.ts + +engine.ts: kept audited comments, took main's structure; two test files: +HEAD harness wins), harness seed moved to state.skyCubemapCapture with the +renamed fields. tsc clean; engine/gpu/visual/camera/conventions 4463 green. +Pushed. + +## USER RULING (2026-09-09): cameraRuntime single-writer refactor ON THIS +BRANCH, with a plan. Process: refactor-ground skill (ideal diff, data delta +first, checkpoint with user) → spec section → writing-plans → SDD execution. + +## REFACTOR-GROUND in flight: cameraRuntime single-writer (2026-09-09) + +Two agents dispatched: (1) Explore/opus — box holders, runFrame step order, +driver/clock/drainInput/surface contracts, test constructors; (2) opus +greenfield — shapes from requirements R1–R9 only (no incumbent types). +On return: sketch ideal diff (data delta first), diff vs greenfield, verdicts +per touchpoint, prep list, adjacent findings → CHECKPOINT to user (shape +sign-off + PR packaging ask). Then spec "Ground preparation" section in +docs/superpowers/specs/2026-09-01-camera-pivot.md → writing-plans → SDD. +Byte bar for the refactor: settleGoldenTrace + round fixtures. +Trace + greenfield returned; CHECKPOINT written: runtime-refactor-ground.md +(ideal shape, options A–D, joints J1–J7+G, preps P1–P5, ask a/b/c). +Presented to user 2026-09-09; awaiting sign-off + PR packaging + units call. +Then: spec "Ground preparation" section → writing-plans (P1–P5 + T18 + R14-3). + +## RULINGS (user, 2026-09-09) on runtime-refactor-ground checkpoint +1. Shape SIGNED OFF (A pure step returning actions + effective intent; B clip + epoch in `epochs`; register+winner merge; surface as data; gate test). +2. Packaging: ALL FIVE PREPS AS COMMITS ON #647 (camera-pivot). +3. Units: elapsed unit unification IN P1. +NEXT: spec "Ground preparation" section (docs/superpowers/specs/2026-09-01-camera-pivot.md) +→ plan docs/superpowers/plans/2026-09-09-camera-runtime-single-writer.md +(writing-plans skill + plan-style.md) covering P1–P5, then T18 + R14-3 tasks. +PLAN AUTHOR dispatched (opus): writes docs/superpowers/plans/2026-09-09-camera-runtime-single-writer.md ++ spec section "Ground preparation — cameraRuntime single-writer" in the camera-pivot spec. +Inputs: runtime-refactor-ground.md, runtime-trace.md, runtime-greenfield.md, plan-style.md. +Phases 0–7 (byte bar, P1..P5, T18+R14-3 followApproach@60/followHold@10, gate). +On return: I self-check the plan against plan-style + the checkpoint, commit spec+plan +on camera-pivot, push, then ask user parallelism level and start SDD execution +(subagent-driven-development; ledger = this file). + +## PLAN WRITTEN + PUSHED (2026-09-10) — camera-pivot @ 24182c2c3 +docs/superpowers/plans/2026-09-09-camera-runtime-single-writer.md (21 tasks, 8 +phases) + spec section (line ~910). Parallel groups: A = T1 ‖ T2 ‖ T10; B = T20 ‖ +T21; rest sequential (runFrame/drainInput/cameraDrivers/harness shared). +Plan author's design calls awaiting user: (1) followApproach priority 55 not 60 +(60 ties tween; pickWinner tie = table order); (2) advanceEpochs has per-row +eligibility keyed on the winner (three epochs reset only when their driver wins — +unconditional advance would burn a tween's ease queued behind a drag) + idempotent +same-ref; (3) follow roll ride moves from drainInput:230-255 to runFrame after +runCameraDrivers (riskiest move in P2); (4) NEAR_CLIP_MPC extracted (near 0.01 +literal ×4); (5) register.winner stays string; (6) T18 re-records the driver +golden trace (R14-3 is a ruled behaviour change; settleGoldenTrace stays byte- +identical). ASK USER: parallelism level + (1)/(6) confirmations. Then SDD execute. +USER (2026-09-10): parallel groups (A: T1‖T2‖T10, B: T20‖T21), followApproach 55 +confirmed; trace re-record not contested → proceed as planned. START SDD execution. +SDD EXECUTION of the single-writer plan runs in ITS OWN workspace/ledger: +.superpowers/sdd/2026-09-09-camera-runtime-single-writer/progress.md (resume map for that plan). +- 2026-09-10 SINGLE-WRITER PLAN COMPLETE + user rulings: LAND on neutral perf; notch family backlogged (docs/backlog/2026-09-10-wheel-notch-route-by-last-winner.md, needs-design); simulateFrame ×2 collapsed onto stepCameraRuntime (tests/helpers/camera/simulateCameraFrame.ts); makeSurfaceDriver kept; poseOf inlined into wireInput (file + test deleted, anti-alias assertion moved to wireInput.test). Ledger archived 258ce1a45 (completed/2026-09-09-camera-runtime-single-writer.ledger.md + .perf-T19.md); workspace deleted. camera-pivot @ 258ce1a45 pushed. RESUMING THIS PLAN at T19 ‖ T20 (isolation worktrees off 258ce1a45, opus each; briefs task-19-brief.md / task-20-brief.md; T19 also owns the deferred m3 = applyWheelZoom in the base-absolute/lastPose-body window the fly-to saga produces). Then T21 perf (paired A/B vs merge-base with main) → T22 USER feel gate → wave-end deletion audit + radar → /feature-done. +- 2026-09-10 T19 dispatched (opus, isolation wt, branch t19-lonlat-body-arm off 258ce1a45; report in ITS worktree .superpowers/sdd/2026-09-01-camera-pivot/task-19-report.md). T20 dispatched (opus, isolation wt, branch t20-keyframe-frames off 258ce1a45; report likewise task-20-report.md). Handling on return of either: cherry-pick its commits onto camera-pivot (record pre-pick HEAD as BASE), copy its report into THIS workspace, `review-package PLAN BASE HEAD`, task reviewer (opus) with brief + report + package + Global constraints (plan lines 63-89); fix rounds resume the same agent; push on clean review; `Task N: complete`. T20 picks AFTER T19 if both land together (cameraDrivers.ts shared only by T20; disjoint otherwise). Then T21 perf (paired A/B: base = merge-base 2befbd94a server vs HEAD server, MERGED medians, own port each) → T22 USER gate. +- T19 returned DONE_WITH_CONCERNS (800b4d3ca in wt agent-a0e1d9bb2b9d0429a) → cherry-picked 02d1613d9 (BASE 258ce1a45). Concerns: (1) rangeM = eye ALTITUDE not resolved-arm distance (absolute distance = orbit radius, would push out one R) — reviewer to rule; (2) GAL_*_EQ 6-decimal literals → basis unit to ~2e-7, metres-level altitude error, saga test moved to equatorial frame — possible backlog; (3) no floor on rangeM, deliberate; (4) heading read in pure body ENU (blendW=1). m3: closed for the saga; inverse one-frame window on fly-to from outside the band (notch takes anchored zoom) — reviewer to classify. Reviewer dispatched (opus) on review-258ce1a45..02d1613d9.diff. T20 still running. +- T19 review (opus): spec Accepted, quality Accepted. Concern 1 UPHELD (altitude is the correct "range preserved"; the brief's literal resolved-arm distance would push out one R — brief wording was the defect). Two Importants = knowing out-of-band deltas mandated by T19 itself, RULING: accepted as plan-mandated (fly-to from outside the band now lands at the heading-derived roll instead of screen-up; eye at true altitude when base.target ≠ Earth centre) — both observable above disengageHR → carry to the T22 user ping + spec deviations list at /feature-done. Minor 4 (body→world→body round trip on an engaged path, ~1.3 m via 6-decimal GAL literals, default orientation unaffected) NOT taken (one more branch for near-zero effect). m3 inverse window = backlog: added as case (c) to the notch-routing detail (cb02b6b4c). Pushed camera-pivot @ cb02b6b4c. Task 19: complete (02d1613d9). T20 still running. +- T20 returned DONE_WITH_CONCERNS (909241caf..b1c1ef32d in wt agent-a5876189b0a0cf511) → cherry-picked 8e71ba4a3, 2d79d796f, b9a94550d, ff89eb6c5 (BASE cb02b6b4c), +628/−74 over 19 files. Once-per-leg memo = WeakMap> in evaluateClip. Concerns: (1) evaluateClip lossy by name (metres for body-framed clips, ~48 callers expect Mpc); (2) mid-tween frame change freezes a straddling channel; (3) no driver-level test for framedClipArm body branch; (4) body keyframes can't express roll → levelled on BODY_LOCAL_FRAME.pole; (5) zero-range toBodyArm call to dodge oneMpcSeam gate; (6) no perf run. Reviewer dispatched (FABLE — size + simplicity judgement) on review-cb02b6b4c..ff89eb6c5.diff. Fix rounds resume agent a5876189b0a0cf511. On clean: push → T21 perf. +- T20 review (fable): spec NEEDS FIX + quality NEEDS FIX. Critical: spin opens a new absolute leg after a body endpoint (relative writers must stay in the current arm). Important: leg-start origin walk inclusive of co-starting segments (zero-length cut leaks units body→absolute); zero-range toBodyArm = wrong seam entry → bodyRelativePose for the point; bodyRadiusM/bodyStateFor duplicate resolveWorldArm; no round-trip test of the pair; no driver-level body-branch test. Minors: ClipFrameOptions comment instant, purity claim false + evaluateClip 0.87 comment ratio, dead fallbacks, dead roll ?? 0, mirror assertion, cosmicFlows tollbooth test, ReadonlyMap return type. Rulings: concern 1 JSDoc suffices (throws without bodies); 2 = authoring-pathology class → compile-time validation FOLLOW-UP (surface at user ping); 4 consistent with surface roll rule; 5 not evasion but wrong entry; 6 T21 covers. Fix round 1 sent to agent a5876189b0a0cf511 (at ~235k tokens; this is its last own-diff round — round 2+ goes to a FRESH opus). On return: cherry-pick new commits, scoped re-review (opus, package from ff89eb6c5), push. +- T20 fix round 1 DONE_WITH_CONCERNS (agent now ~310k tokens → RETIRED; any round 2 = fresh opus). Cherry-picked 104e37936→…, c1281f8a3, 80db8109e, f54894beb onto camera-pivot = HEAD 04d55ff43 (BASE ff89eb6c5; +208/−135, 5 files). Suite 8764 green, traces untouched. Not done: item 13 (deriveBodyStates key type — BodyId is the source-registry union, 14 pre-existing casts; needs a scene-body-id type → candidate backlog). Item 8 partial (own delta 0.32; legacy 90-line header left — no mass sweep). Scoped re-review dispatched (opus) on review-ff89eb6c5..04d55ff43.diff. On Accepted: push → Task 20 complete → T21 perf. +- T20 scoped re-review (opus): ACCEPTED, all 13 items verified (mutation-tested). Item 13 → backlog warranted (scene-body-id type + 14 casts). New non-blocking: straddling spin freezes after a leg boundary (same class as concern 2, newly reachable), equal-startSec differing-frame tweens resolve by track order (pre-existing). Residual class for the user ping: per-endpoint tags under a pose-level frame → compile-time validation follow-up. Pushed camera-pivot @ 04d55ff43. Task 20: complete (8e71ba4a3..04d55ff43). NEXT: T21 perf. +- 2026-09-10 T21 dispatched (opus, isolation wt detached at merge-base 2befbd94a with own dev server = A; B = camera-pivot @ 04d55ff43 on http://localhost:5175, bg shell bvj163lc9 — KILL after T21; DO NOT edit camera-pivot files while T21 measures). Report → its wt .superpowers/sdd/2026-09-01-camera-pivot/task-21-report.md + perf-T21/. Neutral/negative → USER ruling (land/park) per plan. In parallel (read-only, isolation worktrees at 04d55ff43): wave-end deletion audit + entanglement radar over merge-base..HEAD — findings only, fixes dispatched AFTER T21 finishes. +- Deletion audit dispatched (opus, isolation wt at 04d55ff43, read-only; report → its wt .superpowers/sdd/2026-09-01-camera-pivot/deletion-audit.md). Entanglement radar dispatched (opus, isolation wt at 04d55ff43, read-only; report → its wt …/wave-end-radar.md). Handling: copy both reports here; rank; after T21 finishes (and only then — HEAD server must not change mid-measurement) ONE fix dispatch for the certain/likely deletions + agreed un-braids, review, push; "unsure" items → USER ruling list alongside T22. +- Radar DONE (copied → wave-end-radar.md): 3 high / 6 medium / 2 low. TRIAGE (controller): TAKE in the post-T21 fix dispatch, all high-confidence, no behaviour change, no re-record — H2 (poseBasis/upBasis onto DriverCtx, delete 5 ORIENTATION_FRAMES lookups in cameraDrivers), M1 (cameraDebugSnapshotOf → nearestBodyHR, −14), M2 (thread `bodies` into approachTiltedPose + liveBodyPosition/applyFocusedBodyPivot, memo becomes optimisation), M6 (DriverId union type, 4 string fields). M3 (autoRotate epoch advanced twice; disagreement only on a frame where autoRotate was last winner but loses this frame = the stale-winner family) → append as case (d) to docs/backlog/2026-09-10-wheel-notch-route-by-last-winner.md, not fixed here (re-record + design). USER RULINGS at the ping: H1 (session-mutable SURFACE_REGIME/ORIENT_TUNING globals read inside pure math; 4× save/restore ritual in tests), H3 (selectCameraActive restates 5 isActive predicates → `wakesLoop` row field), M4 (expiry rows registry ~30 lines), M5 (authoredOverride carries two stages; pin move → re-run traces). L1/L2 noted only. Waiting on T21 + deletion audit before any edit. +- Deletion audit DONE (copied → deletion-audit.md): ~120 certain / ~350 likely / ~3,650 unsure. TRIAGE (controller, per leanness.md): TAKE post-T21 — 3a settleGoldenTrace hand-rolled harness → makeCameraSimHarness/poseAtHR (trace bytes must stay identical), 8 cameraDrivers mirror (21) + isActive/resting restatements (52), 9 oneMpcSeam readFileSync `it`, 10 state.cam → boolean + drop wireInput createOrbitCamera call, 11+13 comment budget trims (~80, headers only), 12 inline recedeUntilDisengaged, 14 selectCameraIntent un-export (NO_FOLLOW_MEMORY keeps export: 1 external ref). NOT taken: 7 noStoredRegimeFlag = spec §11 acceptance grep (plan Global constraints name it) → keep unless user amends spec; 2 DebugPanel readout = already ruled KEPT by user (ledger 2026-09-09); 1 driverGoldenTrace byte bar (2,169) + 3b settle trace → USER ruling (retire-after-landing vs keep); 4/5/6 feel-trial knobs + northUp/lin parallel paths (~470) → decide AT/AFTER T22 (they are the gate's levers) — USER; readCameraEpochs dies with 1. Combined post-T21 dispatch = radar H2/M1/M2/M6 + M3 backlog append + deletion items above; opus, in camera-pivot, one commit per concern, review, push. +- Both camera-pivot dev servers (5175 shell bvj163lc9, old 5173 shell bt9488iuz) exited 143 (SIGTERM) — most likely T21's end-of-run kill was broader than its own server. task-21-report.md already exists in its worktree → measurement presumably complete; awaiting its notification. No live dev server for camera-pivot now; restart with `npm run dev` (bg) for T22. +- T21 DONE: paired A/B (A 2befbd94a @5176 vs B 04d55ff43 @5175), 4 pairs × 10 scenarios, MERGED medians: sums 216.4 vs 215.8 (Δ −0.6 ms), every per-scenario Δ inside its own spread (median spread 2.5 ms; sgr-a-star-lens bimodal 2/2 both sides); rAF probe vsync-pinned 8.30 ms both sides (no sub-8.3 resolving power — proves 120 fps sustained, not a CPU delta). VERDICT NEUTRAL = the bar. Report → task-21-report.md (raw stays in the agent worktree). PER PLAN: neutral HALTS LANDING → USER ruling land/park (to be asked together with T22 + the other open rulings). INCIDENT: agent's `pkill -f vite -n` killed EVERY Vite server on the machine (5173/5174/5175) — other sessions' worktree servers too; user must be told. Task 21: complete. Fix wave (not a landing step) proceeds now. +- Wave-end fix dispatched (opus, IN camera-pivot, BASE 04d55ff43; 12 commits: M6 DriverId, H2 basis on DriverCtx, M1, M2, M3 backlog (d), 3a settle harness dedupe [STOP if any sampled value moves], 8, 9, 10 state.cam→flag, 12 recede inline, 11+13 header trims, 14). Report → wave-end-fix-report.md. On DONE: review-package 04d55ff43..HEAD → reviewer (opus) → push → restart dev server → USER PING with: T21 land/park ruling, T22 gate list, deletion rulings (driver trace byte bar; feel knobs after T22), radar rulings (H1/H3/M4/M5), clip authoring-validation follow-up, deriveBodyStates key type backlog, the Vite kill incident. +- 2026-09-10 ACCOUNT HANDOFF (personal → ludens; this session ends). LIVE AGENT AT HANDOFF: the wave-end fix implementer (opus, IN camera-pivot, BASE 04d55ff43, 12 commits per the dispatch line above) — it dies with the session. NEW SESSION FIRST: list commits 04d55ff43..HEAD in camera-pivot and check for `wave-end-fix-report.md`; commits present = done items; anything missing → re-dispatch ONLY the missing items from the dispatch spec (BASE = current HEAD); a dirty tree = a half-done item: inspect, then finish or discard it. Then review-package 04d55ff43..HEAD → opus reviewer → fix rounds → push → restart `npm run dev` (bg) → USER PING (contents in the dispatch line). Agent worktrees to clean afterwards via tools/dev/skymap-wt-clean.sh: agent-a0e1d9bb2b9d0429a (T19), agent-a5876189b0a0cf511 (T20), agent-ab3f090826d16fb03 (T21; holds perf-T21 raw), agent-aa968ac3bcf1abe24 (deletion audit), agent-a8d3e0da7a3b9f053 (radar), plus the single-writer plan's older agent worktrees. +- UPDATE before the switch: the fix wave FINISHED — all 12 landed, e7b0fdb2c..7c5aee771 (74 files, +415/−728; src code +10, src comments −87, tests −256, docs +25); suite 8757 green, real tsc clean, fixtures byte-identical, gate untouched. PUSHED camera-pivot @ 7c5aee771 UNREVIEWED (draft PR, sanctioned). Report: wave-end-fix-report.md. Judgement calls for the reviewer: only poseBasis went onto DriverCtx (upBasis resolves after produce, unread) and only 3 of the "5 lookups" were the frame basis (clip/tween authoring basis stays); cameraRuntimeSingleWriter.test.ts:73 allow-list LABEL still says "when state.cam is built" (stale prose, one-line follow-up — allow-list itself unchanged); two tests rewritten not deleted (liveRenderCamera stale-cam leak, wireInput target-copy guard now mutates the spy's array); tiltGain.ts/orientDecay.ts stay over the half-ratio (1 and 5 code lines). NEW SESSION FIRST ACTION is now: review-package 04d55ff43..7c5aee771 → opus reviewer (pass the report + the two audit files' sections) → fix rounds → push → restart dev server → USER PING. +- 2026-09-10 LUDENS SESSION RESUMED from .claude/rps/restart-prompt-2026-09-10_12-01.md. Tree clean @ 7c5aee771 = origin. Wave-end fix REVIEW dispatched (opus, read-only, package review-04d55ff43..7c5aee771.diff + fix report + radar/deletion sections) → report wave-end-fix-review.md. Dev server restarted (bg). On verdict: NEEDS FIX → fresh opus fix round in camera-pivot + scoped re-review; ACCEPTED → push (already pushed) → USER PING (contents in the dispatch line above). +- 2026-09-10 USER DIRECTIVE: surface band defaults 0.2/0.4 → 0.45/0.9 h/R (supersedes ruling 19 values; hysteresis stays 2×). src edit applied in surfaceRegime.ts; 4 tests (surfaceStep ×3, replayInput ×1) hard-coded notch counts to the old band → fix agent (opus, in camera-pivot) re-homing them on SURFACE_REGIME, one commit incl. src, report band-0.45-report.md. Golden traces unaffected (passed with the new band). Wave-end fix review still running in parallel. On DONE: cherry-nothing (agent commits in place) → typecheck → push → note spec/doc lines carrying old defaults for the ping. +- Wave-end fix REVIEW (opus) → wave-end-fix-review.md: NEEDS FIX narrow — 11/12 MATCH, fixtures byte-identical, judgement calls 4 ACCEPT + liveRenderCamera REJECT. C1 = the USER band re-tune (not the wave): settle trace 26→27 steps + poseFold disengage test. Band commit 5aa438961 (tests re-homed on SURFACE_REGIME) + trace RE-RECORDED a94706a45 (parse-compared: engage crossing dive 28→20, no key change) — re-record sanctioned by the user directive (Ruling: band change is a ruled behaviour change → trace re-record permitted; cost if wrong: trace no longer guards the pre-re-tune settles, git has the old fixture). Ruling: minors (a) roll passthrough (dead: InitialCam has no roll), (d) ReadonlyMap seams (= radar L2, out of scope), (h) hoist — NOT taken. USER RULING 2026-09-10: tilt blend (bodyUpWeight) gets its own TILT_BAND + 2 sliders, "blend only" (maxTiltRad stays on disengage); revises ruling 10; invariant zeroHR ≤ disengageHR. Fix round dispatched (opus, in camera-pivot, BASE a94706a45, brief tilt-band-fix-brief.md, units A–F one commit each) → report tilt-band-fix-report.md. On DONE: review-package 7c5aee771..HEAD → scoped re-review (opus) → push → USER PING. +- USER: merge origin/main into camera-pivot. Dry-run: 4 commits behind (#671 #674 #672 #673), CONFLICTS in engine.ts, runFrame.ts, slabs.ts, wireInput.ts (ContentLayer→ContentPass + splat ground prep vs single-writer runFrame). QUEUED behind the tilt-band fix round (dirty tree). Then: merge agent (opus, in camera-pivot; resolve, suite green, golden traces byte-identical vs pre-merge, tsc) → scoped re-review over 7c5aee771.. (branch-own commits only; merge commit excluded from the package) → push → USER PING. +- Tilt-band fix round DONE_WITH_CONCERNS: 7 commits 82c1446ad..c43b757a3 (A poseFold re-home, B log-space line, C liveRenderCamera observable, D minors b/c/e/f/g, E band prose+spec numbers, A2 7 more old-band literal tests re-homed [outside brief, kept — suite gate], F TILT_BAND + setTiltBand + 2 sliders, defaults = regime band). Suite 8760 green, fixtures hash-identical to a94706a45. Concerns: f32 half-ulp sightline nudge on abs→body flip in narrow altitude windows (pre-existing, unbacklogged → user ping); agent used git stash once (restored); spec Δtilt-at-flip figure not re-measured for 0.45/0.9. Report tilt-band-fix-report.md. NOW: scoped re-review (opus) on review-7c5aee771..c43b757a3.diff → wave-end-fix-rereview.md, IN PARALLEL merge agent (opus) origin/main → camera-pivot (pre-merge HEAD c43b757a3). +- Merge origin/main DONE: 6c02b9439 (parents c43b757a3 + 89d716438), 4 conflicts resolved in-commit (branch condensed prose kept, main renames ported; one hand-edited executable line runFrame.ts earthPass.enabled — reviewer eye), suite 8760 green, gate 5/5, fixtures byte-identical. NOT pushed — waiting on scoped re-review (opus, in flight) → push. Heading-reset diagnosis agent (opus, read-only, scratch wt) in flight → heading-reset-diagnosis.md. Debug-panel grill STARTED (Q1 open: what the panel answers first; rec = per-DOF current/target/residual). +- Scoped re-review (opus) ACCEPTED w/ follow-ups → wave-end-fix-rereview.md: 7/7 prior ADDRESSED, 0 Critical, 2 Important (N1 rememberedTilt.test.ts:153/181 standpoints keyed to regime edges → log-vs-lin test vacuous under narrow TILT_BAND; :161-168 loop must STAY on disengageHR; N2 invariant zeroHR ≤ disengageHR only on write → add load-time assertion in tiltBand.ts), 8 minors (ledgered in the file, deferred). Fix dispatched (opus, in camera-pivot, BASE 6c02b9439) → then PUSH. +- N1/N2 fix 9655c6810 (N2 guard homed in bodyUpWeight.ts — the two records import each other cyclically and neither body can see both defaults; report rereview-n1n2-report.md; third regime-keyed fixture = drag-wall test, left, = minor N9). PUSHED camera-pivot @ 9655c6810 (15 commits: band 0.45/0.9, trace re-record, fix round A–F+A2, merge main 6c02b9439, N1/N2). OPEN: heading-reset diagnosis agent in flight; debug-panel grill Q2 open; USER PING still owed (T21 land/park, T22 gate, deletion + radar rulings, clip authoring-validation, deriveBodyStates key type, f32 half-ulp flip nudge, Vite kill incident) — deliver after the heading diagnosis lands. +- Heading-reset DIAGNOSED (heading-reset-diagnosis.md): F1 = orientStepRad decays 25%/frame regardless of notch size (aggregator folds wheel events → one step/frame) → trackpad resets heading in ~90-180 ms; CONFIRMED by user (north-up off ⇒ no reset). F2 = blended-up reversal at the cancel locus flips the heading READOUT only (|lat|>60° ecliptic). Adjacent: absolute-arm wheel burst unclamped; unbacked tilt pops 37° at disengage — offered to user, unruled. F1 fix (decay ∝ log-zoom) OFFERED, not started. USER DIRECTIVE: TILT_BAND defaults 0.06/0.60 → agent (opus) dispatched: defaults + re-home + trace re-record (ruled) → tilt-band-defaults-report.md → push. User observation: north-up off keeps heading as roll when zoomed out = designed R1 behaviour, told. +- USER RULINGS 2026-09-10: T21 = LAND (perf ok). F1 = GO (brief f1-decay-per-logzoom-brief.md; dispatch after the tilt-band-defaults agent lands — both re-record the settle trace). Grill Q3 = engine-side per-frame delta + peak hold beside cameraDebugSnapshotOf (agreed). Grill continues (Q4 next). +- GRILL debug panel COMPLETE (9 Qs; transcript agent writing docs/grill-sessions/camera-debug-panel-2026-09-10.md): per-DOF current/target/residual/Δ/peak rows; engine-side per-frame delta record; endpoint rolls dropped; drawn log band bar with sliders under it; header = arm·driver·gesture; raw rows collapsed; degrees on screen, radians in dump; targets shown with (off) marker; LANDS ON THIS PR (user overruled own-branch rec). Build: dispatch after F1 (or in parallel in an isolation wt off camera-pivot HEAD) — brief to write from the transcript. +- Tilt-band defaults 0.06/0.60 DONE: 3229a164f + f0e41a488 (settle trace re-recorded 27→26 steps, labels diverge at idx 19 = one fewer recede notch; driver trace untouched); 18 tests re-homed on TILT_BAND (report tilt-band-defaults-report.md). Concerns → USER RULINGS: (1) drag-wall reconciliation UNREACHABLE at shipped defaults (ceiling ramp contains the blend band) → re-key maxTiltRad to TILT_BAND, or delete the wall; (3) zeroHR < engageHR would empty the world-arm∩in-band set (7 register-loop fixtures) — consider a floor zeroHR ≥ engageHR. Grill transcript committed ee75dce53. PUSHED @ ee75dce53. DISPATCHED in parallel: F1 fix (opus, IN camera-pivot, BASE ee75dce53, brief f1-decay-per-logzoom-brief.md → f1-decay-report.md) and debug-panel rebuild (opus, ISOLATION wt, branch debug-panel-rebuild off ee75dce53, brief debug-panel-brief.md → its wt report; cherry-pick onto camera-pivot after F1 lands). Then: review both (one package each), push, T22. +- 2026-09-10 ~12:35 USER RULING: "simplify it, and unbraid it" (orientation stack: three h/R ramps over tilt = regime band, tilt blend band, drag-wall ceiling; plus two decays, two trial toggles). Dispatched read-only opus design agent per `orientation-unbraid-brief.md` → output `orientation-unbraid-design.md` (radar + target design + deletion list + behaviour-change rulings + task plan). F1 agent left RUNNING (mid-edit in the same files; its per-log-zoom settle lands FIRST, un-braid goes on top). Debug-panel agent left RUNNING in its worktree (cherry-pick after un-braid; its snapshot fields may need re-fit). On design DONE: user checkpoint on the deletion list + behaviour changes, then execute as SDD tasks on camera-pivot. T22 feel gate moves AFTER the un-braid. +- 2026-09-10 ~14:55 Design DONE: `orientation-unbraid-design.md` (F1 wall accidental, F2/F3 knob+toggle freeze −544 post-T22, F4 gesture union, F5 drag cap, ≈−749 net). USER PING sent: rulings B1/B2 (wall), B4/B5 (toggles), B6 (sliders after T22). Debug-panel agent DONE: branch `debug-panel-rebuild` commits 43c772266 + de32c7560 in wt agent-a6987fd534f6c7e12, report `debug-panel-report.md`; NOT cherry-picked yet (after F1 + T-A land; re-fit: ceilingRad row dies, band bar + sliders die at T-F, per-frame cameraDofAnglesOf cost unmeasured). F1 agent still RUNNING. +- 2026-09-10 ~15:05 F1 agent DONE: bac4f435d (fix) + e9e2c03af (settle trace re-record; driver trace untouched), report `f1-decay-report.md`, NOT pushed. Concerns: (1) park no longer settles (by ruling, T22 checks), (2) u = raw notch not post-clamp spent zoom → provisional ruling: u = SPENT log-zoom, (3) zoom(0.5) spends 0.69 rad. Review dispatched (opus) → `f1-decay-review.md`, package review-ee75dce53..e9e2c03af.diff. On verdict: fix round if needed (fresh opus, include ruling 2), push, then un-braid T-A..T-D pending user rulings B1/B2/B4/B5/B6. +- 2026-09-10 ~15:25 F1 review = FIX ROUND (`f1-decay-review.md`): F1 u raw not spent (HIGH), F2 decay per-notch unbounded + envelope fixtures gone (HIGH), F3 rememberedTilt:214 tautological. Rulings R-A u = spent log-zoom from post/pre distance (derive inside frameAlignedRoll, spentZoomFactor util), R-B no extra cap, pin envelope at u=ln2 by test, R-C dither. Fix round 1 dispatched (fresh opus) → `f1-fix1-report.md`; then scoped re-review, push. +- 2026-09-10 ~15:50 F1 fix round 1 DONE: ffca44eeb (spentZoomFactor util at body-arm site; frameAlignedRoll keeps threaded u — deviation ACCEPTED: absolute-arm zoom unclamped so raw=spent) + 2039ea13f (envelope test at u=ln2). Report `f1-fix1-report.md`. Suite 8774 green, tsc clean (verified by controller), both fixtures + gate byte-identical. Scoped re-review dispatched → `f1-fix1-rereview.md`. On APPROVE: push camera-pivot. Adjacent unruled: "absolute-arm wheel burst unclamped" has NO backlog home (only in design.md:546) → include in user ping. +- 2026-09-10 ~16:05 F1 re-review APPROVE (`f1-fix1-rereview.md`); residuals N1-N3 applied ff48c5c53; PUSHED camera-pivot @ ff48c5c53. F1 CLOSED. Next: user rulings B1/B2/B4/B5/B6 → un-braid T-A (wall) + T-B/T-C/T-D (safe) → cherry-pick debug-panel-rebuild (43c772266, de32c7560; re-fit ceilingRad row) → T22 → T-F freeze → /feature-done. +- 2026-09-10 ~16:15 USER RULINGS: B1/B2/B3 (wall) = YES; B4/B5/B6 (toggles, sliders) = NOT SURE YET → deferred to after T22 (T-F stays parked). Dispatched opus implementer for T-A (wall delete + spec §6 amend) + trace re-record + T-B/T-D (gesture phase union, noteBody) + T-C (drag capRad); BASE ff48c5c53; report `unbraid-TA-TD-report.md`. On DONE: review-package ff48c5c53..HEAD → opus review → fix rounds → push → cherry-pick debug-panel-rebuild → T22 ping. +- 2026-09-10 ~16:25 USER: "pause now". Told T-A..T-D implementer to stop at a clean commit boundary (finish-if-near-green else revert uncommitted, never stash). On its PAUSED report: record committed SHAs + remaining tasks here; nothing else in flight. RESUME = re-dispatch the remaining T-A..T-D tasks from `orientation-unbraid-design.md` §5, then review-package ff48c5c53..HEAD. +- 2026-09-10 ~16:45 T-A..T-D implementer DONE before the pause landed: 884eacaf4 (T-A wall delete + spec §6), e7c6c92e2 (T-B/T-D gesture phase union), c99b0e01b (T-C dragLevelCapRad; notchLogZoom kept as calibration constant, 2 test readers). settleGoldenTrace re-record was BYTE-IDENTICAL (wall inert at every trace pose) → no fixture commit. Suite 8760 green, tsc clean, gate untouched. Net −198. NOT pushed, NOT reviewed. PAUSED here per user. RESUME = review-package ff48c5c53..c99b0e01b → opus review → fix rounds → push → cherry-pick debug-panel-rebuild (43c772266, de32c7560; re-fit: ceilingRad row gone, SurfaceMemory.pointerDown gone) → T22 ping (+ rulings B4/B5/B6, absolute-arm burst unclamped backlog home). +- 2026-09-11 RESUMED (user "you can continue"). Review dispatched (opus) for ff48c5c53..c99b0e01b → `unbraid-TA-TD-review.md`. Then fix rounds → push → cherry-pick debug-panel-rebuild → T22 ping. +- 2026-09-11 un-braid review = FIX ROUND (`unbraid-TA-TD-review.md`): (1) no witness for B1/B2 — golden trace drags all at h/R 2.4e-6 where the wall saturated, so byte-identical was forced; (2) spec §6 paragraph false on the drag path: look drag at h/R 0.9 → 45° unbacked tilt at the flip = SECOND ROUTE to the 37° pop (B7); (3) 4 stale wall comments, spec:346, notchLogZoom doc clause, report overstated. RULING: accept B2 semantics (next notch settles unbacked tilt; no ramp comes back), spec must be true on both paths, B7 second route → T22 checklist item (drag-tilt near disengage then zoom out, watch the flip). Fix round 1 dispatched (opus). +- 2026-09-11 un-braid fix round 1 DONE 8301cdd05 (witness test B1: look drag at disengage altitude → 45° where ceiling was 0; §6 trued; stale prose) + b0dfb6475 (seedRememberedTilt comment). PUSHED camera-pivot @ b0dfb6475. T-A..T-D CLOSED (net ≈ −175 after the witness). Dispatched opus: cherry-pick debug-panel-rebuild (43c772266, de32c7560) onto camera-pivot with re-fit + cameraDofAnglesOf microbench → `debug-panel-cherrypick-report.md`. On DONE: review-package b0dfb6475..HEAD → review → push → T22 ping (checklist += drag-tilt near disengage then zoom out; rulings B4/B5/B6; absolute-arm burst backlog home). +- 2026-09-11 debug-panel cherry-pick DONE: 79cd028d0 (engine record) + c67b3d25b (panel), re-fitted (no ceiling row; gesture three-state); suite 8777 green; microbench 20 µs/frame body arm, 40 µs absolute, ALWAYS ON (two 184-body walks). RULING: not acceptable as shipped — gate on panel mounted or reuse frame values; reviewer to pick. Review dispatched → `debug-panel-review.md`. Stale wall prose in northUpToggle.test.ts:5,81,83 — dies at T-F if B5=freeze, else fix. Then fix round → push → T22 ping. +- 2026-09-11 debug-panel review = FIX ROUND (`debug-panel-review.md`): F1 record unconditional above isReady (fix (a) mount gate ~18 lines; (b) impossible — bandRollTarget not per-frame), F2/F3 test tautologies, F4 band group not on the one model; heading target=0 KEEP. Fix round 1 dispatched (opus) → `debug-panel-fix1-report.md`; then scoped re-review → push → T22 ping. +- 2026-09-11 panel fix round 1 DONE: 1aed9215e (mount gate) + 09cc12a1c (tests); suite 8778 green; scoped re-review dispatched → `debug-panel-fix1-rereview.md`. On APPROVE: push → T22 ping. +- 2026-09-11 panel re-review APPROVE; nits 4c0c88c93; PUSHED camera-pivot @ 4c0c88c93. Panel CLOSED. T22 PING SENT (checklist below in the ping). Open rulings for the user after T22: B4/B5 (toggles), B6 (sliders), B7 (arm-entry adopts tilt), driverGoldenTrace byte bar keep/retire, radar H1/H3/M4/M5, backlog homes (absolute-arm burst, deriveBodyStates casts, clip per-endpoint validation, f32 half-ulp nudge). Then T-F (if ruled) → /feature-done. +- 2026-09-11 T22 USER ATTESTATION: items 1,2,3,5,6 CHECKED; 7 (debug panel) LOOKS GOOD; 4 (drag-tilt near disengage → zoom out, pop) NOT SURE, user will check others first. Rulings B4/B5/B6/B7 still open. +- 2026-09-11 USER RULING B4/B5/B6 = KEEP AND RE-HOME: band sliders + log blend + north-up move into Redux camera state, threaded into the math (radar H1 the threading way; T-F freeze is DEAD). Dispatched read-only opus design per `camera-tuning-slice-brief.md` → `camera-tuning-slice-design.md` (slice placement, threading table, net LOC, 2-4 tasks). On DONE: short user checkpoint on the shape, then execute tasks on camera-pivot (BASE 4c0c88c93), review, push. Golden traces byte-identical is a gate on every task. Still open: item 4 of T22, B7, driverGoldenTrace keep/retire, H3/M4/M5 backlog, 4 backlog homes. +- 2026-09-11 tuning design DONE (`camera-tuning-slice-design.md`): tuning field on camera slice (StepInputs carries RootState; settings rejected = mirror); cycle dies by deleting all three records → one import-free cameraTuning.ts + clampCameraTuning; 26 threading rows via existing ctx bags; net ≈ −60 (src ≈ −5); goldens byte-identical every task. RULING R1: drop the "(engage yielded)" readout clause. Dispatched opus for T1+T2 (BASE 4c0c88c93) → `tuning-T1T2-report.md`. Then: review T1+T2 ∥ dispatch T3+T4 (panel via useAppSelector/dispatch, R1, refs sweep, comment pass, deletion-audit) → review → push → user visual check of sliders/toggles + T22 item 4. +- 2026-09-11 tuning T1 3cb63f494 + T2 7b21505a0 DONE (T3 wiring rode T2: useAppSelector/dispatch, Provider tests; R1 applied = no yielded clause); suite 8782 green, goldens byte-identical, cycle greps pass; net −9 (design said −60). Dispatched opus T4 sweep (refs/spec prose, comment pass, madge, test tuning helper) → `tuning-T4-report.md`. Then ONE review package 4c0c88c93..HEAD → fix rounds → push → user visual pass on sliders/toggles + T22 item 4 → /feature-done (deletion-audit there). +- 2026-09-11 tuning T4 DONE 6ce2c013a (spec/plan prose, CameraBandBar/test comments, madge: only cycle in the camera folders is PRE-EXISTING bodyLikeFraming⇄focusFraming; helper declined; 5 stale plan maxTiltRad hits incl. live checkbox :1010 need a ruling). Suite 8782 green. Review dispatched (opus) for 4c0c88c93..6ce2c013a → `tuning-review.md`. Then fix rounds → push → user visual pass (sliders/toggles live) + T22 item 4 → /feature-done. +- 2026-09-11 tuning review APPROVE (`tuning-review.md`); residuals 20f3d6627 (F1-F4, plan :1010 restated structural); PUSHED camera-pivot @ 20f3d6627. Tuning slice CLOSED. USER PING: visual pass on sliders/toggles (live) + T22 item 4 → rulings B7, driverGoldenTrace keep/retire, H3/M4/M5 backlog, 4 backlog homes, pre-existing bodyLikeFraming⇄focusFraming cycle → /feature-done (deletion-audit there). +- 2026-09-11 USER ATTESTATION: sliders and toggles work live from Redux (T3 visual pass DONE). Still open: T22 item 4, rulings B7 / driverGoldenTrace / backlog yes-no. +- 2026-09-11 USER ATTESTATION T22 item 4: NO POP (drag-tilt near disengage → zoom out through flip). T22 COMPLETE (7/7). B7 → backlog (no feel need). Remaining rulings: driverGoldenTrace keep/retire; backlog yes-no list. Then /feature-done. +- 2026-09-11 RULINGS: driverGoldenTrace KEEP; adjacent findings → BACKLOG ALL (B7, absolute-arm burst, deriveBodyStates casts, clip per-endpoint validation, f32 half-ulp nudge, bodyLikeFraming⇄focusFraming cycle, radar H3/M4/M5). Starting /feature-done: (1) backlog entries agent ∥ whole-branch deletion audit (opus, legacy framing, fenced by wave-end `deletion-audit.md`); (2) DoD audit; (3) safe-now deletions commit; (4) completion moves + ledger archive + push; (5) user squash-merges #647. +- 2026-09-11 backlog entries committed 420cbd211 (7 detail files + index lines). NOTE: index clauses too long for the convention → trim to one terse clause each in the completion commit. Waiting: deletion-audit-final.md, feature-done-audit.md. +- 2026-09-11 DoD audit NOT READY on process only (`feature-done-audit.md`): checkboxes 0/43 ticked, Task 5 prose names dead maxTiltRad, single-writer plan straggler in plans/; tests+tsc PASS, 0 TODOs, smoke FOUND, parity up; branch 4 behind main. RULINGS: tick from ledger, restate Task 5 lines, move straggler, merge main. Dispatched opus completion agent (merge main → tick → moves + ledger archive + backlog trim → push). Deletion audit still running → safe-now bin = follow-up commit (ride if it lands before the push). Then USER squash-merges #647; worktree cleanup after. +- 2026-09-11 USER: at cleanup also remove the agent-* worktrees used for this work (memory saved). Cleanup set after merge: camera-pivot, earth-rtc-foundation, agent-a6987fd534f6c7e12 (debug-panel-rebuild, cherry-picked), agent-a0e1d9bb2b9d0429a, agent-a5876189b0a0cf511, agent-ab3f090826d16fb03, agent-aa968ac3bcf1abe24, agent-a8d3e0da7a3b9f053 + any older agent-* from this PR; branches debug-panel-rebuild, worktree-agent-*. diff --git a/docs/superpowers/plans/completed/2026-09-01-camera-pivot.md b/docs/superpowers/plans/completed/2026-09-01-camera-pivot.md new file mode 100644 index 0000000000..801bcf3ec5 --- /dev/null +++ b/docs/superpowers/plans/completed/2026-09-01-camera-pivot.md @@ -0,0 +1,1049 @@ +# Camera pivot (spec 2) — implementation plan + +Shipped 2026-09-11 via PR #647 (squash). Ledger: [`plans/completed/2026-09-01-camera-pivot.ledger.md`](2026-09-01-camera-pivot.ledger.md). + +> **Spec.** [`specs/2026-09-01-camera-pivot.md`](../specs/2026-09-01-camera-pivot.md) — the +> binding authority. Every ruling in it is settled; the plan implements, it does not +> re-open. Rationale for any decision lives in the ruling record +> ([grill session, incl. Addendum 2](../../grill-sessions/globe-camera-pivot-2026-08-24.md)). +> **Execution.** `subagent-driven-development` per +> [`conventions/sdd-execution.md`](../conventions/sdd-execution.md) — task list before +> Task 1, pipelined reviews, ledger archived on Finish. +> **Style.** [`conventions/plan-style.md`](../conventions/plan-style.md) — contract code +> only. Read the current file before editing it; nothing here is a snapshot to copy. + +## Ground preparation + +**Done — shipped and merged.** P5 (optional `roll` on `CameraPose`, plumbed through +`poseOf` / `assembleOrbitCamera` / `reencodePose` / `frameContext`'s `cam.roll ?? 0` +and the dropper sweep) and P6 (`orbitControls` reduced to a pure gesture recognizer; +`src/services/engine/subsystems/inputAggregator.ts` run-based fold; +`src/services/engine/frame/drainInput.ts` applied at the top of `runFrame` above the +driver table's `getState()`; `beginDrag` / `cancelCameraTween` fired at DOM time in +the `wireInput` emit sink) landed on `main` as PR #648 (`725c6ddc7`) and are merged +into this branch. Spec §2 records them, including the accepted deviation: the +controller still mutates the OrbitCamera register at the drain, and the returns-pose +shape is deferred to this feature's surface controller (Task 16). + +No further prep. Every gesture primitive the spec composes (`quatFromAxisAngle`, +`multiplyQuat`, `rotateVec3ByQuat`, `mat3FromColumns`, `normalize3`, `cross3`, +`smoothstep`, `raySphereRoots`) exists in `src/utils/math/`. + +## Strategy + +Main has **no** surface-camera machinery (PR #623 closed unmerged), so this is +net-additive. The order below is forced by one property: the union type and its +conversion must exist and be proven lossless before any consumer can be migrated, +and every consumer must be arm-aware before the fold is allowed to produce a body +arm. Concretely: + +1. **Phase 1** builds the data delta and every pure primitive with no consumer at + all. Nothing in the running engine changes; the suite is green throughout for a + boring reason. +2. **Phase 2** lands the union (`camera.base: FramedCameraPose`) as a + **behaviour-identical, mechanical** migration — no body arm can be constructed + yet, so every gate added is trivially true — then provider B, then the fold that + first makes a body arm reachable. +3. **Phase 3** puts gestures behind the priority-100 driver slot the SpaceMouse + driver vacated. +4. **Phase 4** migrates the late consumers: the `lonLatFocusPose` instrument, and + keyframe/tour/serialization frame tagging. +5. **Phase 5** is the perf comparison and the user's feel gate. + +Three shapes are pinned here because the spec's §5.1 signature is indicative and the +plan must be exact — see each task: + +- The conversions take the orientation-frame bases (`poseBasis`, `upBasis`), without + which `yaw`/`pitch`/`roll` are undefined and the round trip cannot be lossless. +- `CameraDriver.pose` returns `FramedCameraPose`; `resting` returns `base` + untouched, the world-only drivers gate their `isActive` on the absolute arm. +- Exactly **one** world-arm resolution per frame (`resolveWorldArm`, called right + after the fold); every world-shaped reader downstream takes that value. The + authoritative `cameraRuntime.lastPose` stays framed; + `helpers/liveWorldPose.ts` is the single off-frame resolution site. + +## Global constraints + +Binding on every task; do not restate them in commit messages. + +- `npm test` and `npm run typecheck` green **at the end of every task**. A task that + cannot leave the suite green is mis-sized — stop and report. +- **Behaviour outside the engage band is identical to `main`.** Anything a user can + observe above the tuning's `disengageHR` — framing, drag rates, wheel routing, + tour playback, boot pose — must not move. Phase 2 in particular is a mechanical + migration: if a Phase 2 task changes a rendered pixel, it is wrong. +- **No renderer diff.** No slab, layer, shader, tile-planner or `.wesl` file is + touched (spec §1 non-goals). A renderer file in a task's diff is a review failure. +- `type` aliases, never `interface`. One exported type per file in `src/@types/`, one + exported function per file in `src/utils/` — filename = symbol name, deep relative + imports, no barrels. `src/services/**` files may export a small related set (the + conversion pair is the example). +- Comments per [`conventions/comments.md`](../conventions/comments.md): module header + ≤ 10 lines, comment lines ≤ half the code lines. Record units, frames, landmines + and cross-file contracts; link the spec instead of summarising it. +- Tests per [`conventions/testing.md`](../conventions/testing.md). Specifically for + this plan: **no** runtime tests of the new `.d.ts` shapes, **no** restatement of + `CameraTuning` defaults, and **no mirror tests** — every conversion/gesture + expectation is a hand-computed value, a round trip, or an independent invariant. + The one permitted structural greps are the two the spec's acceptance criteria name + (§11): no stored regime flag, and the amended one-seam importer list. +- All engaged-path arithmetic is SI metres, f64. No field carrying metres may be + `Mpc`-suffixed, and vice versa. + +## Phase 0 — baseline + +### Task 1: perf baseline + +**Files:** none (measurement only). + +Read `.claude/skills/perf/SKILL.md` first. Start this worktree's own dev server and +take the `Local:` port from **its** output — running `npm run perf` without +`--url http://localhost:` silently measures whatever other branch's server is +up, which is the standing trap. + +- [x] `npm run perf -- --url http://localhost:`, default poses. +- [x] Record the full MERGED / PER-LAYER / FLOOR output verbatim in the SDD ledger + under `Task 1 baseline` (Task 21 diffs against it; a summarised baseline is + not comparable). +- [x] No commit. + +## Phase 1 — data delta and pure primitives + +Nothing in this phase has a consumer. Each task is a new file plus its test. + +### Task 2: pose-frame types and the regime constants + +**Files (new):** `src/@types/camera/PoseFrame.d.ts`, +`src/@types/camera/BodyFixedPose.d.ts`, `src/@types/camera/FramedCameraPose.d.ts`, +`src/data/camera/surfaceRegime.ts` + +Copy the four declarations from spec §3 exactly — field names, units and the +`readonly` markers are the contract the rest of the plan is written against. +`BodyId` comes from `src/@types/data/body/BodyId`; `Vec3` / `Mat3` from +`src/@types/math/`. + +`SURFACE_REGIME` (spec §3): `engageHR: 1.7`, `disengageHR: 3.4`, `tiltFullHR: 0.02`. +`engageHR` / `disengageHR` are **ruled** (Q6, Q5). `tiltFullHR` is **open until the +Task 22 feel gate** — say so in one line beside it, and nowhere else. The tilt +ceiling is gone: display tilt is `remembered × bodyUpWeight(h/R, tuning)`, which is +0 at and above `tuning.tiltZeroHR ≤ tuning.disengageHR`. + +- [x] Write the four files. No tests: type declarations and a constant table are + exactly what `testing.md` forbids restating at runtime. +- [x] `npm run typecheck`. +- [x] Commit. + +### Task 3: the two conversions + +**Files (new):** `src/services/engine/camera/poseFrameConversion.ts`, +`tests/services/engine/camera/poseFrameConversion.test.ts` + +**Signatures** (this supersedes spec §5.1's indicative form — `yaw`/`pitch`/`roll` +are defined only against the orientation-frame bases, so a conversion without them +cannot round-trip): + +```ts +export function toBodyArm( + pose: CameraPose, + poseBasis: Readonly, + upBasis: Readonly, + bodyId: BodyId, + bodyState: BodyState, +): BodyFixedPose; + +export function toWorldArm( + pose: BodyFixedPose, + bodyState: BodyState, + poseBasis: Readonly, + upBasis: Readonly, + bodyRadiusM: number, +): CameraPose; // carries `roll` — spec §12-R1 +``` + +**Behaviour.** `toBodyArm`: `eyeRel = orientationᵀ · (camPosMpc − bodyPosMpc) · +MPC_TO_M`, basis through the same rotation, `anchorLocalM = [0,0,0]` (spec §5.3 — the +first landing anchors at the body centre). It captures **nothing** time-dependent: no +epoch, no snapshot (spec §5.1 — this is why a fast clock cannot move the engaged +path). The world eye and camera basis are derived the same way +`frameContext.ts:212-228` derives them (`updatePosition` maths + `imagePlaneBasis` +with `cam.roll ?? 0` and `frameUp(upBasis)`) — read that site, do not re-invent the +basis convention. `toWorldArm` inverts it, then re-derives the orbit +parameterization: `target` on the forward axis at the range to the point under the +screen centre (`raySphereRoots` against the body sphere at `bodyRadiusM`; **a ray +that misses ⇒ target at the body centre**, the pivot-pin-compatible choice, which the +tilt ceiling makes the only reachable case at the disengage boundary), `yaw`/`pitch` +from the eye direction via `orbitAnglesLookingAlong`, `distance = |eye − target|`, +`roll` from the residual screen-up rotation. + +This module and `bodyRelativePose.ts` are the only two permitted importers of +`SCALE_UNITS.MPC_TO_M` / `.M_TO_MPC` in the camera path (spec §10; Task 4 enforces). + +**Tests** — all fixtures hand-built, none derived with the functions under test: + +- `toWorldArm(toBodyArm(pose)) round-trips eye, forward and screen-up over Earth` +- `…round-trips over a body with a tilted pole and a non-identity orientation` — + the FW-F reviewer's fixture shape; catches the quaternion-order landmine (spec §11). +- `…round-trips a rolled pose` — a pose with non-zero `roll`, the §12-R1 case. +- `…round-trips at a moon's radius` — nothing is Earth-typed. +- Exactness bar: eye, forward and screen-up agree to within provider A's ~14 µm floor. +- `toBodyArm agrees with bodyRelativePose at the same camera` — assert + `anchorLocalM + eyeRelAnchorM === eyeRelBodyM` and `basisLocal === basisM` from + `bodyRelativePose` for the same inputs, to that same floor (spec §5.2). + +- [x] Write the tests, watch them fail, implement, `npm test -- poseFrameConversion`. +- [x] Commit. + +### Task 4: amend the one-seam importer test + +**Files (modify):** `tests/services/engine/camera/oneMpcSeam.test.ts` + +The current sweep covers `src/services/engine/frame` and +`src/services/gpu/renderers/bodies` — the **render** path. Spec §10 amends the gate to +the engaged **camera** path: extend `TS_FILES` with `walk('src/services/engine/camera')`, +`walk('src/services/camera')` and `walk('src/utils/camera')`, and add the existing +legitimate uses to `SCALE_UNITS_ALLOW_LIST` with a category justification each — +`bodyLikeFraming.ts`, `cameraDrivers.ts:332` and `pivotRadiusMpc.ts` are all the +radius→Mpc **framing-bridge** category, not pose math. `bodyRelativePose.ts` and +`poseFrameConversion.ts` are the two **seams**: exclude them from the sweep the way +`bodyRelativePose` is excluded today, and extend the first `it` to assert both. + +Do not relax the existing three-file cull/fade allow-list (spec §10). + +- [x] Extend the sweep + allow-list; add `poseFrameConversion.ts` to + `KNOWN_ANCHOR_FILES`'s spirit (assert the new dirs found real files, or the + glob can silently sweep zero and pass vacuously). +- [x] `npm test -- oneMpcSeam` — green, and demonstrably failing if you temporarily + add a `SCALE_UNITS.M_TO_MPC` use to an un-listed camera-path file. +- [x] Commit. + +### Task 5: the tilt ceiling + +**Files (new):** `src/utils/camera/bodyUpWeight.ts`, +`tests/utils/camera/bodyUpWeight.test.ts` — the un-braid ruled the standalone +`maxTiltRad` away, so the weight ramp is the shipped subject of this task. + +**Signature:** `bodyUpWeight(hOverR: number, tuning: CameraTuning): number` +**Behaviour:** the tilt ceiling is gone — display tilt is `remembered × +bodyUpWeight(h/R, tuning)`, which is 0 at and above `tuning.tiltZeroHR ≤ +tuning.disengageHR` (spec §6). Note the edge order — `smoothstep(edge0, edge1, x)` +with `edge0 > edge1` is the descending ramp, and it looks like a +transposed-argument bug to a reader who does not know it is deliberate. One +comment line. + +**Tests:** + +- The tilt ceiling is gone: display tilt is `remembered × bodyUpWeight(h/R, tuning)`, + which is 0 at and above `tuning.tiltZeroHR ≤ tuning.disengageHR` — the Q4 identity + and a spec §11 acceptance criterion. +- `bodyUpWeight is 1 at and below tiltFullHR` — full remembered tilt survives. +- `bodyUpWeight crosses 0.5 near 1.71 R` — the midpoint of the two edges (spec §6); + assert the crossing lies between 1.6 and 1.8, not an exact value, so a feel-gate + tweak to `tiltFullHR` does not make this a tollbooth. +- `bodyUpWeight is monotonically non-increasing in h/R` over a sampled sweep. + +- [x] TDD, `npm test -- bodyUpWeight`, commit. + +### Task 6: re-anchoring + +**Files (new):** `src/utils/camera/reanchoredPose.ts`, +`tests/utils/camera/reanchoredPose.test.ts` + +**Signature:** `reanchoredPose(pose: BodyFixedPose): BodyFixedPose` +**Behaviour** (spec §5.3): move the anchor toward the eye and subtract the same +delta, so the pair still names the same body-fixed point while both stored magnitudes +shrink. The shift is **quantized to the ulp of the anchor's own magnitude** before it +is applied — that is what makes both updates exact. Trigger: range below a +magnitude-relative fraction of `|anchorLocalM|`, so the rule is body-independent and +needs no per-body constant. Below the trigger, return the input **by reference**. + +Not reached at the shipped descent floor with a centre anchor (spec §5.3) — it is +built and tested now because the deep-zoom anchor is wanted and this is not being +redone later. Say that in one header line; do not explain the floor here. + +**Tests:** + +- `reanchoredPose leaves the named point unmoved` — `anchor + eyeRel` is + **bit-identical** before and after, not close-to. +- `reanchoredPose shrinks |eyeRelAnchorM|` for a pose past the trigger. +- `reanchoredPose returns the input unchanged below the trigger` (reference equality). + +- [x] TDD, commit. + +### Task 7: the cursor ray + +**Files (new):** `src/utils/camera/cursorRayBodyLocal.ts`, +`tests/utils/camera/cursorRayBodyLocal.test.ts` + +**Signature** (spec §6): + +```ts +export function cursorRayBodyLocal( + pose: BodyFixedPose, + pixel: Vec2, + viewportPx: Vec2, + fovYRad: number, +): { readonly originM: Vec3; readonly dir: Vec3 }; +``` + +Built from `basisLocal` and the FOV directly — **no matrix inverse**, so it cannot +drift from the slab's vp. `pixel` and `viewportPx` are CSS pixels. `originM` is the +eye in body-fixed metres (`anchorLocalM + eyeRelAnchorM`); `dir` is unit. + +**Tests** (hand-computed): + +- `the screen-centre pixel rays along the pose forward axis` +- `a pixel at the top edge rays at fovY/2 above forward` +- `a horizontal edge pixel rays at the aspect-scaled half-angle` — catches the + aspect term being applied to the wrong axis, the classic form of this bug. +- `dir is unit for an off-axis corner pixel` + +- [x] TDD, commit. + +### Task 8: the surface readout + +**Files (new):** `src/@types/camera/SurfaceReadout.d.ts`, +`src/utils/camera/surfaceReadoutOf.ts`, `tests/utils/camera/surfaceReadoutOf.test.ts` + +Type verbatim from spec §3 — including that `tiltRad` is measured from local +**NADIR** (0 = straight down, π = zenith), never Cesium's complementary pitch. The +datum is in the field name deliberately; keep it. + +**Signature:** `surfaceReadoutOf(pose: BodyFixedPose, bodyRadiusM: number): SurfaceReadout` +**Behaviour:** KML `LookAt` semantics evaluated in the ENU of the point under the +screen centre. `altitudeM` is `|eye| − R` — **eye-based, never pivot- or +target-derived** (FW-A, spec §11). + +**Tests:** + +- `altitudeM is |eye| − R, not derived from the range` (**FW-A**) — construct a pose + whose sightline range differs sharply from its altitude and assert the altitude. +- `standpoint is the sub-camera lon/lat at nadir` — hand-computed against + `directionToLonLatDeg`'s convention. +- `heading falls back to the up vector within ~0.08° of vertical` — the nadir escape + (spec §14). +- `readout is finite and continuous stepping across the pole` — the pole escape. +- `tiltRad is 0 looking straight down and π looking at the zenith` — pins the datum. + +- [x] TDD, commit. + +### Task 9: the descent floor in metres + +**Files (new):** `src/utils/camera/surfaceFloorM.ts`, +`tests/utils/camera/surfaceFloorM.test.ts` + +**Signature:** `surfaceFloorM(bodyRadiusM: number): number` +**Behaviour:** `bodyRadiusM * SURFACE_STANDOFF_RADII`, importing that constant from +its **single existing declaration** (`src/utils/camera/clampDistance.ts:47`) — spec +§10's requirement is that the two arms cannot disagree about where the ground is, so +re-declaring the ratio in metres is the specific thing forbidden here. + +The floor is unconditional and is resampled **after the last position write** of a +gesture (spec §6, landmine O §4) — the gesture tasks own that ordering; this task +owns only the value. + +**Test:** `surfaceFloorM tracks the shared standoff ratio` — assert +`surfaceFloorM(R) / R === SURFACE_STANDOFF_RADII` for two different radii. (Not a +constant restatement: the fact under test is that the two arms read one declaration.) + +- [x] TDD, commit. + +### Task 10: anchored drag rotation + +**Files (new):** `src/utils/camera/anchoredDragRotation.ts`, +`tests/utils/camera/anchoredDragRotation.test.ts` + +**Signature:** + +```ts +export function anchoredDragRotation( + pose: BodyFixedPose, + prevRay: { readonly originM: Vec3; readonly dir: Vec3 }, + currRay: { readonly originM: Vec3; readonly dir: Vec3 }, + anchorRadiusM: number, +): BodyFixedPose | null; // null ⇒ a ray missed the frozen sphere; caller degrades to trackball +``` + +**Behaviour** (spec §6a): intersect both rays with the **frozen** pick sphere of +radius `anchorRadiusM` (`raySphereRoots`), then rotate the pose — **position and +basis together** — by the quaternion carrying `p̂₀` to `p̂₁` +(`quatFromAxisAngle` + `rotateVec3ByQuat`; rebuild the basis columns and +`reorthonormalise`). Pole-free and identical at every latitude: there is no +`cos(latitude)` term to be wrong, and dragging over the pole is an ordinary rotation +with a near-equatorial axis. At grazing incidence (`|ray·normal| < 0.05`) a rotation +is a teleport, so return `null` and let the caller strafe in the plane through the +anchor (spec §6a, C §2.8) — a **hard test**, never a blend, which would be a second +path hiding drift. The grazing threshold is feel-open until Task 22. + +**Tests** (**FW-I** — the drag-exactness criterion): + +- `a two-ray drag at the equator puts the grabbed point back under the cursor` — + re-project the rotated anchor point through `cursorRayBodyLocal` and assert it + lands on the current pixel to sub-pixel tolerance. +- `…at 80° latitude` and `…dragging across the pole` — same assertion, hand-built + fixtures; these are the cells probe defect 1 failed. +- `the rotation carries the basis, not only the position` — assert screen-up after + the drag is the parallel transport, not the original vector. +- `a grazing ray returns null` (|ray·normal| under the threshold). +- `a ray that misses the frozen sphere returns null`. + +- [x] TDD, commit. + +### Task 11: anchored zoom step + +**Files (new):** `src/utils/camera/anchoredZoomStep.ts`, +`tests/utils/camera/anchoredZoomStep.test.ts` + +**Signature:** + +```ts +export function anchoredZoomStep( + pose: BodyFixedPose, + factor: number, + cursorAnchorM: Vec3 | null, // null ⇒ centre-directed + bodyRadiusM: number, +): BodyFixedPose; +``` + +**Behaviour** (spec §6b): `eye′ = anchor + factor · (eye − anchor)` — **stateless per +tick, no accumulator** (FW-B). The distance _measure_ comes from the screen centre; +the _anchor_ comes from the cursor. Approaching with a cursor hit anchors on the +cursor; **zoom-out, and any cursor miss, is always centre-directed** (FW-H — the +cursor anchor is a repelling fixed point on the way out, and the offset it +accumulates is `altitude · tan(off-axis)` at every scale, i.e. geometry, not a +storage artefact). Guards: clamp the step **magnitude on both signs**; force a fresh +anchor pick after an overshoot past the anchor's tangent plane (report that to the +caller via the returned pose being past the plane — the controller re-picks); gate +the approach on **closing distance**, never absolute altitude. Floor the result with +`surfaceFloorM(bodyRadiusM)`. + +**Tests:** + +- `260 notches out and back with the cursor parked returns to the starting pose` + (**FW-H**, spec §11) — bit-comparable to a tight tolerance, no drift. +- `zoom is stateless: the same input pose and factor give the same output twice` + (**FW-B**) — and no module-level mutable state exists to make it otherwise. +- `zooming out ignores the cursor anchor` — the same out-step with and without a + cursor anchor produces the same pose. +- `an approach step never goes below the surface floor`. +- `an oversized factor is clamped on both signs` (**FW-D**'s bounded-step half). + +- [x] TDD, commit. + +### Task 12: the regime predicate + +**Files (new):** `src/services/engine/camera/regimeArmFor.ts`, +`tests/services/engine/camera/regimeArmFor.test.ts`, +`tests/services/engine/camera/noStoredRegimeFlag.test.ts` + +**Signature:** + +```ts +export function regimeArmFor( + current: PoseFrame, + eyeMpc: Readonly, + bodyStates: ReadonlyMap, +): PoseFrame; +``` + +**Behaviour** (spec §4, §12-R2): the discriminant is `h/R` with `h = |eye − body| − R` +— **eye-based**, never pivot-derived (FW-A). Radii come from `SCENE_BODIES` +(`radiusM`); the roster is the intersection of that registry with the `bodyStates` +map the frame derived, so the predicate is body-blind (`argmin h/R`) and reads +**geometry only** — never focus, never the drag mode, never a render path. Hysteresis +falls out of `current`: from `'absolute'` the test is `min(h/R) < engageHR`; from a +body arm it is `h/R > disengageHR` **for that body**. There is no boolean anywhere — +`camera.base.frame` _is_ the regime. + +The gesture-in-flight rule (spec §4: no flip during an active gesture) belongs to the +**caller** (Task 15), not here: this function stays a pure geometric read. + +**Tests:** + +- `engages the nearest body below 1.7 R` and `holds the world arm above it`. +- `holds an engaged body arm until 3.4 R` — the hysteresis, from both directions. +- `picks the minimising body when two are close` — an unfocused flyby engages + (spec §12-R2); assert focus is not an input by passing none at all. +- `is body-blind: a small moon engages at its own 1.7 R` — no Earth-typed constant. +- **`noStoredRegimeFlag.test.ts`** — the spec §11 grep criterion, written as an + import-graph / declaration scan in the shape of `oneMpcSeam.test.ts` (ts-morph, not + a substring search): no module under `src/state/`, `src/services/engine/camera/`, + `src/services/camera/` or `src/@types/camera/` declares a boolean field or variable + whose name matches `/surface|regime|engaged/i`. Name the rule in the header: the + arm tag is the only discriminant, and an inconsistent pair must stay + unrepresentable. + +- [x] TDD, commit. + +## Phase 2 — the union, provider B, and the fold + +### Task 13: `camera.base` becomes `FramedCameraPose` + +**Files (modify):** `src/@types/camera/CameraState.d.ts`, +`src/@types/engine/state/CameraRuntime.d.ts`, +`src/@types/engine/camera/CameraDriver.d.ts`, `src/state/camera/cameraSlice.ts`, +`src/state/camera/selectors.ts`, `src/state/camera/watchOrientationChangeSaga.ts`, +`src/state/camera/watchFlyToLonLatSaga.ts`, `src/state/camera/orientationActions.ts`, +`src/state/perf/installPerfHook.ts`, `src/services/engine/camera/cameraDrivers.ts`, +`src/services/engine/camera/applyWheelZoom.ts`, +`src/services/engine/camera/applyFocusedBodyPivot.ts`, +`src/services/engine/frame/runFrame.ts`, `src/services/engine/frame/drainInput.ts`, +`src/services/engine/phases/wireInput.ts`, `src/services/engine/engine.ts`, +`src/services/engine/wiring/buildDemandCtx.ts`, +`src/services/engine/helpers/liveRenderCamera.ts`, +`src/services/engine/animation/playClip.ts`, `src/services/engine/subsystems/clipPlayer.ts`, +plus the `tests/` mirrors of each. +**Files (new):** `src/utils/camera/absoluteArm.ts`, +`src/services/engine/helpers/liveWorldPose.ts`, +`src/utils/camera/eyeMpcOf.ts` + +The spec's honest cost (§12-T2): every reader of `camera.base` becomes frame-aware. +It is bounded and enumerable, and this task is where it is paid — **mechanically**. +No body arm can be constructed until Task 15, so every gate added here is trivially +true and no behaviour moves. + +**Contract:** + +```ts +// src/@types/camera/CameraState.d.ts +base: FramedCameraPose; // was CameraPose + +// src/@types/engine/state/CameraRuntime.d.ts +lastPose: { + current: FramedCameraPose; +} // the AUTHORITATIVE produced pose + +// src/@types/engine/camera/CameraDriver.d.ts +pose: (s: RootState, cam: OrbitCamera, elapsedMs: number) => FramedCameraPose; + +// src/utils/camera/absoluteArm.ts — the mechanical wrapper at world-arm producers +export function absoluteArm(pose: CameraPose): FramedCameraPose; + +// src/services/engine/camera/poseFrameConversion.ts — added export +export function resolveWorldArm( + framed: FramedCameraPose, + bodyStates: ReadonlyMap, + poseBasis: Readonly, + upBasis: Readonly, +): CameraPose; + +// src/services/engine/helpers/liveWorldPose.ts — the ONE off-frame resolution site +export function liveWorldPose(state: EngineState): CameraPose; + +// src/utils/camera/eyeMpcOf.ts — the eye the regime predicate reads +export function eyeMpcOf(pose: CameraPose, poseBasis: Readonly, out?: Vec3): Vec3; +``` + +**Rules for the migration:** + +- `commitCameraPose` takes a `FramedCameraPose`. World-arm dispatch sites wrap with + `absoluteArm(...)` — that is the whole diff at most of them. +- `selectCameraBase` returns the `FramedCameraPose` (spec §9). Readers that are + world-arm concerns by nature read through `resolveWorldArm` / `liveWorldPose`. +- Driver rows: `resting` returns `s.camera.base` **unchanged** (arm-agnostic, still + the floor and still always active). `orbitDrag`, `autoRotate` and `followBody` are + world-arm producers — wrap with `absoluteArm` **and** add + `s.camera.base.frame === 'absolute'` to their `isActive` (spec §7: the follow + driver's approach ease and idle hold have no meaning once the state co-rotates). + `tween` / `clip` keep producing absolute for now; Task 20 gives them frame tags. +- `applyWheelZoom` and `applyFocusedBodyPivot` are world-arm-only (spec §7). Give + each an explicit early return on a body arm rather than letting a caller remember + — the pin has nothing to do in a co-rotating frame, and the wheel's three distance + owners are simply not consulted. +- `eyeMpcOf` is extracted from `updatePosition`'s maths and `updatePosition` + **delegates to it**, so the eye has one derivation. Keep `updatePosition`'s + allocation-free contract: it passes its module scratch as `out`. +- `liveWorldPose` resolves `cameraRuntime.lastPose.current` against + `deriveBodyStates(cameraRuntime.lastRenderedSimDays.current)` — the pick path's + epoch rule (see `CameraRuntime.d.ts`'s `lastRenderedSimDays` note) is why it reads + that field and not a fresh clock sample. `liveRenderCamera`, `buildDemandCtx`, + `engine.ts`'s `getLivePose`, `drainInput`'s gesture seed and `cameraDrivers`' + `followFrom` capture all route through it — no second resolution site. + +**Tests:** the existing suite is the gate. Add exactly two: + +- `resolveWorldArm returns the absolute arm's pose by reference` — the idempotence + that makes the fold free on world-arm frames. +- `liveWorldPose resolves a body arm at the last RENDERED sim epoch` — a body-arm + `lastPose` plus a clock that has moved on; the resolved pose must use the rendered + epoch, not the current one. + +Update existing tests only where the type forces it (wrap fixtures in +`absoluteArm`). A test whose **assertion** changes in this task is a signal the +migration moved behaviour — stop and report. + +- [x] Migrate, `npm test` (full suite), `npm run typecheck`. +- [x] Commit. + +### Task 14: provider B behind the pose seam + +**Files (new):** `src/utils/camera/poseFromBodyArm.ts`, +`tests/utils/camera/poseFromBodyArm.test.ts` +**Files (modify):** `src/services/engine/frame/frameContext.ts`, +`tests/services/engine/frame/frameContext.test.ts` + +**Signature:** `poseFromBodyArm(pose: BodyFixedPose): BodyRelativePose` — +`eyeRelBodyM = anchorLocalM + eyeRelAnchorM`, `basisM = basisLocal`. The anchor fold +is the whole conversion; the ~nm floor is the point of it. + +At the existing seam (`frameContext.ts:212-233`, the `bodyPose` closure) branch per +spec §5.2: the engaged body gets provider B, **every other body keeps provider A** +(ruled, S1), and on approach from deep space the camera is heliocentric regardless. +`deriveFrameContext` needs the arm — thread the `FramedCameraPose` in beside the +already-resolved world pose rather than re-resolving it. + +Unreachable until Task 15 flips the fold on; that is deliberate — it lands tested and +inert so the fold's task is a one-concern change. + +**Tests:** + +- `provider B and provider A agree at the engage boundary` — same camera, both + providers, agreement to provider A's ~14 µm floor (spec §5.2's stated unit test). +- `provider A still serves every body that is not the engaged one` — a two-body + frame with one arm engaged. +- `poseFromBodyArm folds the anchor exactly` — a non-zero anchor, hand-computed. + +- [x] TDD, commit. + +### Task 15: the fold + +**Files (modify):** `src/services/engine/frame/runFrame.ts`, +`tests/services/engine/frame/runFrame.test.ts`, plus a new +`tests/services/engine/frame/poseFold.test.ts` + +Steps 5–6 of spec §7, **last**: below driver arbitration, after every pose writer for +the frame, at exactly one site. FW-G's round-1 finding was that a commit-on-edge +above the fold discards it and the wrong writer wins — so the fold goes **after** +step 3b (pivot pin) and **before** step 4 (`lastPose.current = …`) and the frame +context derivation. Read `runFrame.ts:359-490` before editing; the ordering comments +there are the contract you are extending. + +Shape (prose, not a snippet to paste): resolve this frame's world pose once via +`resolveWorldArm`; derive the eye with `eyeMpcOf`; **skip the predicate entirely +while a gesture is in flight** (`rootState.camera.dragging`) and re-evaluate at +gesture end (spec §4 — this subsumes FW-C's mid-drag wheel guard and FW-D's +gesture-scoped latch); otherwise call `regimeArmFor` and normalize `renderPose` to +the resulting arm with the Task 3 conversion pair, which is a no-op by reference when +the arms already agree. Then the existing step 4 and the scale-bar snap and +`deriveFrameContext` all consume the **resolved world pose** computed here — one +resolution per frame, no second call. + +**Tests:** + +- `the fold runs after the pivot pin and before lastPose is updated` (**FW-G**) — + assert the call order via spies, the way the existing commit-on-edge tests do. +- `a gesture in flight cannot change the arm` (spec §11) — dragging across the + engage threshold leaves `frame` untouched; releasing re-evaluates. +- `crossing the engage threshold does not move the rendered camera` — eye, forward + and screen-up on the frame before and the frame after agree to the ~14 µm floor. + This is the **no-snap** acceptance criterion. +- `the pivot pin and the follow driver are inert in a body arm` (spec §14). +- `the wheel does not route through applyWheelZoom in a body arm`. + +- [x] TDD, full suite, commit. + +## Phase 3 — gestures + +### Task 16: the surface controller + +**Files (new):** `src/@types/camera/SurfaceGesture.d.ts`, +`src/services/camera/surfaceController.ts`, +`tests/services/camera/surfaceController.test.ts` +**Files (modify):** `src/services/engine/frame/drainInput.ts`, +`src/services/engine/camera/cameraDrivers.ts` + +`SurfaceGesture` verbatim from spec §3 — per-gesture, latched at gesture start, dead +at pointerup (ruled, Q3). `anchorRadiusM` is the **frozen** pan sphere (`|first +pick|`); `anchorLocalM` is body-fixed, **never world** (C landmine #5); `prevPixel` is +the previous **frame's** end pixel, not the press point (C §2.1 — the aggregator's +`InputStep.drag` already carries `startPx`/`endPx` in exactly that encoding; read +`src/@types/camera/InputStep.d.ts`). + +**Controller contract:** + +```ts +export function createSurfaceController(): { + readonly apply: ( + arm: BodyFixedPose, + step: InputStep, + viewportPx: Vec2, + fovYRad: number, + bodyRadiusM: number, + ) => BodyFixedPose; + readonly onGestureStart: () => void; + readonly onGestureEnd: () => void; +}; +``` + +Mode selection (spec §6, C §5.1): **what the cursor is over** decides; altitude is +only a tiebreak. Cursor hits the body → anchored pan at any altitude (Task 10); +cursor misses and high → trackball; cursor misses and low → free-look. The mode is +latched at gesture start and **sticky** for the gesture. A pan whose ray misses the +frozen sphere degrades to trackball, stickily; grazing incidence strafes in the plane +through the anchor. Zoom routes to Task 11. Tilt orbits the pose about the latched +ground anchor — heading about the anchor's local up, **then** tilt about the +_already-yawed_ east (the intrinsic Z-X-Z order KML specifies; the probe measured a +fixed-screen-axis tilt dragging ~10° of unwanted heading per 60 px). Look rotates the +basis about the eye, which never moves — the only route to the sky. + +There is **no persistent target**: the anchor dies at pointerup, which is what makes +FW-H's proven root cause (an accumulating stored pivot) unreachable rather than +handled. + +Wiring: the controller occupies the **priority-100** driver slot the SpaceMouse +driver vacated (spec §7), active only while a gesture is in flight **and** the arm is +a body arm. `drainInput` routes its steps to the controller instead of +`applyInputToCamera` / `applyWheelZoom` when the arm is a body arm; the world-arm +path is untouched. + +**Tests:** + +- `the mode is latched at gesture start and sticky` — a mid-gesture cursor move off + the body does not change the mode. +- `a trackpad inertial burst after pointerup neither starts a gesture nor moves the +view` (**FW-C**). +- `the gesture's rate currency does not alternate frame to frame across the limb` + (**FW-D**) — walk a drag across the limb and assert the per-step magnitude stays + bounded and single-signed. +- `tilt applies heading then tilt about the already-yawed east` — assert against a + hand-computed pose that a fixed-axis order would get wrong. +- `look leaves the eye and altitude bit-identical while heading stays live`. +- `an overshoot past the anchor tangent plane forces a fresh anchor pick`. + +- [x] TDD, commit. + +### Task 17: ceiling enforcement on driven writes + +**Files (modify):** `src/services/camera/surfaceController.ts`, +`tests/services/camera/surfaceController.test.ts` + +Enforcement is **orientation-only, applied after every write to the body arm** +(spec §6, §12-R3): recompute the ENU at the new standpoint and rebuild the basis from +`(heading, tilt)` — the tilt ceiling is gone; display tilt = remembered × +`bodyUpWeight(h/R, tuning)`, zero at and above `tuning.tiltZeroHR ≤ +tuning.disengageHR`. **The eye never moves.** + +Not an entry clamp (spec §12-R3): enforcing on arm entry would snap a pose that +arrives above the ceiling — a flyby aimed away from the body, a tour keyframe. Since +altitude only changes through zoom, and zoom re-levels through the ceiling, every +path the user can drive still lands at `tilt = 0` by the disengage boundary. Put that +sentence's _content_ in the header in one line; it is the reason the ceiling's zero +must sit exactly at `disengageHR`. + +**Tests:** + +- `a zoom-out re-levels against the new local vertical` — the camera converges to + top-down with **no untilt tween anywhere**. +- `the pose reaching the disengage boundary has tilt 0` — so its forward axis points + at the body centre and it survives the world regime's pivot pin unchanged. This is + what the Q4 invariant buys. +- `enforcement never moves the eye` — bit-identical eye before and after. +- `a pose entering the arm above the ceiling is not clamped` (spec §12-R3). + +- [x] TDD, commit. + +### Task 18: clock and frame-loop integration + +Superseded by +[`2026-09-09-camera-runtime-single-writer.md`](2026-09-09-camera-runtime-single-writer.md) +Task 17 — `tests/services/engine/frame/engagedArmClock.test.ts`, spec §14 clock +invariance, driven through the pure step instead of a running engine. + +## Phase 4 — late consumers + +### Task 19: `lonLatFocusPose` becomes a body-arm constructor + +**Files (modify):** `src/utils/camera/lonLatFocusPose.ts`, +`src/state/camera/watchFlyToLonLatSaga.ts`, plus their tests. + +The deferred item from spec 1's ledger ("STOPPED per standing ruling — reaches +`CameraPose`/`OrbitCamera` = spec-2 territory"). Today it builds a local direction, +rotates it out to world, and recovers `(yaw, pitch)` through +`orbitAnglesLookingAlong` — a body-relative intent expressed as an Mpc round trip. + +**New signature:** + +```ts +export function lonLatFocusPose( + point: LonLatDeg, + bodyId: BodyId, + bodyRadiusM: number, + rangeM: number, + headingRad: number, +): BodyFixedPose; +``` + +No Mpc in it: standpoint from the lon/lat, range preserved, tilt 0, heading +preserved. If the resulting pose is outside the band, the Task 15 fold converts it — +**the instrument does not need to know**. That is the general rule (spec §9): a +producer that is body-relative by nature authors a body arm; the fold is the single +site that reconciles. + +The saga's `distance` currently comes from the resting pose in Mpc; it becomes a +range in metres via the resolved arm. Keep the instant-commit (a snap, not a fly). + +**Tests:** + +- `lonLatFocusPose puts the given lon/lat under the camera` — round-trip through + `surfaceReadoutOf`, asserting `standpoint` back. +- `…at the requested range and at tilt 0`. +- `the fly-to saga commits a body arm` and `…the fold converts it when out of band`. + +- [x] TDD, commit. + +### Task 20: frames for keyframes, tours, and serialization + +**Files (modify):** `src/@types/animation/CameraAction.d.ts`, +`src/services/engine/camera/evaluateClip.ts`, +`src/services/engine/camera/cameraDrivers.ts` (tween/clip rows), +`src/services/engine/helpers/logCameraState.ts`, plus tests. + +**Convert now** (ruled, Q10-B): keyframes today interpolate in absolute Mpc, which is +wrong the moment the sim clock moves. + +**Contract:** the animation system keeps its four channels and its `Space` mapping — +this is the tag-beside-channels form T4 ruled for, **not** the declined `FramedPose` +rewrite. Each base-layer endpoint (`set` / `setVec`) grows +`readonly frame?: PoseFrame`, absent ⇒ `'absolute'`. Relative writers (`spin`, +`rate`, `osc`) act in whatever arm is current and are **untouched**. + +**Interpolation runs in the endpoint's own frame.** A leg whose endpoints disagree +converts its start into the endpoint's frame **once, at leg start**, through the Task +3 pair. Body-framed channel values are read in that body's fixed axes, in metres: +`target` is a body-fixed point, `distance` is a range, `yaw`/`pitch` are angles about +the body's own axes — a LookAt, decoded to a `BodyFixedPose` at the driver's exit. +Authored keyframes are **decoded, never accumulated**, so the pole degeneracy that +rules angles out as state does not reach them. + +Deep-space keyframes stay absolute Mpc and **no existing clip changes**: the grand +tour's near-body beats already reference ids resolved at play time (`moveTargetId` / +`dollyToId`), which are frame-free by construction. Assert that. + +**Serialization** (ruled, Q10b): the serialized form names its frame; untagged legacy +input parses as `'absolute'`. The URL hash carries no camera pose today +(`HASH_PARAM_SOURCES` is `focus`, `t`, `orientation`), so there is nothing to migrate +— the deliverable is the rule plus its one live consumer: `logCameraState`'s debug +blob names the frame and prints **metres** in a body arm. + +**Tests:** + +- `an untagged endpoint parses as absolute` — the legacy path. +- `a leg with disagreeing endpoints converts its start once, at leg start` — assert + the conversion is called exactly once for a multi-frame leg. +- `a body-framed leg interpolates in body-fixed metres` — hand-computed midpoint. +- `no existing clip's evaluated pose changes` — run a registry clip before/after. +- `logCameraState names the frame and prints metres in a body arm`. + +- [x] TDD, full suite, commit. + +## Phase 5 — measurement and the gate + +### Task 21: perf after, and the landing verdict + +**Files:** none. + +Re-run the Task 1 measurement, same flags, same poses, **same worktree URL trap**. +The work is CPU-side and small; **neutral is the expectation and the bar**. + +- [x] `npm run perf -- --url http://localhost:`. +- [x] Diff against the Task 1 baseline verbatim in the ledger. Interpret per the + `perf` skill (MERGED vs PER-LAYER vs FLOOR; Apple Silicon slot-sum inflation). +- [x] **A neutral-or-negative measurement halts the landing pipeline.** Land or park + is the user's ruling, never process momentum (spec §11). + +### Task 22: USER GATE — visual and feel pass + +**Owner: the user. Not an agent task.** Dev server, real data, **f.lux off before any +colour judgement**. Spec §11's list, verbatim: + +1. Descend from 5 R to the descent floor over Earth: **no snap at either crossing**, + no ground drift once engaged, the camera settles top-down on the way back out with + no tween. +2. Drag at 1:1 across the equator, over a pole, and at a grazing limb — the grabbed + point stays under the cursor; no twist, no teleport. +3. Tilt to the horizon and look to the zenith from ~2 m altitude; heading stays live + while pinned; the horizon is level at every latitude and azimuth (probe defect 3's + failing cells: due east from the frame equator, and Denmark's latitude). +4. Zoom-out-then-in round trip with the cursor parked off-centre. +5. Accelerated clock at high rate, sitting engaged: the ground is nailed and the sun + and stars sweep; then cross the boundary and watch the drift onset. **This is the + H1 measurement — adverse evidence, and only adverse evidence, buys H2** + (smoothstepping the co-rotation rate over ~1 s). +6. The same sequence over the Moon and over Mars: nothing in the path is Earth-typed. + +Feel constants are settled **here**, not before: the tuning's `tiltFullHR`, the +grazing-incidence threshold (Task 10), and the zoom step-magnitude clamp (Task 11). +No published reference exists for any of them. Also flagged for this gate: **small +bodies** — on a ~10 km moon the band engages at ~17 km altitude, which is correct but +may feel abrupt. If the gate objects, the remedy is a per-row engage floor — a +registry parameter, **never a second regime**. + +- [x] Record the verdict per item in the ledger. Any adverse finding is a fix loop + before `/feature-done`, not a follow-up. + +## File structure + +**Created** + +``` +src/@types/camera/PoseFrame.d.ts T2 +src/@types/camera/BodyFixedPose.d.ts T2 +src/@types/camera/FramedCameraPose.d.ts T2 +src/@types/camera/SurfaceReadout.d.ts T8 +src/@types/camera/SurfaceGesture.d.ts T16 +src/data/camera/surfaceRegime.ts T2 +src/services/engine/camera/poseFrameConversion.ts T3 (+ resolveWorldArm, T13) +src/services/engine/camera/regimeArmFor.ts T12 +src/services/engine/helpers/liveWorldPose.ts T13 +src/services/camera/surfaceController.ts T16 deleted by the prep → surfaceStep.ts +src/utils/camera/bodyUpWeight.ts T5 (the ceiling is gone; this ramps the remembered tilt) +src/utils/camera/reanchoredPose.ts T6 +src/utils/camera/cursorRayBodyLocal.ts T7 +src/utils/camera/surfaceReadoutOf.ts T8 +src/utils/camera/surfaceFloorM.ts T9 +src/utils/camera/anchoredDragRotation.ts T10 +src/utils/camera/anchoredZoomStep.ts T11 +src/utils/camera/absoluteArm.ts T13 +src/utils/camera/eyeMpcOf.ts T13 +src/utils/camera/poseFromBodyArm.ts T14 +tests/** mirroring each of the above +tests/services/engine/camera/noStoredRegimeFlag.test.ts T12 +tests/services/engine/frame/poseFold.test.ts T15 +``` + +`reanchoredPose.ts`, `surfaceReadoutOf.ts` and `SurfaceReadout.d.ts` were deleted by +the 2026-09-02 simplification wave (zero importers — spec §5.3, §13). + +**Created — cameraRuntime single-writer prep** +([plan](2026-09-09-camera-runtime-single-writer.md), its task numbers) + +``` +src/@types/engine/camera/Epoch.d.ts P2 +src/@types/engine/camera/CameraEpochs.d.ts P2 +src/@types/engine/camera/FollowMemory.d.ts P4 +src/@types/engine/camera/DriverCtx.d.ts P7 +src/@types/engine/camera/StepInputs.d.ts P15 +src/@types/camera/SurfaceMemory.d.ts P10 +src/@types/engine/state/FrameOutputs.d.ts P14 +src/services/engine/camera/cameraEpochs.ts P2, P3 +src/services/camera/surfaceStep.ts P10 +src/services/engine/camera/replayInput.ts P12 +src/services/engine/camera/seedCameraRuntime.ts P14 +src/services/engine/camera/stepCameraRuntime.ts P15 +src/services/engine/camera/commitOnEdge.ts P15 +src/services/engine/frame/projectFramePose.ts P15 +src/utils/camera/surfaceGestureEdge.ts P15 +src/utils/camera/isFollowDriverId.ts P18 +tests/helpers/deepFreeze.ts P16 +tests/fixtures/camera/driverGoldenTrace.json P1 +tests/services/engine/frame/driverGoldenTrace.test.ts P1 +tests/services/engine/frame/cameraRuntimeSingleWriter.test.ts P16 +tests/services/engine/frame/engagedArmClock.test.ts P17 (was T18) +tests/services/engine/frame/followApproachStrand.test.ts P18 +tests/** mirroring each new src file +``` + +**Deleted — cameraRuntime single-writer prep** + +``` +src/@types/engine/camera/CameraClock.d.ts P4 → Epoch + FollowMemory +src/services/engine/camera/cameraClock.ts P4 +src/@types/camera/SurfaceController.d.ts P11 → SurfaceMemory +src/services/camera/surfaceController.ts P11 → surfaceStep.ts +src/services/engine/frame/drainInput.ts P13 → replayInput.ts +``` + +**Modified** + +``` +src/@types/camera/CameraState.d.ts T13 base: FramedCameraPose +src/@types/engine/state/CameraRuntime.d.ts T13, prep five value groups, no boxes +src/@types/engine/camera/CameraDriver.d.ts T13, prep pose(ctx, mem) → { pose, memory }; + isActive(s, approachDone?) +src/@types/animation/CameraAction.d.ts T20 frame tag on set/setVec +src/state/camera/{cameraSlice,selectors}.ts T13 +src/state/camera/{watchOrientationChangeSaga,orientationActions}.ts T13 +src/state/camera/watchFlyToLonLatSaga.ts T13, T19 +src/state/perf/installPerfHook.ts T13 +src/services/engine/camera/cameraDrivers.ts T13, T16, T20, prep module-constant + table; followApproach 55 / followHold 10 +src/services/engine/camera/applyWheelZoom.ts T13 world arm only; prep writes nothing +src/services/engine/camera/applyFocusedBodyPivot.ts T13 world arm only +src/services/engine/camera/evaluateClip.ts T20 per-leg frame conversion +src/services/engine/frame/runFrame.ts T13, T15 the fold; prep the fold moves + into projectFramePose, one runtime write +src/services/engine/frame/frameContext.ts T14 provider B branch +src/services/engine/phases/wireInput.ts T13, prep seeds via seedCameraRuntime +src/services/engine/engine.ts T13, prep seeds the runtime; the clip + player holds no reference into it +src/services/engine/wiring/buildDemandCtx.ts T13 +src/services/engine/helpers/liveRenderCamera.ts T13 +src/services/engine/helpers/logCameraState.ts T20 names the frame +src/services/engine/animation/playClip.ts T13 +src/services/engine/subsystems/clipPlayer.ts T13, prep tick(clipEpoch, nowMs) +src/utils/camera/updatePosition.ts T13 delegates to eyeMpcOf +src/utils/camera/lonLatFocusPose.ts T19 body-arm constructor +tests/services/engine/camera/oneMpcSeam.test.ts T4 importer amendment +``` + +**Untouched** — every slab, layer, shader and renderer file; the tile pipeline; the +`.bin` catalog path; `HASH_PARAM_SOURCES`; `followPanOffset` / `followBody`'s +deep-space behaviour. + +## Definition of Done + +**Deliverable inventory** + +- [x] `FramedCameraPose` is the store's camera currency; `camera.base.frame` is the + only regime discriminant in the tree. +- [x] `poseFrameConversion` exports `toBodyArm` / `toWorldArm` / `resolveWorldArm`, + and is the second and last permitted importer of `MPC_TO_M` / `M_TO_MPC` in the + camera path. +- [x] Provider B is selected at the existing `frameContext` seam; provider A still + serves every other body. +- [x] The surface controller holds the priority-100 driver slot and is active only + while a gesture is in flight in a body arm. +- [x] `lonLatFocusPose` authors a body arm with no Mpc in it. +- [x] `set` / `setVec` endpoints carry an optional `PoseFrame`; `logCameraState` + names the frame and prints metres in a body arm. + +**Acceptance criteria from spec §11** + +- [x] **Pose exactness at engage and disengage** — eye, forward and screen-up + round-trip to within provider A's ~14 µm floor, over a body with a **tilted + pole** and a non-identity orientation. +- [x] **No-snap crossing** — the rendered camera on the frame before and the frame + after a threshold crossing agrees to that same floor, in both directions. +- [x] **Grep: no stored regime flag** — `noStoredRegimeFlag.test.ts` green, and the + one-seam importer test green with the camera path swept. +- [x] `bodyUpWeight(tuning.disengageHR, tuning) === 0` for every `clampCameraTuning` + output — the Q4 identity, now structural via the `tiltZeroHR ≤ disengageHR` + cap rather than asserted against a record. +- [x] A gesture in flight cannot change the arm. +- [x] The nine fix waves carried forward as named tests: FW-A (T8), FW-B + FW-H + (T11), FW-C + FW-D (T16), FW-E (subsumed — 3.4 R makes it trivially true, + ruled Q6), FW-F (T18), FW-G (T15), FW-I (T10). + +**Named observable behaviours (the Task 22 manual pass)** — the six items in Task 22, +each recorded pass/fail with the user's own words, not "works correctly". + +**Perf** — before/after recorded verbatim; neutral or better, or an explicit +land/park ruling from the user. + +**Deferral boundary — do not chase these** + +- Inertia / coast: none in this landing. If ever added, flick-only synthetic replay + **in the body-fixed frame** (written down, zero LOC). +- MapLibre's pole "dial" band: no. +- Terrain-height collision, DEM-driven sensitivity, streaming-height low-pass: + skymap's bodies are analytic spheroids and the tile pipeline streams imagery, not + elevation. The rules are recorded in the code's comments so a future DEM cannot + arrive frame-blind; nothing is built. +- XR and 6-DoF devices. +- Any renderer change at all. +- **Lowering the descent floor** — re-anchoring is built and tested so the floor is a + constant rather than an architectural limit; moving it is a separate, measurable + change. +- H2 (smoothstepped co-rotation onset) — bounded escalation path, spent only on + adverse Task 22 evidence. +- A tour ending on a non-body-centred pose while a body is focused snaps when the + resting driver's pivot pin resumes. Incumbent property of the pin, not spec 2's. diff --git a/docs/superpowers/plans/completed/2026-09-09-camera-runtime-single-writer.ledger.md b/docs/superpowers/plans/completed/2026-09-09-camera-runtime-single-writer.ledger.md new file mode 100644 index 0000000000..79f02e50d6 --- /dev/null +++ b/docs/superpowers/plans/completed/2026-09-09-camera-runtime-single-writer.ledger.md @@ -0,0 +1,116 @@ +# SDD ledger — plan: docs/superpowers/plans/2026-09-09-camera-runtime-single-writer.md + +Execution branch: camera-pivot (PR #647). Start HEAD 24182c2c3 (2026-09-10). +Parent effort ledger (context, rulings 1–19, R14-3): `../2026-09-01-camera-pivot/progress.md`. +Spec authority: `docs/superpowers/specs/2026-09-01-camera-pivot.md` §"Ground preparation — cameraRuntime single-writer". +Parallelism (user): group A = T1 ‖ T2 ‖ T10 (isolation worktrees, `git checkout -B
`, cherry-pick onto camera-pivot); group B = T20 ‖ T21; rest sequential. Pipelined reviews per sdd-execution.md Rule 2. +Task list (Rule 1; harness has no todo tool in this session — this table IS the list): +| T | status | T | status | T | status | +|---|---|---|---|---|---| +| 1 | complete | 8 | complete | 15 | complete | +| 2 | complete | 9 | complete | 16 | complete | +| 3 | complete | 10 | complete | 17 | complete | +| 4 | complete | 11 | complete | 18 | complete | +| 5 | complete | 12 | complete | 19 | complete | +| 6 | complete | 13 | complete | 20 | complete | +| 7 | complete | 14 | complete | 21 | complete | + +## Pre-flight conflict scan (2026-09-10) + +| pair / task | produces vs consumes | finding → ruling | +|---|---|---| +| Global "re-record forbidden" vs T18 re-records driver trace | — | CONFLICT. Ruling: user ruled "trace rerecord also" → constraint binds T1–T17; T18 may re-record the DRIVER trace only, diff in commit body; settleGoldenTrace never moves. Cost if wrong: a behaviour drift hidden in the follow legs. | +| T4 deletes `clipElapsed`/`CameraClock` vs T5 migrates clipPlayer (which calls clipElapsed and is handed `clock` at engine.ts:280) | T4 leaves clipPlayer uncompilable | CONFLICT. Ruling: T4 + T5 are ONE dispatch; the implementer may land them as one commit if two green commits are impossible (T5's handshake needs `runtime.epochs.clip`, which T4 creates). Cost if wrong: a larger single diff to review. | +| T7 DriverCtx.zoomToFollow vs T8 produces it | T7 passes `null` until T8 | consistent (T7 must wire the field, T8 fills it) | +| T8 → T9 → T12 → T13 drainInput return shape | { zoomToFollow } → + follow → replayInput → deleted | consistent chain | +| T3 `advanceEpochs` takes `clip` from T5 | before T5, runFrame has no clip epoch source | covered by T4+T5 single dispatch | +| T4 CameraRuntime.d.ts:12,20 / T11 :60-64 / T14 whole file | sequential, same file | fine (line refs are at 24182c2c3; implementers read current file) | +| T14 seedCameraRuntime in wireInput = an assignment; T16 allow-lists wireInput.ts | consistent | fine | +| T12 ctx.winnerLastFrame / autoRotateEpoch vs T7 DriverCtx.winnerLastFrame, T2 Epoch | names match | fine | +| T3/T18 follow eligibility "is a follow row" | T18 adds followApproach/followHold | consistent | +| T1 perf baseline in an isolation worktree | needs its own dev server + `--url` | fine; brief says so | +| Each task self-consistency | tests vs code vs files | T1–T21 read consistent; no placeholder patterns found | + +## Dispatch log + +- 2026-09-10 BASE 24182c2c3. Group A dispatched: T1 (opus, wt, branch t1-driver-golden), T2 (sonnet, wt, branch t2-epochs), T10 (opus, wt, branch t10-surface-memory). On each DONE: cherry-pick onto camera-pivot, review-package from the pre-pick HEAD, task reviewer. +- Handling on return (any of T1/T2/T10): `git cherry-pick ` onto camera-pivot (record pre-pick HEAD as BASE for the package), `scripts/review-package PLAN BASE HEAD`, dispatch task reviewer (task-reviewer-prompt.md; model: sonnet for T2, opus for T1/T10) with brief + report + package paths + the Global constraints block (plan lines 56-88). Fix loop per skill (rounds 1-3 resume the same agent). Task complete only on clean review; write `Task N: complete (...)` here and flip the table. +- Queue after group A: T3 (sonnet, sequential, in THIS worktree, after T2 review clean) → T4+T5 ONE dispatch (fable — byte-bar-sensitive ordering) → T6 (opus) → T7 (opus) → T8 (opus) → T9 (opus) → T11 (opus, needs T10) → T12 (fable — effective-intent fold debuts here) → T13 (fable) → T14 (fable, wide) → T15 (fable) → T16 (opus) → T17 (sonnet) → T18 (opus; re-record DRIVER trace only, diff in commit body) → T19 (perf, halts on neutral/negative → USER) → T20 ‖ T21 → final whole-branch review (fable) → archive ledger to docs/superpowers/plans/completed/.ledger.md → parent plan T19–T22 → /feature-done. +- Push camera-pivot after each task's review closes (user-sanctioned branch pushes throughout this effort). +- 2026-09-10 T2 returned DONE (8e947c981 in wt agent-a53660d534f1d0c8b). Cherry-picked → camera-pivot aa476991a (BASE 24182c2c3). Package review-24182c2c3..aa476991a.diff. Reviewer dispatched (sonnet). T1, T10 still running. +- 2026-09-10 T10 returned DONE (5f5e1822e in wt agent-ab23785ac72667c22; report copied into this workspace). Cherry-picked → camera-pivot 050905ed1 (BASE aa476991a). Package review-aa476991a..050905ed1.diff. Reviewer dispatched (opus). Implementer concerns to adjudicate with the review: SurfaceStepCtx local alias; noteBody fresh object on unchanged body; gesture edges left to T11. +- T2 review (sonnet): spec ✅; Important = comment budget on Epoch.d.ts (4:1), Minor = cameraEpochs.ts 5/8. Ruling: controller trimmed both headers directly (ad91f3215) — a two-line comment trim does not warrant a fix-round dispatch or a re-review; no code change. Task 2: complete (aa476991a + ad91f3215). +- 2026-09-10 T3 dispatched (sonnet, in camera-pivot, BASE ad91f3215; agent a3f6843fa718a75e0). Ruling carried: follow-row eligibility = per-row predicate in the table, 'followBody' only today, T18 adds ids as cells. Report → task-3-report.md. On DONE: review-package ad91f3215..HEAD (mind T1 cherry-pick may interleave — use the exact T3 sha range), reviewer sonnet. +- T10 review (opus): spec ✅, Approved, no Important. Rulings on minors: zoom arm now reads `prev.pointerDown ? prev.gesture : null` (parity with controller's `live?.gesture ?? null`; removes the T11 dependency) + test `toBe` on the declined-drag identity — controller applied both directly (5364d9bf4). noteBody identity short-circuit: NOT taken (no reader keys on surface identity; smallest diff). SurfaceStepCtx local alias: accepted. tiltOf copy in test: known copy, accepted. Task 10: complete (050905ed1 + 5364d9bf4). T11 brief must carry: gestureEnd writes `gesture: null`, pointerDown edges per plan. +- 2026-09-10 USER: "implement with opus when needed" — model floor raised: T6, T9 → opus (multi-file contracts); T17 stays sonnet (single verification test); any T3 fix round → opus. Reviewers of opus-implemented tasks stay opus. +- 2026-09-10 USER: "or even fable when needed" — T13, T14, T15 + final whole-branch review → fable (integration keystones). Supersedes the standing never-Fable-for-subagents feedback for this effort. +- 2026-09-10 T3 returned DONE 2314163b0 (in camera-pivot). Package review-5364d9bf4..2314163b0.diff; reviewer dispatched (opus, agent a7f4355048197ca3f). Implementer's judgement call to adjudicate: eligibility as a boolean object per row, not a generic loop. T4+T5 briefs generated, awaiting T3 clean. +- 2026-09-10 Task-board artifact published (user ask): https://claude.ai/code/artifact/ff89d734-0eeb-4e50-a8d2-8292183ee812 — source scratchpad/camera-runtime-tasks.html; republish at phase boundaries (same file path keeps the URL). +- T3 review (opus): Approved, no Important. Minors ruled: positive-path tween test + timeless comments (task/line citations out) + test header — applied by controller (5bad76965); rejected: second identity test, lifting the nested ternary (no gain). Reviewer recorded a benign frameTween divergence (advance on null frames drives the row to null where the incumbent left it stale; unobservable). Task 3: complete (2314163b0 + 5bad76965). +- T1 returned DONE_WITH_CONCERNS (e70fe86b6 in wt agent-a77a059ae0d83f81f; report + perf-baseline-T1.txt copied here). Cherry-picked → camera-pivot d88c69b27 (BASE 2314163b0). Package review-2314163b0..d88c69b27.diff. Concerns → rulings: (1) repo-wide `npm run format` rewrites ~800 files incl. settleGoldenTrace.test.ts — every dispatch says prettier on touched files only; (2) perf ±3 ms noise at 30 frames on an unchanged tree → T19 runs PAIRED A/B, never a single diff vs baseline; (3) extra at-rest orbit-drag leg beyond the brief — reviewer to judge; (4) fixture pins the STALE clip epoch after clipPlayer.stop() — Ruling: T5 may re-record the driver trace iff the only cells that change are clip-epoch cells on frames after stop(); diff in the commit body; anything else = real behaviour change, stop. Cost if wrong: a drift hidden in the same re-record — mitigated by the cell-only diff requirement. +- 2026-09-10 T1 reviewer dispatched (opus, agent a13b8275a50034d51) on review-2314163b0..d88c69b27.diff. T4+T5 dispatched (fable, in camera-pivot, BASE 5bad76965, agent a6f94fd1ddce40663; report → task-4-5-report.md). Rulings carried: advanceEpochs called once at the winner point in runFrame; applyWheelZoom takes autoRotateElapsedMs computed by drainInput without storing; follow side effect = one line in runFrame; clip stays seconds at the read until T6; ruled clip-stale re-record only. NOTE: T4/5 runs against the driver trace as recorded — if T1's review changes the trace SOURCE, re-run both traces on T4/5's head before its review. +- T1 review (opus): Needs fixes. Important: (1) follow.panOffset [0,0,0] on all 41 steps — no positive follow-memory coverage; (2) test source reads clock ×12, boxes, and builds createClipPlayer with clock — cannot survive T4/5/14 unmodified; (3) stale-epoch pinning is 4 rows, not just clip. Minors: displayed==register invariant comment; record path minified vs committed pretty fixture; frameTween negative-only; report count nits (230 code/37 comment, fixture 995 lines). Rulings: (a) T1 fix round FOLDED INTO the running T4/5 dispatch as step 0 on the unrefactored tree (message sent; a separate fix agent would collide in the same worktree): pan leg + pretty record path + re-record + clock/box reads behind tests/helpers/camera/readCameraEpochs.ts + real clip player as a harness option + invariant comment; (b) byte bar restated = fixtures + script/assertion logic frozen; construction lines in settleGoldenTrace.test.ts may migrate shape like any test literal; (c) re-record widened to any epoch row's cells after its driver last won, cell-only diff in commit body; follow memory/winner/pose/actions cells changing = stop; (d) frameTween leg: NOT added (T3 review confirmed row parity; column still fails on a spurious start) — cost if wrong: a frameTween epoch drift in T4 goes unpinned at runFrame level, caught only by resolveFrameBasis unit tests. T1 extra orbit-drag leg: accepted (justified). Task 1 stays "in review" until step 0 lands; its scoped re-review rides the T4/5 review package. +- 2026-09-10 USER: "make sure the implementer doesn't run out of context; start a new one if necessary". T4/5 agent a6f94fd1ddce40663 measured at ~283k cache-read tokens / 64 tool uses with step 0 + T4 + T5 still ahead → HANDOFF ordered (message sent): writes task-4-5-handoff.md, leaves uncommitted edits in place, returns. Next: dispatch a fresh fable implementer with the handoff + both briefs + the step-0 message content (rulings a–d above). Standing rule for this effort: check `cache_read_input_tokens` in an agent's transcript when a dispatch spans >1 task; rotate at ~250k when substantial work remains. +- 2026-09-10 T4/5 agent #1 returned HANDOFF at 340k tokens: step 0 committed d3dd41cd9 (pan leg steps 42–48, pretty fixture, readCameraEpochs helper + harness realClipPlayer option); T4+T5 migration applied UNCOMMITTED (32 mod + 1 new), src typechecks, settle trace byte-identical, red: applyWheelZoom.test, clipPlayer.test, one type import in settleGoldenTrace.test; driver trace fails at step 38 clip epoch (ruled re-record case, not yet parse-verified). Handoff: task-4-5-handoff.md. Successor dispatched (fable, agent a574bdfc7c650c1e3) with handoff + briefs + rulings 1–9; told to self-handoff at ~60 tool calls. T1 step-0 commit d3dd41cd9 needs a scoped re-review — ride the T4/5 review package (BASE 5bad76965). +- 2026-09-10 T4/5 agent #2 returned DONE_WITH_CONCERNS: 391a20818 (T4+T5 one commit; driver fixture re-recorded, claims 20 clip-epoch cells frames 38–47 only) + 77a1945e8 (comment budget). Full suite 1256/8729 green, typecheck clean, settle trace byte-identical. Concerns: 4 small files over half-ratio; frameTween null delta (known); pickWinner twice per frame; runFrame-level test under vi.mocks. Package review-5bad76965..77a1945e8.diff (3 commits incl. step 0); reviewer dispatched (fable, agent a5e4da80770e89664) with instruction to parse-verify the fixture diff itself. T1 scoped re-review rides this package. +- T4/5 + T1-step-0 review (fable): Approved, no Important. Fixture parse-compare confirmed: 20 cells, all epochs.clip, steps 38–47, settle fixture byte-identical, driver test source unmoved during migration. pickWinner-twice = pre-existing, winner identical (same rootState, pure isActive). Minors ruled: (1) pendingEnd path nulls the clip epoch one frame earlier than the incumbent — RECORDED here as a second benign stale-epoch delta, not a leg; (2) legOf prettier reflow in settle test — accepted; (3) EpochCells export unused / CameraRuntime.d.ts duplicated R12b-1 note — deferred to T14 (rewrites that file) and T20 deletion audit. Task 1: complete (d88c69b27 + d3dd41cd9). Task 4: complete. Task 5: complete (391a20818 + 77a1945e8). +- 2026-09-10 T6 dispatched (opus, in camera-pivot, BASE 77a1945e8, agent ab4466471affe606e; report → task-6-report.md). Phase 1 closes with T6. On DONE: package 77a1945e8..HEAD, reviewer opus, then T7 (P2 start) — generate briefs 7/8/9 now. +- Briefs 7, 8, 9 generated (task-7/8/9-brief.md). T7 waits for T6 review (shares cameraDrivers.ts). +- 2026-09-10 T6 returned DONE_WITH_CONCERNS 9908829bf (concerns: elapsedMs param shadows helper; test green pre-change (RED on intermediate); player keeps /1000 at read for FP bit-identity). Package review-77a1945e8..9908829bf.diff; reviewer dispatched (opus, agent a719da49a5205aa32). +- T6 review (opus): Approved, no Important; minors (param shadow uniform, followBody `elapsed` naming, test coupling to DEFAULT_ORIENTATION) noted, none taken. Task 6: complete (9908829bf). PHASE 1 (P1 epochs) CLOSED. +- 2026-09-10 T7 dispatched (opus, in camera-pivot, BASE 9908829bf, agent a920b2deda6ea8141; report → task-7-report.md; self-handoff at ~60 tool calls). Rulings carried: ctx.elapsedMs = winner's elapsed computed by caller; zoomToFollow null until T8; winnerLastFrame/register/simDays sourced from the boxes; memory adoption uniform, runFrame writes runtime.follow = memory in place; applyWheelZoom follow write untouched until T8; buildCameraDrivers → module-level table via refactor CLI refs. +- 2026-09-10 T7 returned DONE_WITH_CONCERNS ad57bb50f (153 tool uses / 240k — at the rotation edge, finished). Concerns: unbriefed DriverCtx.authoredWorld (followBody read authoredWorldPose(state); brief missed it); elapsedForWinner exported, takes winner id; runFrame.test lastPose fixture wrapped in absoluteArm; one subsumed test deleted; double pickWinner survives. Package review-9908829bf..ad57bb50f.diff; reviewer dispatched (opus, agent a6243f526db3c6161) with the authoredWorld parity check named. +- T7 review (opus): Approved, no Important; authoredWorld hoist equivalent argument-by-argument; followBody branch-for-branch parity. Minors ruled → CARRIED INTO T8's dispatch (same files): (1) share one body between runFrame's inline authoredWorld and authoredWorldPose (extract `authoredWorldPoseFrom(register, bodyStates, poseBasis, upBasis)`; helper becomes the deriveBodyStates wrapper); (4) makeDriverCtx test defaults → pass harness simDays/projection in commitOnEdge + playClipFlyout; (5) CameraDriver.d.ts header trim to the pointer; (6) cameraDrivers.test.ts:472 vacuous `if` guard → assert called. NOT taken: (2) eager resolveWorldArm per frame (note for T19 perf); (3) elapsedForWinner winner computed independently — plan-mandated, agreement covered by invariant test; (7) pivot/zoomToFollow/winner unread until T8. Task 7: complete (ad57bb50f). +- 2026-09-10 T8 dispatched (opus, in camera-pivot, BASE ad57bb50f, agent a4b843d6219ca9914; report → task-8-report.md; self-handoff at ~60 calls). Rulings carried: roll-ride dispatch moves after runCameraDrivers — the driver trace's mid-approach-notch leg is the proof; a failing cell there = BLOCKED, never re-record; route condition evaluated by drainInput; T7 polish (a–d) as a separate first commit. +- T8 BLOCKED (correctly): moving the roll ride after runCameraDrivers lags the follow driver's roll lerp by one frame — driver trace step 7 displayed[6] 0 vs -2.1e-17, settle trace step 2 displayed[6] -0.25897 vs -0.25920; actions/follow/epochs columns matched. Polish commit fca0cd3d2 landed. Ruling: PLAN DEFECT in T8 text — the ride's commit must precede the frame's store snapshot until T13's effective intent exists. Resolution: ride STAYS in drainInput with a data pre/post pair (prev.follow.distanceTarget vs the notch-resolved distance); `zoomedDistance`'s single call site moves to drainInput; `DriverCtx.zoomToFollow` → `followDistanceTarget: number | null` (resolved distance, driver adopts it); revert the autoRotate gate move unless load-bearing; `authoredWorldPoseFrom` passthrough deleted (both sites call resolveWorldArm). Cost if wrong: T12/T13 must carry the resolved-distance shape into replayInput (they will — it is data). Agent resumed (round 1). T12/T13 briefs must reflect `followDistanceTarget`. +- 2026-09-10 T8 returned DONE after the ruling: b68b14509 (T7 polish, authoredWorldPoseFrom dropped as passthrough) + 585634c19 (task). Both traces unmodified. Concerns: applyWheelZoom bag `autoRotate.active` means "spin owns the pose" (misnomer; offer `spin: { owns, rate }`); new surface-floor test; focus-reset edge untested (pre-existing). Package review-ad57bb50f..585634c19.diff; reviewer dispatched (opus, agent aebbb398fc8e12375). +- T8 review (opus): Approved, no Important. Minors ruled → CARRIED INTO T9 (same file): `autoRotate.active` bag field → `spin: { owns, rate }`; fix false header claim in applyWheelZoom.ts (enabled-but-not-authoring degenerates via active:false, not elapsed); stale "three owners" comment drainInput.ts:~197; drainInput header back to ≤10 lines; add a test for the route condition's third clause (no captured target ⇒ base commit, notch not swallowed). Plan/spec `zoomToFollow` → `followDistanceTarget` + ruling recorded: c0918be26. Task 8: complete (b68b14509 + 585634c19). +- 2026-09-10 T9 dispatched (opus, in camera-pivot, BASE c0918be26, agent a9469071c9d77bc19; report → task-9-report.md). Rulings: drain returns `follow`; runFrame assigns it once BEFORE advanceEpochs/focus-edge reset; T8 polish (a–e) as a separate first commit. T9 closes P2; next T11 (P3, needs T10 done) — generate briefs 11, 12, 13. +- 2026-09-10 T9 returned DONE_WITH_CONCERNS: a53711de6 (T8 polish) + afa82aad9 (task). Full suite 1256/8732 green; traces unmodified; grep shows follow writers only in runFrame (:121 drained, :154 reset, :184 adoption). Concerns: autoRotateElapsedMs naming beside `spin`; empty-drain path returns held memory (landmine). Package review-c0918be26..afa82aad9.diff; reviewer dispatched (opus, agent acfa79b1c1ff59fbf). +- T9 review (opus): Needs fixes — Important: CameraRuntime.d.ts follow note still claimed "written by the pan fold". Controller applied directly (9156c08ed): note rewritten to name runFrame as the only writer; `autoRotateElapsedMs` → `spinElapsedMs` (3 sites); empty-drain returns-held-memory test added. Targeted tests + both traces green, tsgo clean. No re-review (comment + rename + one test). Task 9: complete (a53711de6 + afa82aad9 + 9156c08ed). PHASE 2 (P2 drivers) CLOSED. +- 2026-09-10 T11 dispatched (opus, in camera-pivot, BASE 9156c08ed, agent a67e81dec670171c1; report → task-11-report.md). Rulings: surface = value, in-place writes at each former method site until T15; gestureEnd writes gesture:null (T10 zoom-arm parity); empty-memory constant homed like UNSTARTED_EPOCHS; surfaceController.test cases merge into surfaceStep.test with no assertion changes; refactor CLI deletes. On DONE: package 9156c08ed..HEAD, reviewer opus; then T12 (P4, opus) — brief exists; T13 fable. +- 2026-09-10 T11 returned DONE_WITH_CONCERNS 88b20c6b4 (full suite 1255/8730 green; traces unmodified). Concerns: test-only makeSurfaceDriver closure keeps the old apply shape (zero assertion changes); refs CLI under-reports property-access readers — grep `cameraRuntime.` alongside it for T12–T15; four in-place surface writers until T15; EMPTY_SURFACE_MEMORY = surfaceStep.ts third export. Package review-9156c08ed..88b20c6b4.diff; reviewer dispatched (opus, agent ab6d99d6f7d8f2bf9). +- T11 review (opus): Approved, no Important. Assertion identity mechanically verified (33 its / 115 expects, one fixture rename). Minors ruled: stale "controller" prose in rememberedTilt/tiltLerpRoundTrip/tiltRegisterLoop/tiltCommitIdempotence/drainInput tests → CARRIED INTO T12 (touches drainInput.test) as a polish commit; makeSurfaceDriver test closure → retire at T15 (call the production fold or delete) — NOTE FOR T15 BRIEF; engine.ts debug re-nesting → flatten cameraDebugSnapshotOf's gesture param after T15 — NOTE FOR T20 deletion audit. settle test hunk was 4 shape-only lines (accepted). Task 11: complete (88b20c6b4). PHASE 3 (P3 surface) CLOSED. +- 2026-09-10 T12 dispatched (fable, in camera-pivot, BASE 88b20c6b4, agent ac529dac90777cb9d; report → task-12-report.md; self-handoff at ~60 calls). Rulings: thin caller assigns then dispatches BEFORE runFrame's snapshot (order preserved; T13 moves it); effective intent = real cameraSlice reducer folded per emitted action; autoRotateEpoch returned (store-or-drop, must be provably neutral); T11 prose polish as separate first commit. On DONE: package 88b20c6b4..HEAD, reviewer fable (keystone), then T13 (fable) — brief exists; generate 14/15/16 briefs. +- 2026-09-10 T12 returned DONE_WITH_CONCERNS: aa72c7df1 (T11 prose polish) + d8ff44310 (replayInput). Full suite 1256/8736 green; traces unmodified. BEHAVIOUR DELTA (trace-invisible): incumbent drain read bodies at TWO instants (this frame's simDays for surface/world-arm conversions; lastRenderedSimDays = previous frame for authoredWorldPose in the pan strafe + roll ride); single ctx.bodies collapses both onto this frame's. Provisional ruling: ACCEPT as recorded convergence (runFrame's ctx.authoredWorld already uses this frame's instant since T7; observable only under a running clock with a body-arm register under an absolute base) — reviewer asked to confirm/characterise; USER may overrule (carry two maps to keep the lag). autoRotateEpoch returned but dropped by caller (store not provably neutral) — T15 must consume per its brief. Effective intent not returned — T13 must add it. Package review-88b20c6b4..d8ff44310.diff; reviewer dispatched (fable, agent a1b3e9024ab3f58e8). +- T12 review (fable): Approved, no Important in the diff. Behaviour delta CONFIRMED as exactly the two authoredWorldPose reads converging on this frame's instant (body-arm register ∧ absolute base ∧ running clock) — ACCEPTED, recorded here, not in code. Plan-text error ruled: `autoRotateEpoch` must NOT be handed to advanceEpochs (non-autoRotate winner replays prev.ref → would keep a fold-time reset the incumbent never stored); T13 removes the field from replayInput's return; plan+spec updated 74e84cb46. Five dispatch sites (brief said four; test 2 covers the fifth). Effective intent not returned — T13 re-folds per its text (or adds an `intent` slot). Task 12: complete (aa72c7df1 + d8ff44310). +- 2026-09-10 T13 dispatched (fable, in camera-pivot, BASE 74e84cb46, agent a54ddd6bd80c0e8ac; report → task-13-report.md; self-handoff at ~60 calls). Rulings: ONE snapshot before replay; assign → dispatch → effective fold (identity on steady frames); autoRotateEpoch removed from replayInput's return; drainInput deleted via CLI, its tests migrated to runFrame/replayInput; ruled listener-order change = one landmine comment. On DONE: package 74e84cb46..HEAD, reviewer fable; then T14 (fable, wide) — brief exists; T15 brief must carry: makeSurfaceDriver retirement, no autoRotateEpoch hand-off, lastZoomFactor `??` fold. +- 2026-09-10 T13 returned DONE 7fe18a8f1 (full suite 1255/8730; traces unmodified; 3 brief tests mutation-verified; drainInput deleted, 21 tests migrated 2/12/7). Concerns: stray `page 000x/000N: FAILED` stdout lines in the suite (pre-existing, source unidentified); poseBasis read collapsed onto rootState.settings. Package review-74e84cb46..7fe18a8f1.diff; reviewer dispatched (fable, agent ac2baabeb7100fdd1). +- T13 review (fable): Approved, no Important. Field argument holds (no sync listener re-dispatches on commitCameraPose/endDrag). Stray stdout lines = tools/fetch/fetchGaia.ts:430 console.log leaking in tests/tools/fetch/fetchGaia.test.ts (pre-existing; NOTE for T20 audit / a stray-fix). Minor: fold gated on actions.length rather than reducer identity — not taken. Task 13: complete (7fe18a8f1). PHASE 4 (P4 replay) CLOSED. +- 2026-09-10 T14 dispatched (fable, in camera-pivot, BASE 7fe18a8f1, agent ae04e5a13624a9afd; report → task-14-report.md; self-handoff at ~60 calls, src+harness commit first if green). Rulings: readonly groups, whole-group replacement writes in runFrame this task; projection → outputs.projection as a value; seedCameraRuntime replaces engine literal + wireInput writes + harness seedPose; driver-trace helpers absorb the shape (test source untouched); no assertion changes. On DONE: package 7fe18a8f1..HEAD, reviewer fable; then T15 (fable) — brief exists; carry: makeSurfaceDriver retirement, no autoRotateEpoch hand-off, lastZoomFactor `??` fold. +- 2026-09-10 T14 returned DONE_WITH_CONCERNS 6ec02c2b0 (56 files; full suite 1256/8734; traces unmodified; driver test untouched). Concerns: demandTable.test stderr row-24 throw (controller: diff hunks there are shape-only → likely pre-existing; reviewer to settle); transitional whole-group writes at old timing; seed shares one pose object between register.pose/outputs.displayed; wireInput re-seed resets all groups. Package review-7fe18a8f1..6ec02c2b0.diff; reviewer dispatched (fable, agent ad2547f31e360793a). +- T14 review (fable): Approved, no Important. Write-timing table: every group write at the same position relative to dispatches/readers. demandTable stderr = pre-existing missing `assetSlots.starCatalogs` stub (row 24 = GaiaStars) — NOTE for a stray-fix/T20. Minors → CARRIED INTO T15: (a) CameraRuntime.d.ts three field notes echo the header's only-writer sentence (13/12) — cut to the header; FrameOutputs.d.ts 9/10 trim; (b) focusReleaseWhileEngaged.test.ts:142 calls seedPose mid-run — outcome traced identical; T15/T16 to judge whether seedPose is construction-only (note at call site otherwise). Task 14: complete (6ec02c2b0). +- 2026-09-10 T15 dispatched (fable, in camera-pivot, BASE 6ec02c2b0, agent aebc8426ed95749b4; report → task-15-report.md; self-handoff at ~60 calls; intermediate green commits for commitOnEdge/projectFramePose extractions allowed). Rulings carried: all camera actions returned and dispatched after the assignment in today's relative order (drained → commit-on-edge → crossing commit), effective intent for any post-dispatch store read; non-camera dispatches stay in runFrame; projection in-step; no autoRotateEpoch hand-off; lastZoomFactor ??; structural sharing on idle frames; FW-G order is the contract; makeSurfaceDriver retirement + seedPose construction-only ruling; T14 .d.ts comment trims. On DONE: package 6ec02c2b0..HEAD, reviewer fable; then T16 (opus, guard) — brief exists. +- 2026-09-10 T15 returned DONE_WITH_CONCERNS: 30cdcf5ec (T14 polish + seedPose ruling) + c459a4e93 (step; runFrame 603→315, one assignment at :118; suite 1257/8747; traces unmodified). Deviations: step returns extra `world` + `rootState` (effective intent for keep-tick vote); StepInputs.focus / projectFramePose.dragging dropped as redundant; noteBody returns prev on unchanged body; surfaceGestureEdge util; **aspect from CSS px every frame vs incumbent backing-store on resize — rounding-level production delta → FIX ROUND 1 ordered (agent resumed): `StepInputs.aspect` from deps.canvas.width/height, bit-exact.** Reviewer (fable) dispatches after the fix lands. +- T15 fix round 1 DONE: amended → eeb5b39e6 (StepInputs.aspect from backing store; bit-exact). Package review-6ec02c2b0..eeb5b39e6.diff (30cdcf5ec + eeb5b39e6); reviewer dispatched (fable, agent a7b8c8a87b79d8b79) with action-order table + effective-intent reads + FW-G order + structural sharing + one-assignment checks named. Deviations to adjudicate: extra return fields `world`/`rootState`; focus/dragging dropped; noteBody identity; surfaceGestureEdge util; seedPose ruling. +- Briefs 17, 18 generated. Ruling: after T15 review clears, T16 (opus, camera-pivot) ‖ T17 (sonnet, isolation worktree off T15's head; test-only) — T17's file is cherry-picked and re-run under T16's harness freeze. T18 (opus) after both; user already ruled 55 (plan's "flag at checkpoint" is satisfied — "55 looks good"). +- T15 review (fable): Approved, no Important. Action-order table identical (8 dispatches); FW-G statement-by-statement; structural sharing by construction; one assignment. Deviations accepted: `world` + `rootState` return fields (minimal moves avoiding trace-invisible behaviour changes); focus/dragging dropped; noteBody identity; makeSurfaceDriver reshaped w/ honest header; seedPose call-site note. Minors → CARRIED INTO T16: restore the lost memo-repoint landmine (simDays is the single-writer epoch the pick path reads — NOT the derive memo's key, which a between-frames deriveBodyStates(CONST_J2000) can repoint) as one clause on FrameOutputs.simDays; wrap 3 >100-char doc lines; commitOnEdge.test simulateFrame → migrate onto stepCameraRuntime (T20 audit candidate). Task 15: complete (30cdcf5ec + eeb5b39e6). PHASE 5 (P5 value) CLOSED except the guard. +- 2026-09-10 T16 dispatched (opus, in camera-pivot, BASE eeb5b39e6, agent a674ed8ecda7f0384; report → task-16-report.md; carries T15 comment polish). T17 dispatched (sonnet, ISOLATION worktree, branch t17-engaged-arm-clock off eeb5b39e6, agent a000ddd74ee2fa9a5; report in its worktree → copy). On T17 DONE: cherry-pick onto camera-pivot AFTER T16 lands (so the freeze covers it), re-run its file, package separately (reviewer sonnet). On T16 DONE: package eeb5b39e6..HEAD, reviewer opus. Then T18 (opus). +- 2026-09-10 T16 returned DONE: 14a5cfe9c (T15 comment polish) + bfd00a470 (gate: 751 swept files, 16 assignment tokens + Object.assign, allow-list runFrame+wireInput, RED proof at makeReconcileEffects.ts:29; deepFreeze in harness after tick+seedPose — ZERO freeze throws across the camera suite; full suite 1258/8752). Concerns: alias blind spot (freeze covers), single `it` over sweep, throwaway freeze fixture deleted, commitOnEdge.ts header 11 (pre-existing). Package review-eeb5b39e6..bfd00a470.diff; reviewer dispatched (opus) with the shared-constant-freeze check named as the key risk. T17 still running in its worktree. +- 2026-09-10 T17 returned DONE (e4f288275 in wt agent-a000ddd74ee2fa9a5; report copied). Cherry-picked → camera-pivot 7cb52f4a5 ON TOP of T16 (BASE bfd00a470); its file + both traces green under the freeze (3 files / 6 tests). Package review-bfd00a470..7cb52f4a5.diff; reviewer dispatched (sonnet). T16 review (opus) still running. After both clear: push, dispatch T18 (opus). +- T17 review (sonnet): Approved, no Important. Minor (one-line divergence note on test 3's per-tick notch loop) — not taken. Task 17: complete (7cb52f4a5). Waiting on T16 review before push + T18. +- T16 review (opus): Needs fixes. Important: (1) alias blind spot live — six `const rt = cameraRuntime()` bindings in swept sagas + engine.ts const; ruling: syntactic per-file alias widening (root identifier declared from a cameraRuntime initialiser) + unwrap NonNull/Paren/As + string-literal element access at root; destructuring/delete out of scope (stated). (2) deepFreeze needs a self-test (array recursion, typed-array/Map skip, frozen-subtree skip). Minors taken: false ORIENTATION_FRAMES sentence in deepFreeze.ts; gate header claim scoped to what it enforces; engine.ts acquittal reason corrected. Contamination check CLEAN (registry rows copied at both entry points; UNSTARTED_EPOCHS/EMPTY_SURFACE_MEMORY frozen but never mutated; per-file isolation). Fix round 1 → same agent (resumed). Scoped re-review (sonnet) after. seedRememberedTilt.ts:59 between-frames replacement leaves an unfrozen window — noted for T20. +- T16 fix round 1 DONE b8b4946a9 (alias widening via initialiser-SPINE walk — deviation: text-contains over-fired on engine.ts's `const state = {…, cameraRuntime}`; unwrap !/()/as; string-literal element access; deepFreeze.test.ts; minors taken; RED proof ×4 forms). Residual: runtime arriving as a function PARAMETER is not covered (none in src today). Scoped re-review dispatched (sonnet) on review-7cb52f4a5..b8b4946a9.diff. +- T16 re-review (sonnet): Accepted; all five findings fixed; new note: aliasNames is per-file flat (not scope-aware) — within the ruling; no collisions today. Task 16: complete (14a5cfe9c + bfd00a470 + b8b4946a9). PHASE 5 CLOSED. Residuals for T20: parameter-passed runtime uncovered by the gate; literal-wrapped alias; seedRememberedTilt unfrozen window; commitOnEdge.ts header 11; deepFreeze.ts 7/9. +- 2026-09-10 T18 dispatched (opus, in camera-pivot, BASE b8b4946a9, agent → see notification; report → task-18-report.md). Rulings: one shared produce fn for both rows; isActive gets the elapsed from prev.epochs.follow at pick time; eligibility literal + shouldKeepTicking + every 'followBody' literal migrated; driver test source may change ONLY the in-script winner-id assertions; driver fixture re-recorded with parse-compared cell diff in the commit body (pose diff on a leg without autoRotate involvement = BLOCKED); settle trace must not move; R14-3 repro RED-before/GREEN-after recorded. Then: T19 perf (paired A/B, halts on neutral → USER), T20 ‖ T21, final review (fable). +- 2026-09-10 T18 returned DONE_WITH_CONCERNS 6a87cb428 (repro RED eye 0.13 R♄ → GREEN; settle unmoved; driver fixture re-recorded 48→47 steps / 222 cells; suite 1261/8765). Concerns for ruling: (1) unplanned commitOnEdge fix — follow pair treated as one author (isFollowDriverId ×5), row-level `author` deferred; (2) hand-off not bit-equal (no frame on FOCUS_TWEEN_MS) — monotone-step assertion instead; (3) 1.2% arrival residual under spin (autoRotate inherits the approach's last-frame base) — reviewer asked to derive and recommend; (4) isActive(s, followElapsedMs?) optional on the generic contract. Package review-b8b4946a9..6a87cb428.diff; reviewer dispatched (fable, agent a→notification) with fixture attribution named as check (a). +- T18 review (fable): Needs fixes. Fixture attribution VERIFIED cell by cell (48→47 = thinner rule; follow legs winner-only; spin leg + inherited yaw lag 2 steps / distance −1.2e-4 downstream). Important: (1) unsaturated hand-off — brief's "saturated at 1 by construction" premise false (no frame lands on FOCUS_TWEEN_MS; t_last = 592/600 → ease 0.99999763; residual (1−t)³×span = 1.2% harness / 10.7% 60 Hz worst / ~86% 30 fps / unbounded from far vantages under spin). RULING: part of the ruled fix's intent → fix round 1 (agent resumed): FollowMemory.saturated; pick arg `approachDone` boolean (deletes the pre-advance epoch read + followElapsedMs optional); approach authors one saturated frame; tests 1/2 assert framing/equality to the float floor; re-record + attribute. Cost if wrong: one memory field. (2) comment budget: commitOnEdge.ts header 11 + 0.56; CameraDriver.d.ts 16/15 — trims in the same round. Minors: `epoch` row field to replace isFollowDriverId ×6 + elapsedForWinner chain + eligibility literal → T20 radar candidate (recorded); test-2 RED log mismatch → report correction. +- T18 fix round 1 DONE abd30c9ba (FollowMemory.saturated; approachDone boolean; pre-advance epoch read + followElapsedMs deleted; test 2 exact equality; test 1 framing to 1e-12; derivation verified 1.215% = measured 1.01215; corrected RED; suite 1261/8765; settle unmoved; driver fixture re-recorded 207 cells). NEW FINDING (pre-existing, exposed): wheel notch DROPPED on the hand-off frame under spin — replayInput routes by LAST frame's winner; fix needs pick-before-drain (design change) → ADJACENT FINDING for USER (ask, don't backlog); fixture pins the drop for now. Scoped re-review dispatched (opus) with the attribution + exactly-one-saturated-frame checks named. Residuals for T20: `saturated` belongs with the `author` un-braid; DriverCtx.d.ts 10/17 + FollowMemory.d.ts 10/8 over budget (pre-existing +1 line each). +- T18 re-review (opus): Accepted. Both Importants fixed (exactly one saturated frame verified from the fixture: 736 unsaturated → 768 saturated → 784 spin). Dropped-notch: attribution exact (6 cells at the leg, distance bit-identical to predecessor; 133 inherited), pre-existing route-by-last-winner rule, frequency raised by the split (every spin-on focus has a hand-off frame). Ruling: pin + MARK (comment at the leg, controller commit) rather than nudge the script; fix = pick-before-drain → USER adjacent finding. Task 18: complete (6a87cb428 + abd30c9ba + marker commit). Residuals for T20: shouldKeepTicking keeps the clock rule (last copy; cannot strand); `author`/`epoch` row field un-braid (isFollowDriverId ×6, elapsedForWinner chain, eligibility literal, `saturated`); DriverCtx.d.ts/FollowMemory.d.ts over budget; trace does not pin `saturated`; makeDriverCtx approachDone default flat false. +- 2026-09-10 T19 dispatched (opus, ISOLATION worktree detached at BASE 24182c2c3 with its own dev server; HEAD server = camera-pivot @ e0985c5d4 on http://localhost:5174, shell by0k2nzhz — KILL after T19). Paired A/B ≥3 pairs, MERGED medians, wall-clock probe if reachable. Neutral/negative → HALT → USER ruling (land/park). T20 ‖ T21 may run during T19 (disjoint: audits + docs vs measurement). Dispatching T20 (opus, in camera-pivot) and T21 (opus, isolation worktree) now. +- 2026-09-10 T20 dispatched (opus, ISOLATION worktree, branch t20-audits off e0985c5d4; residuals 1–9 listed in the dispatch; prime candidate = `epoch`/`author` row field un-braid). T21 dispatched (opus, ISOLATION worktree, branch t21-bookkeeping off e0985c5d4; spec deviations enumerated in the dispatch). Both cherry-pick onto camera-pivot AFTER T19 finishes (HEAD server must not change mid-measurement); T20 review = opus per-commit package; T21 review = sonnet. Then final whole-branch review (fable) → archive ledger → parent T19–T22 → /feature-done. +- 2026-09-10 T21 returned DONE (6e1b2b55f in wt agent-a853fbd33e8e6842a; report copied). Cherry-picked → camera-pivot 67c6316d2 (docs only; safe during T19). Flagged out of scope: parent plan Task 13 body still sketches pre-prep shapes; spec's J1–J7 "blocker today" table and incumbent line refs describe 1d44398e5. Reviewer dispatched (sonnet). +- T21 review (sonnet): Approved; every spec signature verified against code. Minor: this plan's own File-structure Created list omits StepInputs.d.ts, surfaceGestureEdge.ts, isFollowDriverId.ts — fold into the ledger-archive step (plan file edit at /feature-done). Task 21: complete (67c6316d2). Push deferred until T19 finishes (server unaffected by docs, but keep one push). +- 2026-09-10 T19 DONE: paired A/B, 4 pairs GPU harness (A 24182c2c3 @5175 vs B e0985c5d4 @5174): sum of MERGED medians 210.8 vs 210.8 ms, every per-scenario Δ inside its own spread (noise floor ~1.5 ms, 3× the skill's nominal); counter-balanced rAF wall-clock probe (6 pairs, forward + reversed arms — forward alone manufactures +1.85 ms thermal bias) Δ ≤ 0.3 ms on 9.5–15.8 ms frames, 3/4 poses fractionally faster. VERDICT: NEUTRAL (the expectation and the bar). Report + 17 raw files → task-19-report.md, perf-T19/. Per plan + code-is-liability rule: NEUTRAL HALTS THE LANDING PIPELINE → USER ruling land/park. T20 audits + final review continue (not landing steps). Task 19: complete. +- 2026-09-10 T20 returned DONE (8 commits in wt agent-a81e702dfd4190b34; net −171; byte bar held; suite 1260/8757). Cherry-picked → camera-pivot d43e8c5ec..76035a41a (BASE 67c6316d2): authoredWorldPose deleted; projectionOf inlined; ONE winner pick per frame (activeDriverId + runCameraDrivers deleted); CameraDriver.epoch tag replaces elapsedForWinner chain + eligibility literal; keep-alive reads saturation; debug snapshot flat gesture facts; .d.ts comment budget; EpochCells dropped. DECLINED → USER: simulateFrame duplicated in commitOnEdge.test + playClipFlyout (12 expectations to rewrite); makeSurfaceDriver (~50 sites); .d.ts still 2–5 lines over; adjacent: poseOf + assembleOrbitCamera now have projectionOf's shape (one boot caller). Trap recorded: resizeCanvasToDisplay's boolean IS read by scene-workbench + mcpm-workbench. Reviewer dispatched (opus). +- T20 review (opus): Approved; every deletion unreferenced, epoch tag exact row-for-row, keep-alive value-identical, debug truth table preserved, byte bar empty. Minors taken by controller (next commit): test helper double pick; approachDone doc line; keep-alive null-invariant line. Correction to declined item 5: `assembleOrbitCamera` has THREE callers — only `poseOf` (one boot caller) is the projectionOf-shaped candidate. Task 20: complete (d43e8c5ec..76035a41a + polish). ALL 21 TASKS COMPLETE. Next: final whole-branch review (fable) over 24182c2c3..HEAD → adjudicate → archive ledger → parent T19–T22 → /feature-done. OPEN USER RULINGS: (1) land/park on NEUTRAL perf; (2) dropped-notch route fix now/later; (3) simulateFrame duplication (12 expectations); (4) makeSurfaceDriver (~50 sites); (5) poseOf collapse (adjacent). +- 2026-09-10 FINAL whole-branch review dispatched (fable) over 24182c2c3..d015c7543 (43 commits, 136 files, +7040/−3630; package review-24182c2c3..d015c7543.diff). On return: ONE fix dispatch for Importants (if any), one scoped re-review, adjudicate residuals; then archive this ledger to docs/superpowers/plans/completed/2026-09-09-camera-runtime-single-writer.ledger.md (+ fix this plan's own File-structure list: StepInputs.d.ts, surfaceGestureEdge.ts, isFollowDriverId.ts, EpochRow.d.ts; note deletions from T20), delete the SDD workspace per Rule 3, update the task board artifact, then parent plan T19–T22 + /feature-done — gated on the USER's land/park ruling. +- 2026-09-10 FINAL REVIEW (fable): "Ready after the listed Important fixes"; no Critical. Important: (1) DriverCtx.approachDone + pivot populated but read by no row → delete; (2) spec :996–997 name winnerId / runCameraDrivers (deleted in T20; T21 landed before T20's pick) + plan Created/Deleted lists. New recorded delta (iv): wireInput re-seed resets all five groups under a pre-seed frame (benign). Sibling of the dropped notch found: a notch on the exact spin-OFF frame zooms the frozen pre-spin base and the edge commit bakes it — pre-existing, same route-by-last-winner family; one pick-before-drain fix covers both (→ USER ruling 2 context). Recommendations: LAND on neutral; dropped notch later as own change; collapse simulateFrame ×2 onto stepCameraRuntime NOW (playClipFlyout encodes the pre-T18 commit rule — drift, not just duplication); keep makeSurfaceDriver; poseOf in a follow-up. ONE fix dispatch (opus) sent: Importants 1–2 + minors (seedRememberedTilt→surfaceGestureEdge; deepFreeze import; NO_FOLLOW_MEMORY export; stale prose ×6; delete seedCameraRuntime.test; FollowMemory.d.ts budget). simulateFrame collapse NOT in this dispatch — it is user ruling 3 (reviewer recommends taking it). +- Final fix round DONE: 50e39a5a9 (DriverCtx fields dropped) + 751f237c9 (spec/plan lists; projectionOf's real old path src/services/engine/camera/) + d2c4b635b (polish); suite 1259/8755; byte bar + gate green; pushed. Scoped re-review dispatched (sonnet) on review-d015c7543..d2c4b635b.diff. On Accepted: archive ledger → docs/superpowers/plans/completed/2026-09-09-camera-runtime-single-writer.ledger.md, delete SDD workspace (keep perf raw files with the archive), memory update, board update; then await USER rulings 1–5 before parent T19–T22 + /feature-done. +- Final fix re-review (sonnet): Accepted; no new issues. PLAN EXECUTION COMPLETE at camera-pivot d2c4b635b (pushed). Deferred to /feature-done: ledger archive (Rule 3) + workspace deletion + plan/spec → completed/. GATED ON USER: ruling 1 land/park (reviewer: land); ruling 2 dropped notch + spin-OFF sibling (reviewer: later, own change); ruling 3 simulateFrame ×2 (reviewer: take now — playClipFlyout encodes the pre-T18 commit rule); ruling 4 makeSurfaceDriver (reviewer: keep); ruling 5 poseOf (reviewer: follow-up). Then parent plan T19–T22 (T22 = user feel gate incl. FW-E + RB-4 attestation) + /feature-done. +- 2026-09-10 USER RULINGS (asked one by one): (1) LAND on neutral perf. (2) dropped notch + spin-OFF sibling → LATER, own change: write `docs/backlog/2026-09-10-wheel-notch-route-by-last-winner.md` + index line (Camera area), pinned marker test stays. (3) simulateFrame ×2 → COLLAPSE NOW onto stepCameraRuntime (commitOnEdge.test + playClipFlyout.integration.test; the latter's pre-T18 commit rule is the drift to remove). (4) makeSurfaceDriver → KEEP. (5) poseOf → INLINE NOW (`npm run refactor -- inline`, one caller wireInput.ts:119; poseOf.test.ts goes with it). Post-ruling dispatch: ONE opus implementer in camera-pivot, three commits (tests / refactor / docs), review, push; then ledger archive → parent T19–T22 → /feature-done. +- 2026-09-10 Post-ruling implementer dispatched (opus, in camera-pivot, BASE d2c4b635b): commit 1 simulateFrame collapse (tests), commit 2 poseOf inline (refactor), commit 3 backlog `2026-09-10-wheel-notch-route-by-last-winner.md` + index line. Report → post-ruling-report.md. On DONE: review-package d2c4b635b..HEAD, reviewer (opus), push, then archive ledger → parent T19–T22 → /feature-done. +- Post-ruling implementer DONE_WITH_CONCERNS: 8d8b9300e (simulateFrame ×2 → real stepCameraRuntime; no assertion changed — drift was in the fixture's stage order, not its expectations), 9bc2f7ab0 (poseOf inlined; poseOf.test.ts's roll round-trip `it` dropped — impl is `roll: pose.roll`, a field mirror), 3a244853a (backlog detail + index line). Suite 1258/8749, tsgo clean, traces + gate green. Reviewer dispatched (opus) on review-d2c4b635b..3a244853a.diff. On Accepted: push → archive ledger → parent T19–T22 → /feature-done. +- Post-ruling review (opus): 8d8b9300e Accepted (helper ≡ runFrame inputs/order; no assertion changed; gate untouched), 9bc2f7ab0 Accepted (roll `it` correctly dropped = field mirror; note: `roll?` optional on both sides so deleting `roll: pose.roll` compiles silently — a VP-level rolled-pose test would close it, not taken), 3a244853a Needs fix: blocker misstated (pick reads post-replay rootState + drained.follow.saturated, NOT epochs; advanceEpochs already after pick). Minors: tag ready→needs-design, drain vs replay wording ×2, simulateCameraFrame comment misses replayInput as a commit source, anti-alias target copy lost its assertion. Fix round 1 sent to the same implementer (one docs commit; item 5 only if a wireInput test file exists). Then sonnet scoped re-review → push → archive. +- Fix round 1 DONE 5a4b83e22 (blocker corrected, tag needs-design, wording, helper comment, wireInput anti-alias assertion added + mutation-verified). Scoped re-review dispatched (sonnet) on review-3a244853a..5a4b83e22.diff. On Accepted: push camera-pivot → archive ledger (Rule 3) → parent T19–T22 → /feature-done. +- Scoped re-review (sonnet): Accepted, no new findings (131 files / 1442 tests, tsgo clean). PUSHED camera-pivot @ 5a4b83e22. POST-RULING WORK COMPLETE. Archived per Rule 3 → docs/superpowers/plans/completed/2026-09-09-camera-runtime-single-writer.ledger.md (+ .perf-T19.md = the T19 report; raw perf files not kept). Plan/spec → completed/ happens at /feature-done with the parent. Next: parent plan T19 ‖ T20 (isolation worktrees off 5a4b83e22) → T21 perf → T22 user feel gate → /feature-done. diff --git a/docs/superpowers/plans/completed/2026-09-09-camera-runtime-single-writer.md b/docs/superpowers/plans/completed/2026-09-09-camera-runtime-single-writer.md new file mode 100644 index 0000000000..7683881a96 --- /dev/null +++ b/docs/superpowers/plans/completed/2026-09-09-camera-runtime-single-writer.md @@ -0,0 +1,1165 @@ +# cameraRuntime single-writer — implementation plan + +> **Spec.** [`specs/2026-09-01-camera-pivot.md`](../specs/2026-09-01-camera-pivot.md), +> section **"Ground preparation — cameraRuntime single-writer (2026-09-09)"** — the +> binding authority for the shape. The checkpoint it was written from +> (`.superpowers/sdd/2026-09-01-camera-pivot/runtime-refactor-ground.md`) is signed +> off: shape approved, all five preps land as commits on PR #647, elapsed-unit +> unification goes in P1. +> **Supersedes.** Task 18 of [`2026-09-01-camera-pivot.md`](2026-09-01-camera-pivot.md) +> ("clock and frame-loop integration"). Its three tests are carried forward verbatim +> into Task 17 here, on the post-refactor shape. Tasks 19–22 stay in the parent plan. +> **Execution.** `subagent-driven-development` per +> [`conventions/sdd-execution.md`](../conventions/sdd-execution.md) — task list before +> Task 1, pipelined reviews, ledger archived on Finish. Ledger: +> `.superpowers/sdd/2026-09-01-camera-pivot/progress.md`. +> **Style.** [`conventions/plan-style.md`](../conventions/plan-style.md) — contract +> code only. Every line reference below points at `1d44398e5`; read the current file +> before editing it. + +## Goal + +`state.cameraRuntime` becomes a value with one writer. Today it is a bag of six +`{ current }` boxes written from eleven sites across five files, plus a mutable +`CameraClock` captured by reference by the clip player. `runFrame` ends the plan +holding exactly one assignment (`state.cameraRuntime = next`) and one dispatch loop, +fed by a pure `stepCameraRuntime`. The two features that motivated it — the parent +plan's T18 clock verification and R14-3's follow-driver split — then land as growth +in Phase 6 rather than as two more writers. + +## Architecture + +Five groups by lifetime and owner (`register` / `epochs` / `follow` / `surface` / +`outputs`), five pure stages in a fixed order, actions returned rather than +dispatched mid-step. The full type and stage list is the spec section; do not +re-derive them here, and do not deviate from them without a ruling. + +Three properties carry the whole design and every task below leans on one of them: + +1. **`advanceEpoch` is idempotent for an unchanged ref.** A second call in the same + frame cannot mean a second reset, because nothing mutates. That is what retires + the "safe to re-call" guards at `runFrame.ts:149-151,166` and the second clock + touch at `applyWheelZoom.ts:39` without needing a call-count discipline. +2. **Effective intent.** Stages after `replayInput` read + `cameraReducer(intent, actionsSoFar)`, reproducing exactly what + `runFrame.ts:117`'s post-drain `getState()` gives the driver table today. +3. **Structural sharing.** A stage returns its input group by identity when nothing + changed, so `prev.surface === next.surface` is a free "did this frame touch it" + test — used by the guard and by the harness freeze. + +## Tech stack + +TypeScript + Vitest. No new dependencies. `ts-morph` is already a devDependency +(`tests/services/engine/camera/oneMpcSeam.test.ts`) and is the guard test's engine. +No renderer, shader, `.wesl` or GPU file is touched by any task in this plan. + +## Global constraints + +Binding on every task; do not restate them in commit messages. + +- **Byte bar.** `tests/services/engine/frame/settleGoldenTrace.test.ts` and the + Task 1 driver leg must pass **unmodified** at the end of every task. Re-recording + a golden fixture (`SETTLE_GOLDEN_RECORD=1`) is forbidden in this plan: there is no + ruled behaviour change in it. A task that cannot hold the trace is a task that + found a real behaviour difference — **stop and report it**, do not re-record. +- `npm test` and `npm run typecheck` green at the end of every task. +- **No behaviour change.** This is a refactor. If a task changes a rendered pixel, + a dispatched action, or the order actions reach the store within a frame, it is + wrong. The one accepted difference, ruled at the checkpoint: from Task 13 on, + store listeners see the frame's runtime already installed when the frame's actions + arrive (they run after `state.cameraRuntime = next`, not before). +- **Every file/symbol move, rename or deletion goes through the refactor CLI** — + `npm run refactor -- move `, `npm run refactor -- rename `, + `npm run refactor -- delete `. Each task names the subcommand in its step. + Hand-editing import paths after a move is always wrong + (`.claude/skills/refactor/SKILL.md`). +- `type` aliases, never `interface`. One exported type per file in `src/@types/`, one + exported function per file in `src/utils/`. `src/services/**` files may export a + small related set (`cameraEpochs.ts` and `surfaceStep.ts` are the two here). +- Comments per [`conventions/comments.md`](../conventions/comments.md): module header + ≤ 10 lines, comment lines ≤ half the code lines, **checked on every file a task + touches** (the branch was already audited to that budget — do not regress it). + Every landmine comment being deleted from a site must land at the site that + inherits the fact, or be deliberately dropped with a reason in the review package. +- Tests per [`conventions/testing.md`](../conventions/testing.md). Specifically: + **no** runtime tests of the new `.d.ts` shapes, **no** restatement of driver + priorities or `FOCUS_TWEEN_MS`, **no** mirror tests. The one permitted structural + test is the Task 16 guard (a cross-file contract with no behavioural expression, + the same keep-rule `oneMpcSeam.test.ts` cites). + +## Parallelism + +The executor asks the user for a parallelism level before Task 1. Tasks marked +**‖** have file sets disjoint from the tasks they are grouped with and may run in +their own worktrees, cherry-picked onto the execution branch. Everything else is +strictly sequential: it edits `runFrame.ts`, `drainInput.ts`, `cameraDrivers.ts` or +the harness, which every other task also reads. + +- **‖ group A:** Task 1, Task 2, Task 10 (all create-only, no shared file). +- **‖ group B:** Task 20, Task 21 (an audit pass and a docs pass). +- All others sequential, in number order. + +--- + +## Phase 0 — the byte bar + +### Task 1: driver/epoch golden leg + perf baseline ‖ + +The existing bar covers the settle mechanisms through a gesture script only. Nothing +pins the driver arbitration, the five epochs, or the action stream — which is exactly +what Phases 1–5 move. This task adds the missing leg before anything changes. + +**Files (create):** `tests/services/engine/frame/driverGoldenTrace.test.ts`, +`tests/fixtures/camera/driverGoldenTrace.json` +**Files (read, do not modify):** `tests/services/engine/frame/settleGoldenTrace.test.ts` +(the record/thin/compare shape to copy), `tests/helpers/camera/makeCameraSimHarness.ts:84-95` + +**What the fixture records, per recorded frame** (the contract — not the code): + +| field | source | +| ----------------------- | ---------------------------------------------------------------------------- | +| `label` | the script leg | +| `winner` | the winning driver id that frame | +| `displayed`, `register` | pose numbers to 12 significant digits, as `settleGoldenTrace` | +| `follow` | `from.distance`, `distanceTarget`, `panOffset` (nulls recorded as `null`) | +| `epochs` | each row's `startMs` **relative to script start**, and whether `ref` is null | +| `actions` | the action `type` strings dispatched during that frame, **in order** | + +The action stream is the load-bearing column: Phase 4 moves _when_ dispatch happens, +and this is what proves the _what_ and the _order_ did not move with it. + +**Script legs** (each must be reachable — assert the winner it names, the way +`settleGoldenTrace.ts:222` asserts its gesture mode, or the leg is silently vacuous): + +- boot world-armed at h/R 5, resting; +- focus Earth → the follow approach easing to framing; +- one at-rest wheel notch **mid-approach** (the notch the follow driver swallows — + `applyWheelZoom.ts:32-35`, and `drainInput.ts:230-255`'s roll ride); +- `autoRotate` on, then a notch under it (`applyWheelZoom.ts:36-41`); +- a focus tween dispatched and run to completion (the `cancelCameraTween` edge); +- a **looping** clip run past its duration so `clipPlayer.ts:262-273` rewinds once; +- clip end, `autoRotate` off, focus cleared. + +- [ ] Write the test with the record path (`DRIVER_GOLDEN_RECORD=1`) and the compare + path, mirroring `settleGoldenTrace.test.ts`'s `thin` / `expectTraceMatches`. +- [ ] Record the fixture; `npm test -- driverGoldenTrace` green. +- [ ] **Mutation-verify it has teeth**: temporarily change one driver priority, one + epoch reset condition, and the dispatch order in `drainInput`; each must fail + the trace. Record which cells moved, in the ledger. Revert. +- [ ] Read `.claude/skills/perf/SKILL.md`. Start **this worktree's** dev server and + run `npm run perf -- --url http://localhost:` — a run without + `--url` silently measures another branch's server. +- [ ] Record the full MERGED / PER-LAYER / FLOOR output verbatim in the ledger under + `Task 1 baseline` (Task 19 diffs against it; a summary is not comparable). +- [ ] Commit: `test(camera): golden trace leg for drivers, epochs and the action stream`. + +--- + +## Phase 1 — P1: epochs (J2, J6) + +Ends with `CameraClock` deleted, five epochs advanced through one pure primitive, and +the clip player holding no reference into the runtime. + +### Task 2: `Epoch` / `CameraEpochs` types and the two pure primitives ‖ + +No consumer. New files and their test only. + +**Files (create):** `src/@types/engine/camera/Epoch.d.ts`, +`src/@types/engine/camera/CameraEpochs.d.ts`, +`src/services/engine/camera/cameraEpochs.ts`, +`tests/services/engine/camera/cameraEpochs.test.ts` + +**Interfaces (produces):** + +```ts +export type Epoch = { readonly ref: Ref | null; readonly startMs: number | null }; + +export type CameraEpochs = { + readonly tween: Epoch; + readonly frameTween: Epoch; + readonly autoRotate: Epoch; + readonly follow: Epoch; + readonly clip: Epoch>; +}; + +export function advanceEpoch(prev: Epoch, ref: Ref | null, nowMs: number): Epoch; +export function elapsedMs(epoch: Epoch, nowMs: number): number; +``` + +**Behaviour** (from `cameraClock.ts:36-110`, which stays in place this task): +`advanceEpoch` returns `prev` **by identity** when `ref === prev.ref`; otherwise +`{ ref, startMs: ref === null ? null : nowMs }`. `elapsedMs` returns `0` for a null +`startMs`, else `nowMs - startMs`. Milliseconds, always — the `autoRotate` row's +`ref` is `active ? base : null`, folding `lastAutoRotateActive` and `lastBaseRef` +(`CameraClock.d.ts:16,18,30`) into one reset condition. + +- [ ] Failing test `advanceEpoch returns the same object when the ref is unchanged` + (assert `toBe`, not `toEqual` — structural sharing is the contract Task 15 and + Task 16 rely on). +- [ ] Failing test `advanceEpoch restarts the clock on a ref change` and + `…clears startMs when the ref goes null`. +- [ ] Failing test `a second advance in the same frame is a no-op` — advance twice + with the same ref at two different `nowMs`, assert `elapsedMs` measures from + the first. This is the property that lets Task 4 delete the second-call guards. +- [ ] Failing test `elapsedMs of an unstarted epoch is 0`. +- [ ] `npm test -- cameraEpochs` red → implement → green. +- [ ] Commit: `feat(camera): pure Epoch primitives beside the camera clock`. + +### Task 3: `advanceEpochs` — the five rows in one call + +**Files (modify):** `src/services/engine/camera/cameraEpochs.ts`, +`tests/services/engine/camera/cameraEpochs.test.ts` + +**Interfaces (produces):** + +```ts +export function advanceEpochs( + prev: CameraEpochs, + inputs: { + readonly intent: CameraState; + readonly focus: SelectionRow | null; + readonly clip: Epoch>; + readonly winnerId: string; + readonly nowMs: number; + }, +): CameraEpochs; +``` + +**The eligibility rule, and why it is not "advance everything".** Today three of the +five reset only on the frame their driver _wins_, because `elapsedForWinner` +(`cameraDrivers.ts:52-64`) is their only caller. A tween dispatched while a drag +holds starts its ease when the drag ends, not when it was dispatched. Advancing +unconditionally would burn that ease. So the row's live ref is consulted only when +its owner is eligible, otherwise the row is returned unchanged: + +| row | eligible when | live ref | +| ------------ | ------------------------------------------------------------------------ | ----------------------------------------------- | +| `tween` | `winnerId === 'tween'` | `intent.tween` | +| `autoRotate` | `winnerId === 'autoRotate'` | `intent.autoRotate.active ? intent.base : null` | +| `follow` | `winnerId` is a follow row | `focus` | +| `frameTween` | always (`resolveFrameBasis.ts:90` runs every frame) | `intent.frameTween` | +| `clip` | never — the row is `inputs.clip`, already advanced by its owner (Task 5) | — | + +Express it as a row table, not a chain of `if (winnerId === …)` +([`simplicity.md`](../conventions/simplicity.md) §7). Returns `prev` by identity when +no row moved. + +- [ ] Failing test `a tween that is not winning does not start its epoch` — the + regression the eligibility table exists to prevent. +- [ ] Failing test `the frameTween epoch advances on a frame no driver owns it`. +- [ ] Failing test `an unchanged frame returns the same epochs object` (`toBe`). +- [ ] Failing test `the clip row is passed through untouched`. +- [ ] `npm test -- cameraEpochs` red → implement → green. +- [ ] Commit: `feat(camera): advanceEpochs — one advance site per epoch per frame`. + +### Task 4: migrate every clock reader onto the epochs + +The mechanical swap. `CameraRuntime.clock` becomes `epochs: CameraEpochs` plus +`follow: FollowMemory | null` — the three follow fields are regrouped now (so P2 only +has to change _who writes them_, not _where they live_) and are still written in +place by the same three sites. + +**Files (create):** `src/@types/engine/camera/FollowMemory.d.ts` +**Files (modify):** `src/@types/engine/state/CameraRuntime.d.ts:12,20`, +`src/services/engine/camera/cameraDrivers.ts:24,52-79,141,157-187`, +`src/services/engine/camera/applyWheelZoom.ts:12,21-42`, +`src/services/engine/camera/resolveFrameBasis.ts:79-94`, +`src/services/engine/frame/runFrame.ts:33,132,141-172,209-216`, +`src/services/engine/frame/drainInput.ts:136-141,193,240`, +`src/services/engine/helpers/shouldKeepTicking.ts:113-116`, +`src/services/engine/engine.ts:114`, plus the 19 test files that build a +`cameraRuntime` literal and the 4 that touch clock fields — the full list is +`.superpowers/sdd/2026-09-01-camera-pivot/runtime-trace.md` §7. +**Files (delete):** `src/services/engine/camera/cameraClock.ts`, +`src/@types/engine/camera/CameraClock.d.ts`, +`tests/services/engine/camera/cameraClock.test.ts` (its coverage now lives in +`cameraEpochs.test.ts`; re-home anything it asserts that Task 2/3 do not). + +**Interfaces (produces):** + +```ts +export type FollowMemory = { + readonly from: CameraPose | null; + readonly distanceTarget: number | null; + readonly panOffset: Vec3; +}; +``` + +**Contracts to preserve, each already load-bearing:** + +- `runFrame.ts:152-161` and `:167-172` stop re-calling an elapsed fn and read + `elapsedMs` off the epoch the step already advanced. The "re-calling is safe" + comments go with the calls — the fact is now in `advanceEpoch`'s idempotence test. +- `applyWheelZoom` loses its `clock` parameter and takes + `autoRotateElapsedMs: number` instead (computed by the caller from + `advanceEpoch(prev.epochs.autoRotate, …)`); the follow branch at `:32-35` is + untouched this task beyond reading `runtime.follow`. +- `followElapsed`'s side effect (`cameraClock.ts:97-110`: nulling `followFrom` / + `followDistanceTarget` and zeroing `panOffset` on a focus-row change) is **not** an + epoch concern. It becomes one line in `runFrame` beside the advance: + `if (next.epochs.follow.ref !== prev.epochs.follow.ref) runtime.follow = null`. + Task 8 moves it into the step; keep it verbatim here. +- `shouldKeepTicking.ts:114` reads `epochs.follow.startMs`. + +- [ ] `npm run refactor -- refs createCameraClock` and `-- refs CameraClock` first; + the migration list above must match what the CLI reports. +- [ ] Migrate `src/`, then the test literals. No test assertion changes: only the + shape of the literal moves. +- [ ] `npm run refactor -- delete createCameraClock` (then `tweenElapsed`, + `frameTweenElapsed`, `autoRotateElapsed`, `clipElapsed`, `followElapsed`), then + `npm run refactor -- delete CameraClock`. +- [ ] `npm test` and `npm run typecheck` green; **both golden traces byte-identical**. +- [ ] Comment budget on every touched file. +- [ ] Commit: `refactor(camera): CameraClock becomes CameraEpochs + FollowMemory`. + +### Task 5: the clip-player epoch handshake + +Kills the one true reference capture (`engine.ts:280`). + +**Files (modify):** `src/@types/engine/subsystems/ClipPlayer.d.ts:33-38`, +`src/services/engine/subsystems/clipPlayer.ts:98-104,142,241,262-273`, +`src/services/engine/engine.ts:276-282`, +`src/services/engine/frame/runFrame.ts:89`, +`tests/services/engine/animation/playClipFlyout.integration.test.ts:48-55`, plus the +clipPlayer tests under `tests/services/engine/subsystems/`. + +**Interfaces (produces):** + +```ts +tick(clipEpoch: Epoch>, nowMs: number): { + readonly clipEpoch: Epoch>; +}; +``` + +The player advances the epoch it was handed (`advanceEpoch` against +`store.getState().camera.clip`), reads elapsed off it, and returns it — rebased on a +loop wrap: `{ ...advanced, startMs: nowMs - overshootSec * 1000 }`, replacing the +in-place write at `clipPlayer.ts:270`. `ClipPlayerDeps.clock` is deleted. +`runFrame`'s first statement becomes +`const { clipEpoch } = state.subsystems.clipPlayer.tick(state.cameraRuntime.epochs.clip, nowMs);` +and `clipEpoch` is threaded to `advanceEpochs` — it must stay the **first** +statement, because a cue it fires can dispatch a `frameTween` this frame's basis +must see (`applySceneEffect`'s `frameTo`). + +- [ ] Failing test `a looping clip's rewind returns a rebased epoch, not a mutated one` + — assert the returned `startMs` and that the input epoch object is unchanged. +- [ ] Failing test `tick starts the epoch on the clip's arrival frame` (elapsed 0). +- [ ] Failing test `the player holds no reference to the runtime` — construct a + player, replace `state.cameraRuntime` wholesale, tick, assert the returned + epoch derives from the epoch passed in. +- [ ] Implement; `npm test -- clipPlayer playClipFlyout` green. +- [ ] Commit: `refactor(clip): clipPlayer takes and returns the clip epoch`. + +### Task 6: elapsed unit unification — clip in milliseconds + +Ruled at the checkpoint (option C, in P1). + +**Files (modify):** `src/@types/engine/camera/CameraDriver.d.ts:8` (the +"reads it as SECONDS" line goes), `src/services/engine/camera/cameraDrivers.ts:47-51,96,101-103`, +`src/services/engine/subsystems/clipPlayer.ts:240-241,249,255,261,269`, +`tests/services/engine/camera/cameraDrivers.test.ts`. + +Every consumer of the clip elapsed divides at the point of use: +`evaluateClip(clip.data, elapsedMs / 1000, …)` in the driver, `elapsedMs / 1000` for +the cue cursor and the duration compare in the player. `evaluateClip`'s own +`elapsedSec` parameter is **unchanged** — the conversion lives at its two call sites, +not inside it. + +- [ ] `npm run refactor -- refs clipElapsed`-equivalent sweep: grep the branch for + `SECONDS` in the camera path and confirm every hit is either `evaluateClip`'s + own parameter or a comment being deleted. +- [ ] Failing test `the clip driver's pose at 1500 ms matches the pose at 1.5 s of + clip time` — a hand-picked keyframe value, not a re-derivation through + `evaluateClip` (that would be a mirror). +- [ ] Implement; **both golden traces byte-identical** (the clip leg from Task 1 is + what proves the unit change is arithmetic-neutral). +- [ ] Commit: `refactor(camera): clip elapsed in ms like every other epoch`. +- [ ] **Phase gate:** `npm run typecheck`; `npm test -- cameraEpochs cameraDrivers + clipPlayer runFrame drainInput settleGoldenTrace driverGoldenTrace` green. + +--- + +## Phase 2 — P2: drivers return their memory (J1) + +### Task 7: `DriverCtx` and the `pose(ctx, mem) → { pose, memory }` contract + +**Files (create):** `src/@types/engine/camera/DriverCtx.d.ts` +**Files (modify):** `src/@types/engine/camera/CameraDriver.d.ts:14-27`, +`src/services/engine/camera/cameraDrivers.ts:36-255`, +`src/services/engine/frame/runFrame.ts:132-133`, +`tests/services/engine/camera/cameraDrivers.test.ts`, +`tests/services/engine/camera/commitOnEdge.test.ts:54-57`, +`tests/services/engine/animation/playClipFlyout.integration.test.ts:48-56`. + +**Interfaces (produces):** + +```ts +export type DriverCtx = { + readonly state: RootState; + readonly elapsedMs: number; + readonly register: FramedCameraPose; + readonly winnerLastFrame: string; + readonly simDays: number; + readonly projection: CameraProjection; + readonly pivot: PivotFraming; + /** This frame's notch-resolved follow distance, when the follow driver owns it. */ + readonly followDistanceTarget: number | null; +}; + +export type CameraDriver = { + readonly id: string; + readonly priority: number; + readonly commitsOnEdge?: boolean; + readonly pivotsOnFocusedBody?: boolean; + isActive(s: RootState): boolean; + pose( + ctx: DriverCtx, + mem: FollowMemory | null, + ): { readonly pose: FramedCameraPose; readonly memory: FollowMemory | null }; +}; + +export function runCameraDrivers( + drivers: readonly CameraDriver[], + ctx: DriverCtx, + mem: FollowMemory | null, +): { + readonly pose: FramedCameraPose; + readonly winner: CameraDriver; + readonly memory: FollowMemory | null; +}; +``` + +`buildCameraDrivers` stops closing over `EngineState`: `orbitDrag` reads +`ctx.register` (was `state.cameraRuntime.lastPose.current`, `cameraDrivers.ts:123`), +`followBody` reads `ctx.simDays` / `ctx.projection` / `ctx.winnerLastFrame` (was +`:143,182,184`). It becomes a module-level constant table — the same move +`galaxy-field`'s stage table made (commit `0a53aef82`). Every row that does not own +memory returns the `mem` it was handed, so the winner's adoption is uniform and there +is no "does this driver have memory" branch. + +`followBody`'s three writes at `:163-169`, `:180-183` and `:185` become fields of the +returned memory. Its capture rule is unchanged and still load-bearing: the capture is +eye-preserving against the **new** target (`:152-156`) — an Earth-orbit distance read +from Saturn's centre strands the camera inside Saturn. + +- [ ] Failing test `the winning driver's memory is adopted and the losers' discarded` + — two rows returning different memory, assert the winner's comes back. +- [ ] Failing test `followBody's capture is returned, not written` — call `pose` + twice with the same frozen `mem`, assert `mem` is unmutated and both calls + return the same capture. +- [ ] Failing test `orbitDrag produces the register handed to it in ctx`. +- [ ] Implement; both golden traces byte-identical. +- [ ] Commit: `refactor(camera): drivers take a ctx and return their memory`. + +### Task 8: the swallowed wheel notch and the follow roll ride + +`applyWheelZoom` stops writing. The notch a following camera swallows becomes an +input the driver consumes, and the roll ride that reads the target before and after +moves to where both values exist as data. + +**Files (modify):** `src/services/engine/camera/applyWheelZoom.ts:21-42`, +`src/services/engine/camera/cameraDrivers.ts` (the `followBody` row), +`src/services/engine/frame/drainInput.ts:179-257`, +`src/services/engine/frame/runFrame.ts:132-133`, +`tests/services/engine/camera/applyWheelZoom.test.ts`, +`tests/services/engine/frame/drainInput.test.ts`. + +**Interfaces (produces):** + +```ts +export function applyWheelZoom(args: { + readonly base: FramedCameraPose; + readonly factor: number; + readonly autoRotate: { readonly active: boolean; readonly rate: number }; + readonly autoRotateElapsedMs: number; + readonly pivot: PivotFraming; +}): CameraPose | null; + +// drainInput's return, this task only — Task 12 folds it into replayInput's. +export function drainInput( + state: EngineState, + deps: RunFrameDeps, + nowMs: number, +): { + readonly followDistanceTarget: number | null; +}; +``` + +**The route condition, unchanged from `applyWheelZoom.ts:31-35`:** the notch goes to +the follow driver exactly when `base.frame === 'absolute'` **and** +`winnerLastFrame === 'followBody'` **and** `follow?.distanceTarget !== null`. In every +other case `applyWheelZoom` behaves as today (null for a body arm, the spun-base +branch under autoRotate, the plain base otherwise). The drain resolves the notch with +the one `zoomedDistance` call site and hands the result over as +`ctx.followDistanceTarget`; the follow driver adopts it into its returned memory (no +formula in the driver). + +**The roll ride stays in `drainInput`** (ruled during execution: moved after +`runCameraDrivers` it lags the follow driver's roll lerp by one frame — the driver +reads the ride's commit from the store snapshot taken after the drain; both golden +traces caught it). Its pre/post pair is now data — the previous +`follow.distanceTarget` and the resolved `followDistanceTarget` — which removes the +read-mutate-read through the clock that the current site depends on. It +must not re-derive `zoomedDistance` itself — a second copy of that formula is a mirror. + +- [ ] Failing test `a notch under follow lands on the driver's distance target, not + the base` — assert the committed base is unchanged and the produced distance moved. +- [ ] Failing test `the follow roll ride fires on the notch's target change` — assert + the same `commitCameraPose` roll value the current fixture pins, from the new site. +- [ ] Failing test `applyWheelZoom writes nothing` — pass a frozen argument bag. +- [ ] Implement; both golden traces byte-identical (Task 1's mid-approach notch leg + is the one that proves it). +- [ ] Commit: `refactor(camera): the follow driver owns the notch it swallows`. + +### Task 9: the pan offset leaves `drainInput`'s hand + +**Files (modify):** `src/services/engine/frame/drainInput.ts:131-142`, +`src/services/engine/frame/runFrame.ts:205-216`, +`tests/services/engine/frame/drainInput.test.ts`. + +`drainInput`'s return grows `follow: FollowMemory | null`; the strafe accumulation at +`:137-141` produces a new `panOffset` in it instead of assigning through +`state.cameraRuntime.follow`. `runFrame` assigns the returned memory once, before the +driver call. The world-frame rationale at `CameraClock.d.ts:29` (a stable screen +strafe at follow scales, no camera-basis re-projection) moves to `FollowMemory`. + +- [ ] Failing test `a pan strafe under follow returns a new offset and mutates nothing` + — frozen input memory. +- [ ] Implement; golden traces byte-identical. +- [ ] Commit: `refactor(camera): the drain returns the follow memory it changed`. +- [ ] **Phase gate:** `npm run typecheck`; `npm test -- cameraDrivers applyWheelZoom + drainInput commitOnEdge playClipFlyout settleGoldenTrace driverGoldenTrace` green. + +--- + +## Phase 3 — P3: surface memory as data (J4) + +### Task 10: `SurfaceMemory`, `surfaceStep`, `noteBody` ‖ + +New files beside the incumbent controller; no caller migrates yet. + +**Files (create):** `src/@types/camera/SurfaceMemory.d.ts`, +`src/services/camera/surfaceStep.ts`, +`tests/services/camera/surfaceStep.test.ts` +**Files (read):** `src/services/camera/surfaceController.ts:27-118` — the body is +lifted verbatim; only the three closure variables become fields. + +**Interfaces (produces):** + +```ts +export type SurfaceMemory = { + readonly gesture: SurfaceGesture | null; + /** Pointer down: `gesture` stays null until the first drag step carries the press pixel. */ + readonly pointerDown: boolean; + readonly rememberedTiltRad: number; + readonly memoryBodyId: string | null; +}; + +export function surfaceStep( + prev: SurfaceMemory, + arm: BodyFixedPose, + step: InputStep, + ctx: { + readonly viewportPx: Readonly; + readonly fovYRad: number; + readonly bodyRadiusM: number; + readonly sceneUpLocal: Readonly; + }, +): { readonly pose: BodyFixedPose; readonly next: SurfaceMemory }; + +export function noteBody(prev: SurfaceMemory, bodyId: string | null): SurfaceMemory; +``` + +`{ gesture, pointerDown }` replaces the nested `live: { gesture } | null`. The +nesting existed to make "latched with the pointer up" (FW-C's trackpad burst) +unrepresentable; the flat pair does not, so `surfaceStep` must **assert the +invariant** rather than trust it: a `drag` step with `pointerDown === false` returns +`arm` untouched, exactly as `surfaceController.ts:68` does today. Say that in the +header in one line — it is the fact the shape change gives up. + +- [ ] Failing test `a drag with the pointer up is declined` (FW-C). +- [ ] Failing test `a tilt drag writes the un-mapped memory and returns a new object` + — frozen `prev`. +- [ ] Failing test `noteBody wipes the tilt on a different body and keeps it on null` + (ruling 18). +- [ ] Failing test `a zoom step never authors tilt` — the fixed-point property at + `surfaceController.ts:103-108`, asserted as "memory unchanged", not as a formula. +- [ ] Implement; `npm test -- surfaceStep` green. +- [ ] Commit: `feat(camera): surface gesture memory as data`. + +### Task 11: migrate the callers, delete the controller + +**Files (modify):** `src/@types/engine/state/CameraRuntime.d.ts:60-64`, +`src/services/engine/frame/drainInput.ts:72-79,149,170`, +`src/services/engine/frame/runFrame.ts:223-229,237`, +`src/services/engine/engine.ts:129,639,641`, +`tests/helpers/camera/makeCameraSimHarness.ts:17,92`, +`tests/helpers/camera/seedRememberedTilt.ts:20-50`, +`tests/services/camera/surfaceController.test.ts`, +`tests/services/camera/{rememberedTilt,northUpToggle,singularLocusRecession}.test.ts`, +`tests/services/engine/frame/settleGoldenTrace.test.ts:29,112,181` +**Files (delete):** `src/services/camera/surfaceController.ts`, +`src/@types/camera/SurfaceController.d.ts`. + +`runtime.surface` becomes a `SurfaceMemory` value. `onGestureStart` / `onGestureEnd` +become `{ ...prev, pointerDown: true/false, gesture: null }` at the two `drainInput` +sites. The debug snapshot at `engine.ts:639-641` reads +`state.cameraRuntime.surface.gesture` and `.rememberedTiltRad` directly — +`CameraDebugSnapshot`'s `gesture` field keeps its shape, so `cameraDebugSnapshotOf`'s +signature does not move. `seedRememberedTilt` threads the memory through its loop +instead of calling six methods on a closure. + +- [x] Migrate; `npm run refactor -- delete createSurfaceController` then + `npm run refactor -- delete SurfaceController`. +- [x] `npm test` green with **no assertion changes** in the four surface test files — + only the driving shape moves. An assertion that has to change is a behaviour + difference: stop and report. +- [x] `settleGoldenTrace` byte-identical (it reads the tilt memory at `:181`). +- [x] Comment budget on every touched file. +- [x] Commit: `refactor(camera): delete the surface controller closure`. +- [x] **Phase gate:** `npm run typecheck`; `npm test -- surfaceStep surfaceController + rememberedTilt northUpToggle singularLocus drainInput runFrame settleGoldenTrace + driverGoldenTrace` green. + +--- + +## Phase 4 — P4: pure input replay (J5, option A) + +### Task 12: `replayInput` + +**Files (create):** `src/services/engine/camera/replayInput.ts`, +`tests/services/engine/camera/replayInput.test.ts` +**Files (modify):** `src/services/engine/frame/drainInput.ts` (becomes the thin +caller: drain the aggregator, call `replayInput`, assign, dispatch), +`tests/services/engine/frame/drainInput.test.ts`. + +**Interfaces (produces):** + +```ts +export function replayInput( + prev: { + readonly register: FramedCameraPose; + readonly surface: SurfaceMemory; + readonly follow: FollowMemory | null; + }, + steps: readonly InputStep[], + ctx: { + readonly rootState: RootState; + readonly nowMs: number; + readonly canvasPx: Vec2; + readonly projection: CameraProjection; + readonly upBasis: Mat3; + readonly poseBasis: Mat3; + readonly bodies: ReadonlyMap; + readonly winnerLastFrame: string; + readonly autoRotateEpoch: Epoch; + }, +): { + readonly register: FramedCameraPose; + readonly surface: SurfaceMemory; + readonly follow: FollowMemory | null; + readonly lastZoomFactor: number | null; + readonly followDistanceTarget: number | null; + readonly actions: readonly UnknownAction[]; +}; +``` + +Four contracts the current file gets from the store and must now get from the fold: + +- **`store.getState()` per step** (`drainInput.ts:52,96,158,190`) becomes + `cameraReducer(ctx.rootState.camera, actionsSoFar)` folded into a + `{ ...rootState, camera }` snapshot. A step in the same drain must see the previous + step's commit — that is what `:52`'s fresh read buys today. +- **Every step writes the register** (`:80-83`) so a later step in the same drain + chains from it. The fold's accumulator carries it; no step reads `prev.register` + except the first. +- **The at-rest notch's commit is its gesture end** (`:88-91`) — identity-gated + (`next !== from`), because a declined step returns its input by reference. +- **`autoRotateEpoch` in, not out** (ruled at Task 12's review). The autoRotate zoom + branch needs elapsed _this_ frame, so replay advances the row locally for its own + `elapsedMs` read — and discards it, exactly as the incumbent drain did. Handing the + advanced row to `advanceEpochs` is NOT a no-op: when another driver wins, the row + replays `prev.ref`, so a fold-time reset would be kept where the incumbent never + stored one. Task 13 removes the field from the return shape. + +`beginDrag` / `cancelCameraTween` stay at DOM time in the `wireInput` emit sink +(`drainInput.ts:8-9`): a cancel must not outlive the tween a double-click starts in +the gap. They are **not** replay actions. + +- [ ] Failing test `replayInput mutates nothing` — deep-frozen `prev` and `steps`. +- [ ] Failing test `a step sees the commit dispatched by the step before it` — an + at-rest notch followed by a drag in one drain. +- [ ] Failing test `the returned actions are the four dispatch sites, in order` — + `commitCameraPose` at the at-rest body notch, at gesture end, `endDrag` after + it, and the roll-only follow notch. +- [ ] Failing test `a declined step emits no action` (the identity gate). +- [ ] Implement; `npm test -- replayInput drainInput` green; golden traces identical. +- [ ] Commit: `refactor(camera): replayInput is pure and returns its actions`. + +### Task 13: effective intent, and dispatch after the write + +**Files (modify):** `src/services/engine/frame/runFrame.ts:111-133`, +`tests/services/engine/frame/runFrame.test.ts` +**Files (delete):** `src/services/engine/frame/drainInput.ts` (its remaining body is +three lines in `runFrame`; the aggregator drain moves there). + +`runFrame` calls `replayInput`, assigns the returned register/surface/follow, **then** +dispatches the actions, then builds the effective root state for the driver stage: + +```ts +const camera = actions.reduce(cameraReducer, rootState.camera); +const effective = camera === rootState.camera ? rootState : { ...rootState, camera }; +``` + +`cameraReducer` is `cameraSlice`'s default export. Non-camera actions passed through +it are no-ops (RTK reducers ignore unknown types), so the fold stays total and the +`engineScaleChanged` / `engineBodyDistanceReported` dispatches later in the frame need +no special case. This preserves what `runFrame.ts:117`'s post-drain `getState()` gives +the driver table today — most importantly `endDrag`, without which `orbitDrag` wins +one frame too long. + +- [ ] Failing test `the driver table sees a commit the same frame the drain made it` + — the at-rest notch under the resting driver; assert the produced pose is the + committed one, not the pre-commit base. +- [ ] Failing test `a frame with no input reuses the store snapshot by identity` + (`toBe`) — the reducer fold must not allocate on steady frames. +- [ ] Failing test `the frame's actions reach the store in the same order as before` + — a middleware recorder over one mixed frame, compared against the Task 1 + fixture's `actions` column. +- [ ] `npm run refactor -- delete drainInput` after the body has moved. +- [ ] Implement; both golden traces byte-identical. +- [ ] Commit: `refactor(camera): effective intent, dispatch after the register write`. +- [ ] **Phase gate:** `npm run typecheck`; `npm test -- replayInput runFrame + settleGoldenTrace driverGoldenTrace` green. + +--- + +## Phase 5 — P5: the runtime as a value (J3, J7, G) + +### Task 14: the five groups, boxes removed + +Mechanical and wide. `runFrame` still writes fields individually at the end of this +task; Task 15 collapses those writes into one. + +**Files (create):** `src/@types/engine/state/FrameOutputs.d.ts`, +`src/services/engine/camera/seedCameraRuntime.ts`, +`tests/services/engine/camera/seedCameraRuntime.test.ts` +**Files (modify):** `src/@types/engine/state/CameraRuntime.d.ts` (whole file), +`src/services/engine/engine.ts:113-131,453-459,625-641`, +`src/services/engine/phases/wireInput.ts:119-123`, +`src/services/engine/frame/runFrame.ts` (every `.current`), +`src/services/engine/camera/{liveUpBasisQuat,cameraDrivers,replayInput}.ts`, +`src/services/engine/helpers/{liveWorldPose,authoredWorldPose,liveRenderCamera,shouldKeepTicking}.ts`, +`src/services/engine/frame/{pickFrameContext,skyCubemapFaceContext}.ts`, +`src/services/engine/wiring/buildDemandCtx.ts`, +`src/services/engine/effects/makeReconcileEffects.ts`, +`src/services/engine/animation/{playClip,applySceneEffect}.ts`, +plus the 19 test literals and the 2 box-capturing `simulateFrame` helpers +(`commitOnEdge.test.ts:54`, `playClipFlyout.integration.test.ts:48`) and +`makeCameraSimHarness.ts:84-95,117-121` — full list in `runtime-trace.md` §1 and §7. + +**Interfaces (produces):** the `CameraRuntime` / `FrameOutputs` types exactly as the +spec section states them, plus + +```ts +export function seedCameraRuntime(args: { + readonly committed: FramedCameraPose; + readonly projection: CameraProjection; +}): CameraRuntime; +``` + +Field mapping, so nothing is invented: `lastPose` → `register.pose`; `prevActiveId` +→ `register.winner`; `displayedPose` → `outputs.displayed`; `lastRenderedSimDays` → +`outputs.simDays`; `upBasis` → `outputs.upBasis`; `projection` → `outputs.projection`; +`lastZoomFactor` → `outputs.lastZoomFactor`. + +`seedCameraRuntime` replaces **both** seed sites: the literal at `engine.ts:113-131` +and the four writes at `wireInput.ts:119-122`. The harness's `seedPose` +(`makeCameraSimHarness.ts:117-121`) dispatches the commit and then replaces the bag +through the same function. + +- [ ] Failing test `seedCameraRuntime seeds displayed from the committed pose` — the + "nothing has been projected yet" invariant at `engine.ts:118-121`. +- [ ] Failing test `the seed copies the committed pose` — mutate the input, assert the + runtime is unaffected (today's `{ ...base }` at `engine.ts:117`). +- [ ] Migrate `src/`, then tests. No assertion changes. +- [ ] `npm test` green; both golden traces byte-identical. +- [ ] Comment budget: `CameraRuntime.d.ts`'s header loses the `{ current }` paragraph + and keeps the R12b-1 authored-vs-displayed contract, which is still load-bearing. +- [ ] Commit: `refactor(camera): cameraRuntime as five value groups`. + +### Task 15: `stepCameraRuntime` — one assignment, one dispatch loop + +**Files (create):** `src/services/engine/camera/stepCameraRuntime.ts`, +`src/services/engine/frame/projectFramePose.ts`, +`src/services/engine/camera/commitOnEdge.ts`, +`tests/services/engine/camera/stepCameraRuntime.test.ts` +**Files (modify):** `src/services/engine/frame/runFrame.ts:99-133,140-172,174-312`, +`src/services/engine/camera/cameraFraming.ts:45` (add `NEAR_CLIP_MPC = 0.01`, the +literal at `:89` and its two copies at `engine.ts:114` and the harness), +`tests/services/engine/frame/{runFrame,poseFold}.test.ts`. + +**Interfaces (produces):** + +```ts +export type StepInputs = { + readonly nowMs: number; + readonly simDays: number; + readonly rootState: RootState; + readonly focus: SelectionRow | null; + readonly canvasPx: Vec2; + readonly steps: readonly InputStep[]; + readonly bodies: ReadonlyMap; + readonly clipEpoch: Epoch>; + readonly drivers: readonly CameraDriver[]; +}; + +export function stepCameraRuntime( + prev: CameraRuntime, + inputs: StepInputs, +): { + readonly next: CameraRuntime; + readonly actions: readonly UnknownAction[]; + readonly requestRender: boolean; +}; + +export function commitOnEdge(args: { + readonly register: FramedCameraPose; + readonly displayed: FramedCameraPose; + readonly produced: FramedCameraPose; + readonly prevWinner: string; + readonly winner: CameraDriver; + readonly drivers: readonly CameraDriver[]; +}): { + readonly render: FramedCameraPose; + readonly authoredOverride: FramedCameraPose | null; + readonly actions: readonly UnknownAction[]; +}; + +export function projectFramePose(args: { + readonly render: FramedCameraPose; + readonly authoredOverride: FramedCameraPose | null; + readonly pivotsOnFocusedBody: boolean; + readonly focus: SelectionRow | null; + readonly simDays: number; + readonly follow: FollowMemory | null; + readonly surface: SurfaceMemory; + readonly intent: CameraState; + readonly bodies: ReadonlyMap; + readonly poseBasis: Mat3; + readonly upBasis: Mat3; + readonly dragging: boolean; +}): { + readonly register: FramedCameraPose; + readonly displayed: FramedCameraPose; + readonly surface: SurfaceMemory; + readonly actions: readonly UnknownAction[]; + readonly requestRender: boolean; +}; +``` + +`projectFramePose` is `runFrame.ts:205-306` lifted whole — pin, `noteBody`, tilt +projection, `resolveWorldArm`, the regime flip and the crossing commit. **Order is the +contract** (spec §7 steps 5-6: the fold is last, below every pose writer — FW-G). +`commitOnEdge` is `runFrame.ts:186-203`, including the R12c-1 rule that a pivoting +incoming driver renders the authored register while a non-pivoting one renders the +displayed box. + +`outputs.projection` is derived in-step from `inputs.canvasPx` and +`rootState.settings.camera.fovDeg` (replacing `runFrame.ts:99-103`) with +`NEAR_CLIP_MPC` / `FAR_CLIP_MPC` from `cameraFraming.ts`. `runFrame` keeps +`resizeCanvasToDisplay(deps.canvas)` — it resizes the backing store, a real side +effect that is not the camera's — and passes the resulting size in. + +`runCameraDrivers` returns the winning `CameraDriver` (its `commitsOnEdge` and +`pivotsOnFocusedBody` flags are needed by the next two stages); `next.register.winner` +is `winner.id`, so the runtime stores the id and only the step handles the row. + +`runFrame`'s camera section becomes: clip tick → derive sim days and bodies → step → +`state.cameraRuntime = next` → dispatch `actions` → `if (requestRender) …`. Roughly +150 lines leave the file. + +- [ ] Failing test `stepCameraRuntime does not mutate prev` — deep-frozen input, + every group. +- [ ] Failing test `an idle frame returns the epochs, surface and follow groups by + identity` (`toBe` on all three) — structural sharing. +- [ ] Failing test `the fold runs after the pin and the tilt projection` — the FW-G + call-order assertion the parent plan's Task 15 pinned, restated against + `projectFramePose`'s output rather than `runFrame`'s internals. +- [ ] Failing test `the projection follows a canvas resize and a FOV change within + the frame that caused it`. +- [ ] Implement; `npm test` green; **both golden traces byte-identical**. +- [ ] Comment budget on `runFrame.ts` (it shrinks; its header's step list must match + the new stages, not the old numbering). +- [ ] Commit: `refactor(camera): stepCameraRuntime — one writer, one frame`. + +### Task 16: the guard + +**Files (create):** `tests/services/engine/frame/cameraRuntimeSingleWriter.test.ts`, +`tests/helpers/deepFreeze.ts` +**Files (modify):** `tests/helpers/camera/makeCameraSimHarness.ts:150-159` + +Copy the structure of `tests/services/engine/camera/oneMpcSeam.test.ts` — ts-morph +over real AST nodes (not a source-text grep), a derived directory sweep, an explicit +"the sweep found real files" loud-failure check with known anchors, and an allow-list +whose entries are themselves asserted to be real. + +**What it forbids:** any `BinaryExpression` with an `EqualsToken` whose left-hand side +is a `PropertyAccessExpression` rooted at a `.cameraRuntime` access — both +`x.cameraRuntime = …` and `x.cameraRuntime. = …`. +**Allow-list:** `src/services/engine/frame/runFrame.ts` (the frame's one assignment) +and `src/services/engine/phases/wireInput.ts` (the boot seed). `engine.ts` is **not** +on it: after Task 14 it calls `seedCameraRuntime` in the state literal, which is a +call, not an assignment. +**Sweep:** `src/services`, `src/state`, `src/store`, `src/hooks`, `src/components`. +Anchors that must be present in the sweep: `runFrame.ts`, `wireInput.ts`, +`engine.ts`, `cameraDrivers.ts`, `replayInput.ts`. + +The harness freeze is the runtime half of the same guard: after every `tick`, +`deepFreeze(state.cameraRuntime)`. A stray write then throws inside the suite instead +of drifting silently. `deepFreeze` walks plain objects and arrays only and skips +frozen ones, so the shared `ORIENTATION_FRAMES` entries a pose may reference are not +re-walked every frame. + +- [ ] Write the gate test; confirm it **fails** with a deliberate + `state.cameraRuntime.outputs.simDays = 0` added to a swept file, then remove it. +- [ ] Add the harness freeze; run the whole camera suite — any throw is a real second + writer, not a test problem. Fix the writer. +- [ ] `npm test` green. +- [ ] Commit: `test(camera): gate the cameraRuntime single writer`. +- [ ] **Phase gate:** `npm run typecheck`; full `npm test` green; both golden traces + byte-identical. + +--- + +## Phase 6 — the features this prep was for + +### Task 17: the engaged-arm clock verification (supersedes parent T18) + +The parent plan's Task 18, carried forward verbatim. Its content is unchanged; what +changed is that it is now a test against a pure step rather than against a mutable +clock, so it can drive frames without a running engine. + +**Files (create):** `tests/services/engine/frame/engagedArmClock.test.ts` + +Spec §14's "Clock" verification, stated as an **equality, not a tolerance**: with the +sim clock at high rate and the arm engaged, a tracked ground point's body-fixed +coordinates are bit-identical across frames, because nothing in the engaged path +reads a world position. + +**Tests** (names as the parent plan wrote them): + +- `the tracked ground point is bit-identical across frames under a 10⁶× clock` (FW-F). +- `the engaged pose is unchanged by advancing the clock alone` — no gesture, no + driver input, just time. +- `crossing out of the arm under an accelerated clock does not snap the image` — the + H1 boundary; the perceptual judgement is the parent plan's Task 22 user gate. + +- [ ] TDD; `npm test -- engagedArmClock` green. +- [ ] Commit: `test(camera): engaged-arm clock invariance (spec §14)`. + +### Task 18: R14-3 — `followApproach` and `followHold` + +**The bug** (ledger `progress.md:1129`): `autoRotate` at priority 20 outranks +`followBody` at 10, so with the spin pill on, focusing Saturn from an engaged Earth +leaves the camera at 0.2589 R♄ — **inside Saturn**, unrecoverably: toggling the pill +off does not release the arm, and 60 wheel notches reach h/R 0.0009. + +**The fix** (recommended and checkpoint-approved): split the row in two. + +**Files (modify):** `src/services/engine/camera/cameraDrivers.ts`, +`src/services/engine/camera/cameraEpochs.ts` (the follow row's eligibility covers +both ids), `src/services/engine/helpers/shouldKeepTicking.ts:113`, +`tests/services/engine/camera/cameraDrivers.test.ts` +**Files (create):** `tests/services/engine/frame/followApproachStrand.test.ts` + +| id | priority | active when | +| ---------------- | -------- | -------------------------------------------------------------------------------------- | +| `followApproach` | 55 | the `followBody` conditions **and** `elapsedMs(epochs.follow, nowMs) < FOCUS_TWEEN_MS` | +| `followHold` | 10 | the `followBody` conditions | + +Both rows produce the same pose and return the same `FollowMemory`; the ease +parameter is `elapsedMs(epochs.follow, nowMs)` in both, so the hand-off at +`FOCUS_TWEEN_MS` is continuous by construction (`easeOutCubic` is saturated at 1 +there). `commitsOnEdge` and `pivotsOnFocusedBody` stay set on both. + +**Why 55 and not 60.** 60 is `tween`'s priority and `pickWinner` +(`cameraDrivers.ts:37-45`) breaks ties by table order — a tie is a latent +order-dependence, not a policy. 55 keeps the two relationships that matter: above +`autoRotate` (20), which is the bug; below `tween` (60), which is where `followBody` +already sits relative to an explicitly authored camera move. **Flag this at the +checkpoint** — the ledger's recommendation said 60. + +- [ ] Failing test `focusing a body with autoRotate on does not strand the camera + inside it` — the R14-3 reproduction: spin pill on, engaged over Earth, focus + Saturn, run frames to `FOCUS_TWEEN_MS`; assert the final h/R over Saturn is the + framing distance, not `< 1`. Must **fail before** the split and **pass after** — + record both runs in the ledger. +- [ ] Failing test `the approach hands off to the hold with no pose discontinuity` — + the frames either side of `FOCUS_TWEEN_MS`, asserted equal to the float floor. +- [ ] Failing test `an autoRotate spin resumes after the approach completes` — the + thing priority 55 must not break. +- [ ] Implement; `npm test` green. **The driver golden trace WILL move** on the + follow legs (this is the one ruled behaviour change in the plan): re-record it + with `DRIVER_GOLDEN_RECORD=1`, diff the recorded cells, and put the diff in the + commit body. `settleGoldenTrace` must **not** move — it never focuses under + autoRotate. +- [ ] Commit: `fix(camera): split the follow driver so autoRotate cannot strand it (R14-3)`. +- [ ] **Phase gate:** `npm run typecheck`; full `npm test` green. + +--- + +## Phase 7 — gate + +### Task 19: measurement + +**Files:** none. + +- [ ] `npm run perf -- --url http://localhost:`, same flags and + poses as Task 1. Read `.claude/skills/perf/SKILL.md` first. +- [ ] Diff against the Task 1 baseline verbatim in the ledger; interpret per the skill + (MERGED vs PER-LAYER vs FLOOR, Apple Silicon slot-sum inflation). +- [ ] Assert the golden traces' max deviation is **0**. If any cell differs, it must + be ≤ 1e-12 **and** carry a named arithmetic reason (an operation reordered by a + stage boundary) in the ledger. Anything else halts. +- [ ] The work is CPU-side and allocates a handful of small objects per frame where + it previously mutated in place; **neutral is the expectation and the bar**. A + neutral-or-negative measurement **halts the landing pipeline** — land or park is + the user's ruling, never process momentum. + +### Task 20: deletion audit + entanglement radar ‖ + +**Files:** whatever the audits find. + +- [ ] `deletion-audit` skill over the whole plan diff. Named candidates to rule on: + `projectionOf` (its last caller is `wireInput`'s seed, and the step derives the + projection each frame); `activeDriverId` (the step returns the winner); + `authoredWorldPose` vs `liveWorldPose` (two helpers over one register); + `lastZoomFactor` (kept — user ruled the debug UI stays). +- [ ] `entanglement-radar` skill over the whole diff. The design-time answers to + re-check against the code as landed: the epoch eligibility table (is it still a + table, or did it regrow a chain?), `SurfaceMemory`'s flat `pointerDown` pair, + and whether any stage reaches back into `EngineState` instead of taking its + input as an argument. +- [ ] Apply findings per [`conventions/leanness.md`](../conventions/leanness.md); + anything declined goes to the user, not to the backlog, per the standing rule. +- [ ] Commit each fix separately. + +### Task 21: parent plan and spec bookkeeping ‖ + +**Files (modify):** `docs/superpowers/plans/2026-09-01-camera-pivot.md` (Task 18 and +the File-structure block), `docs/superpowers/specs/2026-09-01-camera-pivot.md` (the +`_Ground preparation — cameraRuntime single-writer_` section, if the plan deviated +from it). + +- [ ] Replace the parent plan's Task 18 body with a one-line supersede pointer at + this plan's Task 17. **Do not strike it through** — delete the body; the + completion record is the git log. +- [ ] Add this plan's created files to the parent's "File structure" block, and + correct any of its "Modified" lines this plan invalidated (`cameraDrivers.ts`, + `drainInput.ts`, `runFrame.ts`, `wireInput.ts`, `engine.ts`). +- [ ] If any signature in the spec section moved during execution, correct the spec — + it is the binding authority and must match what shipped. +- [ ] Commit: `docs(camera): supersede T18, record the single-writer prep`. + +--- + +## File structure + +**Created** + +``` +src/@types/engine/camera/Epoch.d.ts T2 +src/@types/engine/camera/CameraEpochs.d.ts T2 +src/@types/engine/camera/FollowMemory.d.ts T4 +src/@types/engine/camera/DriverCtx.d.ts T7 +src/@types/camera/SurfaceMemory.d.ts T10 +src/@types/engine/state/FrameOutputs.d.ts T14 +src/@types/engine/camera/StepInputs.d.ts T15 +src/@types/engine/camera/EpochRow.d.ts T20 +src/services/engine/camera/cameraEpochs.ts T2, T3 +src/services/camera/surfaceStep.ts T10 +src/services/engine/camera/replayInput.ts T12 +src/services/engine/camera/seedCameraRuntime.ts T14 +src/services/engine/camera/stepCameraRuntime.ts T15 +src/services/engine/camera/commitOnEdge.ts T15 +src/services/engine/frame/projectFramePose.ts T15 +src/utils/camera/surfaceGestureEdge.ts T15 +src/utils/camera/isFollowDriverId.ts T18 +tests/helpers/deepFreeze.ts T16 +tests/fixtures/camera/driverGoldenTrace.json T1 +tests/services/engine/frame/driverGoldenTrace.test.ts T1 +tests/services/engine/frame/cameraRuntimeSingleWriter.test.ts T16 +tests/services/engine/frame/engagedArmClock.test.ts T17 +tests/services/engine/frame/followApproachStrand.test.ts T18 +tests/** mirroring each new src file +``` + +**Deleted** + +``` +src/@types/engine/camera/CameraClock.d.ts T4 +src/services/engine/camera/cameraClock.ts T4 +src/@types/camera/SurfaceController.d.ts T11 +src/services/camera/surfaceController.ts T11 +src/services/engine/frame/drainInput.ts T13 +src/services/engine/camera/activeDriverId.ts review +src/services/engine/camera/projectionOf.ts review +src/services/engine/helpers/authoredWorldPose.ts review +src/services/engine/camera/poseOf.ts review +``` + +`runCameraDrivers` also went — a function in `cameraDrivers.ts`, not a file: the step +calls `pickWinner` then `winner.pose` directly. + +**Untouched** — every renderer, slab, layer, shader and `.wesl` file; the tile +pipeline; the `.bin` catalog path; `src/state/camera/*` (the slice and its sagas keep +their current shapes; only _when_ their actions are dispatched moves). + +## Definition of Done + +**Deliverable inventory** + +- [ ] `CameraRuntime` is a value with five groups and no `{ current }` box. +- [ ] `stepCameraRuntime` is the only producer of a `CameraRuntime` after the seed; + `runFrame` holds exactly one assignment to `state.cameraRuntime`. +- [ ] `seedCameraRuntime` is the only constructor, called from `engine.ts` and + `wireInput.ts`. +- [ ] `CameraClock`, `createSurfaceController` and `drainInput` no longer exist. +- [ ] `ClipPlayer.tick` takes and returns a clip epoch; nothing outside the runtime + holds a reference into it. +- [ ] Every camera epoch is milliseconds; no symbol or comment in the camera path + claims otherwise. +- [ ] `followApproach` (55) and `followHold` (10) replace `followBody`. + +**Acceptance** + +- [ ] Both golden traces pass **unmodified** through Task 16, and the Task 18 + re-record's diff is confined to the follow legs and is recorded in its commit. +- [ ] The Task 16 gate test fails when a second `cameraRuntime` writer is introduced + (demonstrated, not asserted). +- [ ] The Task 18 strand fixture fails on the pre-split table and passes after — both + runs in the ledger. +- [ ] Perf before/after recorded verbatim; neutral or better, or an explicit + land/park ruling from the user. + +**Named observable behaviours** — this plan changes no rendered behaviour, so it +carries no manual smoke list of its own. It rides the parent plan's Task 22 gate; +the two items that gate specifically belong to this work: + +- Focus a body with the spin pill on, from an engaged surface pose over another + body: the camera arrives at the framing distance and is recoverable (R14-3). +- Play a looping tour clip through at least two wraps: no cue re-fire glitch and no + time discontinuity at the wrap (the Task 5 handshake). + +**Deferral boundary — do not chase these** + +- Driver ids as a literal union instead of `string`. It would make `register.winner` + and the epoch eligibility table exhaustively checked, and it is a genuine + improvement — but it touches every driver consumer and is not needed by anything + in this plan. +- The greenfield's `simEpoch`-beside-`displayed` pick hazard: `outputs.simDays` + already stores it, and the hazard is out of this plan's scope. +- Splitting `runFrame` further. The step extraction takes ~150 lines out of a + 500-line file; the rest (subsystems, GPU dispatch, keep-tick vote) is not camera. +- Any renderer change at all. diff --git a/docs/superpowers/plans/completed/2026-09-09-camera-runtime-single-writer.perf-T19.md b/docs/superpowers/plans/completed/2026-09-09-camera-runtime-single-writer.perf-T19.md new file mode 100644 index 0000000000..95ee368f61 --- /dev/null +++ b/docs/superpowers/plans/completed/2026-09-09-camera-runtime-single-writer.perf-T19.md @@ -0,0 +1,180 @@ +# T19 — paired A/B perf measurement of the cameraRuntime single-writer refactor + +**Verdict: NEUTRAL.** No scenario moves outside run-to-run noise on either the GPU +harness or the wall-clock rAF probe. The bar for this task was neutral; it is met. + +## What was measured + +| side | URL | commit | tree | +| --- | --- | --- | --- | +| **A** (before) | `http://localhost:5175` | `24182c2c3` (`docs(camera-pivot): cameraRuntime single-writer — spec ground section + plan`) | this worktree, detached at the plan's base commit, `node_modules` + `public/data` symlinked to the main checkout | +| **B** (after) | `http://localhost:5174` | `e0985c5d4` | the `camera-pivot` worktree, server already running | + +`git diff 24182c2c3 e0985c5d4` touches 130 files (+6917/−3423) and **does not touch +`tools/perf/` or `src/state/perf/`** — the harness, the perf hook and the scenario poses are +byte-identical on both sides, so the comparison is apples-to-apples and the poses have not +drifted. + +Harness flags: the default scenario set (10 scenarios), `--frames 30`, `--dpr 2`, tier +`medium` — identical to the Task 1 baseline. Runs 1–6 used `--json`; runs 7–8 used the +human-readable formatter (the "raw text of one A and one B"). + +Because a single run drifts ±3–6 ms on an unchanged tree (the Task 1 finding), this is a +**paired A/B**, alternated across two live servers, never a diff against the T1 file. + +## Run schedule + +``` +warm-up A (solar-system, 10 frames) — sanity + thermal warm-up, not scored +warm-up B (solar-system, 10 frames) — sanity + thermal warm-up, not scored +run 1 A --json +run 2 B --json +run 3 A --json +run 4 B --json +run 5 A --json +run 6 B --json +run 7 A (text) +run 8 B (text) +rAF probe 3 pairs, A-then-B per pair +rAF probe 3 pairs, order REVERSED (B-then-A) — counter-balances the thermal drift +``` + +Four A/B pairs of the full scenario set, plus six counter-balanced rAF pairs. + +## GPU harness — MERGED totals (`TOTAL (merged, production)`, median ms/frame) + +Δ is B − A, so **negative = HEAD faster**. "runs" are the four values in schedule order. + +| scenario | A median | B median | Δ (B−A) | A runs | B runs | A spread | B spread | +| --- | --- | --- | --- | --- | --- | --- | --- | +| earth-surface | 23.4 | 22.8 | **−0.6** | 23.1, 23.8, 19.5, 26.9 | 23.9, 21.7, 24.2, 19.9 | 7.4 | 4.3 | +| solar-system | 20.9 | 20.4 | **−0.5** | 24.0, 19.3, 20.5, 21.3 | 21.6, 20.4, 20.4, 20.2 | 4.7 | 1.4 | +| star-field | 22.4 | 21.7 | **−0.8** | 22.0, 22.9, 21.2, 23.4 | 18.6, 18.3, 26.0, 24.7 | 2.2 | 7.6 | +| milky-way | 21.9 | 22.1 | **+0.3** | 21.8, 21.5, 22.4, 21.9 | 21.2, 22.7, 21.5, 25.8 | 0.9 | 4.6 | +| milky-way-outside | 25.4 | 25.5 | **+0.0** | 25.3, 25.6, 25.1, 25.6 | 25.3, 25.0, 25.7, 25.8 | 0.5 | 0.8 | +| milky-way-close | 29.4 | 29.3 | **−0.0** | 29.3, 29.1, 29.5, 30.5 | 29.4, 28.2, 29.3, 29.7 | 1.4 | 1.5 | +| galactic-centre | 10.2 | 10.1 | **−0.1** | 9.8, 10.0, 11.0, 10.3 | 10.2, 9.9, 9.6, 10.2 | 1.3 | 0.6 | +| sgr-a-star-lens | 11.5 | 11.4 | **−0.0** | 36.7, 11.4, 11.4, 11.5 | 11.5, 11.4, 11.4, 11.9 | 25.3 | 0.5 | +| local-group | 22.4 | 24.2 | **+1.7** | 21.0, 24.4, 23.8, 19.9 | 23.5, 24.2, 24.9, 24.1 | 4.5 | 1.4 | +| full-survey | 23.4 | 23.3 | **−0.1** | 23.2, 23.4, 23.9, 23.3 | 19.0, 24.0, 22.6, 24.4 | 0.7 | 5.4 | + +Sum of scenario medians: **A 210.8 ms, B 210.8 ms, Δ −0.0 ms** (mean Δ per scenario −0.0 ms). + +Spread (max − min of the four runs on one side) ranges 0.5–25.3 ms, **median 1.5 ms** — i.e. +the noise floor here is around 1.5 ms per scenario, three times the skill's nominal ~0.5 ms at +30 frames, consistent with the T1 finding. Every Δ in the table is inside its own side's +spread: + +- `local-group` +1.7 ms is the largest Δ, and A's own four runs span 19.9–24.4 ms (4.5 ms). + The Δ is a third of the within-side spread. Not a result. +- `sgr-a-star-lens` A run 1 read 36.7 ms against 11.4/11.4/11.5 — a single cold-start outlier + (first scored run of the session, lens shader compile). It is why medians, not means, are + quoted; it does not move the median. +- `star-field` −0.8 ms sits against a 7.6 ms B-side spread. Also not a result. + +### Apple Silicon slot-sum inflation — present, and present identically on both sides + +The tell (adjacent MERGED slots with identical medians) fires on **every** scenario in both A +and B, always in the same cluster: + +``` +run 1 (A) earth-surface: bloom = hdr→swap @ 2.00 | hdr→swap = swap·COSMO @ 2.03 | swap·COSMO = swap·NEAR0 @ 2.06 +run 2 (B) earth-surface: bloom = hdr→swap @ 2.13 | hdr→swap = swap·COSMO @ 2.16 | swap·COSMO = swap·NEAR0 @ 2.16 +run 1 (A) milky-way-close: hdr→swap = swap·COSMO @ 3.67 | swap·COSMO = swap·NEAR0 @ 3.67 +``` + +So the 21–30 ms MERGED totals above are **not real per-frame GPU time** — the small back-to-back +composite passes each report the shared TBDR retire interval. They are quoted only as an +ordinal, paired A-vs-B signal, which is valid because the inflation is the same shape on both +sides. They are not summed across slots and not converted to fps. The honest total is the rAF +probe below, which reads 9–16 ms for the same poses. + +## Wall-clock rAF probe (the measurement that can actually see a CPU-side change) + +The refactor is CPU-side; the GPU harness is structurally blind to it. So the skill's honest +probe was run: boot `?perf`, `setStrategy('merged')`, `setPose` to a harness pose, 30 warm-up +frames, then 240 rAF deltas. Driven from +[`perf-T19-rafProbe.mjs`](perf-T19-rafProbe.mjs) (a scratch Playwright script in this folder; +no repo file was added or modified). Four poses: `earth-surface`, `solar-system`, +`milky-way-outside`, `full-survey`. + +**The first arm exposed a confound worth recording.** With every pair ordered A-then-B, both +sides drift monotonically upward across pairs as the machine heats (A `milky-way-outside`: +11.45 → 13.80 → 15.80 ms; B: 14.40 → 15.70 → 15.65 ms), which systematically penalises +whichever side is measured second. The naive forward-arm Δ was +1.85 ms on +`milky-way-outside` — pure order bias. So a second arm was run with the order reversed, and +the two arms averaged. + +Δ is HEAD − base, so **negative = HEAD faster**: + +| scenario | fwd arm Δ (p1,p2,p3) | rev arm Δ (p1,p2,p3) | counter-balanced Δ (all 6 pairs) | counter-balanced Δ (thermally saturated p2+p3) | +| --- | --- | --- | --- | --- | +| earth-surface | +1.40, +0.80, −0.50 | −0.80, +0.25, +0.50 | **+0.27** | **+0.26** | +| solar-system | +0.20, −0.30, +0.20 | +0.10, −0.20, +0.10 | **+0.02** | **−0.05** | +| milky-way-outside | +2.95, +1.90, −0.15 | −5.20, −0.75, −0.15 | **−0.23** | **+0.21** | +| full-survey | +1.20, +2.00, −0.60 | −0.90, +0.00, −0.25 | **+0.24** | **+0.29** | + +Pooled absolute medians (n = 6 per side, both arms): + +| scenario | base rAF median | HEAD rAF median | +| --- | --- | --- | +| earth-surface | 10.90 ms | 10.85 ms | +| solar-system | 9.55 ms | 9.65 ms | +| milky-way-outside | 15.78 ms | 15.60 ms | +| full-survey | 14.15 ms | 13.93 ms | + +Every counter-balanced Δ is **≤ 0.3 ms on a 9.5–15.8 ms frame (≤ 2–3%)**, against individual +pair-to-pair swings of ±2 ms and a within-arm thermal ramp of up to 5 ms. Pooled medians put +HEAD within 0.2 ms of base on all four poses, three of four fractionally *faster*. There is no +per-frame cost signal from rebuilding the camera state as a value object. + +Incidental: rAF medians of 9.5–15.8 ms against MERGED slot sums of 20.9–25.5 ms for the same +poses is a fresh, independent confirmation of the ~2× slot-sum inflation the skill warns about. + +## Raw outputs (all in this folder) + +| file | content | +| --- | --- | +| `perf-T19-run-1-A.txt` … `perf-T19-run-6-B.txt` | runs 1–6, `--json` (10 scenarios each) | +| `perf-T19-run-1-A.stderr` … `perf-T19-run-6-B.stderr` | the harness's stderr progress for those runs | +| `perf-T19-run-7-A.txt` | run 7, A, full human-readable report (the raw text A) | +| `perf-T19-run-8-B.txt` | run 8, B, full human-readable report (the raw text B) | +| `perf-T19-rafprobe.txt` | rAF probe, forward arm (A-then-B), 3 pairs | +| `perf-T19-rafprobe-reversed.txt` | rAF probe, reversed arm (B-then-A), 3 pairs | +| `perf-T19-rafProbe.mjs` | the probe script, kept for provenance | + +## Verdict + +| scenario | GPU MERGED Δ | rAF Δ | verdict | +| --- | --- | --- | --- | +| earth-surface | −0.6 | +0.26 | NEUTRAL | +| solar-system | −0.5 | −0.05 | NEUTRAL | +| star-field | −0.8 | — | NEUTRAL | +| milky-way | +0.3 | — | NEUTRAL | +| milky-way-outside | +0.0 | +0.21 | NEUTRAL | +| milky-way-close | −0.0 | — | NEUTRAL | +| galactic-centre | −0.1 | — | NEUTRAL | +| sgr-a-star-lens | −0.0 | — | NEUTRAL | +| local-group | +1.7 | — | NEUTRAL (Δ is a third of A's own 4.5 ms spread) | +| full-survey | −0.1 | +0.29 | NEUTRAL | + +**Overall: NEUTRAL.** Sum of GPU medians identical to 0.1 ms (210.8 vs 210.8); every +counter-balanced wall-clock Δ ≤ 0.3 ms on a 10–16 ms frame. The single-writer camera runtime +costs nothing measurable per frame, in either the GPU pass shape or real wall-clock. + +Golden traces — the other half of the bar — were **not** re-run here; they were verified +byte-identical on HEAD by the task reviews. + +## Concerns + +- The GPU harness cannot see a CPU-side change by construction, so its NEUTRAL is weak + evidence on its own. The rAF probe is the load-bearing half of this verdict. +- Noise on this machine is ~1.5 ms per scenario median at 30 frames (median within-side + spread), not the ~0.5 ms the skill nominally quotes. A future claim finer than ~1.5 ms on + MERGED needs more frames or more pairs, not a single run. +- The thermal ramp inside the rAF probe is large (up to 5 ms across three pairs on + `milky-way-outside`). Any single-arm A/B probe on this box will manufacture a ~1–2 ms + regression for whichever side runs second. Counter-balance the order. +- Servers were `npm run dev` (unminified, Vite-transformed dev modules) on both sides — a + production build could weight CPU-side work differently, though the change is structural, + not a hot-loop micro-optimisation. diff --git a/docs/superpowers/specs/completed/2026-09-01-camera-pivot.md b/docs/superpowers/specs/completed/2026-09-01-camera-pivot.md new file mode 100644 index 0000000000..c82e0940f0 --- /dev/null +++ b/docs/superpowers/specs/completed/2026-09-01-camera-pivot.md @@ -0,0 +1,1114 @@ +# Camera pivot — design (spec 2) + +> **Status.** Drafted 2026-09-01 as one of two adversarial variants (the Fable +> variant is merged into this file and deleted; git history keeps it). T2 and +> R1 RULED 2026-09-01 (see §12); remaining open at spec review: packaging (§2) +> and feel constants. +> **Date.** 2026-09-01. +> **Ruling record.** [`docs/grill-sessions/globe-camera-pivot-2026-08-24.md`](../../grill-sessions/globe-camera-pivot-2026-08-24.md). +> Decisions below cite it (`ruled, Q6`) rather than re-arguing. Where the +> transcript diverges from +> [`DESIGN-INPUT.md`](../../research/2026-08-24-camera-pivot/DESIGN-INPUT.md), +> the transcript wins. +> **Spec 2 of two.** Spec 1 — +> [body render slabs](completed/2026-08-25-body-render-slabs.md), shipped as +> PR #634 — built the `BodyPoseProvider` seam and put every body's rendering in +> its own metre frame behind it. This spec swaps provider B in behind that seam +> and does not change the renderer (ruled, Q1b, S1). + +## 1. What we're building + +Near a body, the authoritative camera state stops being the heliocentric Mpc +orbit camera and becomes a **body-fixed, anchor-relative pose in SI metres** +(ruled, Q1-A, S2, S3). Google-Earth navigation follows from that storage rather +than from corrections applied on top of a world camera: the ground under the +cursor stays under the cursor, the horizon stays level at every latitude, the +sky is reachable, and a fast sim clock cannot slide the ground, because nothing +in the engaged path reads a world position. + +The state vector the product speaks — **standpoint, heading, tilt, range** — is +the derived readout, evaluated in the target's ENU with KML `LookAt` semantics. +What is _stored_ is the pose and its basis (ruled, Q2-A). The probe's +"heading must be camera state" finding +([probe-findings.md](../../research/2026-08-24-camera-pivot/probe-findings.md) +§Net input) is satisfied strictly more generally by a stored basis: heading is +one component of an orientation the camera owns, so nadir is continuous, the +horizon is level, and no consumer re-derives an up vector from frame-global +state. + +Two regimes, one lossless conversion, one site (ruled, Q1-A). Outside the band +the incumbent Mpc orbit camera is unchanged. The crossing never moves the +camera, because both sides derive the render pose from the same numbers. + +### Why now + +**The seam is built and has exactly one shape of consumer.** Spec 1 shipped +`BodyPoseProvider` with provider A behind it and the whole body-rendering path +in metres. Provider B is a second implementation of a type that already exists, +selected at one line in `frameContext.ts:224-228`. + +**The incumbent parameterization fails at joints, not at values.** The +tilt/look probe measured 90° of horizon roll looking east from the frame +equator, an unreachable sky over the frame equator, and a fold at the frame +pole — all three the same missing camera-owned basis, none of them patchable +where they show (probe defects 1–3). Nine fix waves on the same +correction-on-a-parameterization family are the other half of that evidence +(PR #623, closed unmerged). + +**Deep zoom is a state problem now, not a renderer problem.** Spec 1 made the +render frame metre-native; the camera is what still quantizes. With the anchor +in the state vector (ruled, S2) the state-side floor shrinks with zoom instead +of sitting at Earth-radius magnitude, and the remaining frontier is data. + +### Goals + +- One authoritative camera state at a time, in a named frame; the arm tag is + the only regime discriminant in the design (ruled, Q6 "one state, one + consumer"). +- Engage/disengage is exact: eye, sightline, screen-up and FOV are the same + numbers on both sides (ruled, Q1-A; DESIGN-INPUT §3.1). +- Anchored gestures — 1:1 drag, cursor zoom, tilt about a ground point, + free-look — in body-fixed metres, closed-form, with no solve and no + per-latitude or per-pole case (ruled, Q2-A, Q9). +- Tours, clips and serialized poses name their frame (ruled, Q10, Q10b). +- Fewer constants and fewer concepts than the nine fix waves accumulated + (DESIGN-INPUT §8, the meta-risk). + +### Non-goals + +- **Inertia / coast** — none in this landing (ruled, Q8). If it is ever added + the only acceptable shape is Cesium's flick-only synthetic replay, and it + replays in the **body-fixed frame** (written now, zero LOC). +- **MapLibre's pole "dial" band** (ruled, Q9: no). +- **Terrain-height collision, DEM-driven sensitivity, and the streaming-height + low-pass.** skymap's bodies are analytic spheroids; the Earth tile pipeline + streams imagery, not elevation, so there is no reported ground height for a + camera to chase. The requirements are recorded in §6 as written-down rules so + a future DEM cannot arrive frame-blind. +- **XR and 6-DoF devices.** No XR path on main; the SpaceMouse subsystem was + deleted and is not returning. §7's aggregator is where such a stream would + land; nothing is built for it. +- **The renderer.** No slab, layer, shader or tile-planner change. If this spec + produces a renderer diff, something is wrong with it. +- **Lowering the descent floor.** Re-anchoring is built and tested so the floor + becomes a constant rather than an architectural limit; the floor itself moves + when content justifies it (§10). + +## 2. Ground preparation + +Refactor-ground ran over the whole pivot on 2026-08-24/25. Its prep — P1 +`SlabFrame` discriminant, P2 `frameProgram` builder, P3 step-level depth +load-op, P4 `M_TO_MPC` + `radiusM` migration (PR #635) — plus spec 1 itself +(#634) prepared the **renderer** side. Judged against the code as it stands on +`9250245f8`, two camera-side joints are missing and are prep; everything else +in this spec is growth. + +The gesture math needs no new primitives: `quatFromAxisAngle`, +`multiplyQuat`, `rotateVec3ByQuat`, `mat3FromColumns`, `normalize3`, `cross3`, +`smoothstep` and `raySphereRoots` (with its FW-H discriminant reformulation) +are all present and are composed, not replaced. + +**P5 — `roll` on the pose currency.** `OrbitCameraInit.roll` exists and +`computeViewProj` honours it (`computeViewProj.ts:99,110`), but `CameraPose` — +the currency every driver, keyframe and commit speaks — has no roll field, and +nothing in `src/` sets one. §12-R1 shows the disengage conversion needs exactly +that one degree of freedom to stay lossless. Adding it is a pure additive +field, default 0, threaded through `poseOf` / `assembleOrbitCamera` / +`reencodePose` / the clip evaluator's pass-through, with no behaviour change +while every producer leaves it at 0. Doing it inside the feature commits would +braid "the pose grew a field" with "the camera grew an arm" in one diff. + +**P6 — input: recognizer, aggregator, one apply point.** `orbitControls.ts` +mutates the `state.cam` register inside DOM handlers, one apply per event +(`orbitControls.ts:466-478`, `seedCameraFromBase.ts`). A surface controller +added beside that becomes a second input path writing a second register — the +parallel-path smell, and the ordering artefact behind FW-D's mid-drag desync +and the register-vs-render divergence. The prep splits the file along the line +DESIGN-INPUT §5 draws: `orbitControls` becomes a pure **gesture recognizer** +(it keeps its hard-won DOM knowledge — pointer events, `window`-bound +move/up/cancel for the iOS implicit-capture bug, `touch-action: none` — and +mutates nothing; FW-C's trackpad-burst handling is feature work for the +surface controller, not prep); a per-frame **aggregator** collapses every move +since the last frame into one `{startPixel, endPixel}` (C §2.1) and drains it +at one apply point in the frame loop — `drainInput`, at the top of `runFrame`, +above the driver table's `getState()` — that replaces orbitControls' own +per-event register mutation. As landed (PR #648), the incumbent orbit math +still mutates the OrbitCamera register at that single drain rather than +returning a pose — the returns-pose shape is deferred to this spec's surface +controller / provider B. +Behaviour-preserving, and it is the joint both arms plug into. + +**Packaging is an open ask at the checkpoint, as always: do P5 and P6 land as +their own PR off `main` before the feature commits, or ride this spec's PR?** +No default. (Spec 1's prep landed as a separate PR; that is precedent, not a +rule.) + +## 3. Data delta + +```ts +// src/@types/camera/PoseFrame.d.ts +/** The frame a stored or authored camera pose is expressed in (ruled, Q10). */ +export type PoseFrame = 'absolute' | { readonly body: BodyId }; +``` + +```ts +// src/@types/camera/BodyFixedPose.d.ts +/** + * The camera in one body's FIXED axes, SI metres, f64 — anchor-relative so the + * stored magnitudes shrink with zoom instead of sitting at body-radius scale. + */ +export type BodyFixedPose = { + readonly bodyId: BodyId; + /** Body-fixed anchor point, metres. `[0,0,0]` = body centre (ruled, S2). */ + readonly anchorLocalM: Vec3; + /** Eye − anchor, body-fixed axes, metres. */ + readonly eyeRelAnchorM: Vec3; + /** right | up | forward as columns, body-fixed axes, orthonormal. */ + readonly basisLocal: Mat3; +}; +``` + +```ts +// src/@types/camera/FramedCameraPose.d.ts +/** + * The authoritative camera pose and the frame it lives in. The `absolute` arm + * is today's orbit currency unchanged; the `body` arm is provider B's state. + * This is the tag-beside-channels form T4 ruled for — NOT the declined + * FramedPose rewrite of the animation system, which keeps its four channels. + */ +export type FramedCameraPose = + | { readonly frame: 'absolute'; readonly pose: CameraPose } + | { readonly frame: { readonly body: BodyId }; readonly pose: BodyFixedPose }; +``` + +```ts +// src/@types/camera/CameraPose.d.ts (delta, P5) +export type CameraPose = { + target: Vec3; + yaw: number; + pitch: number; + distance: number; + /** Roll about the view axis, radians. Optional, absent ⇒ 0 (see §12-R1). */ + roll?: number; +}; + +// src/@types/camera/CameraState.d.ts (delta) +export type CameraState = { + base: FramedCameraPose; // was CameraPose + // …unchanged +}; +``` + +```ts +// src/@types/camera/SurfaceReadout.d.ts +/** + * KML LookAt semantics at the ENU of the point under the screen centre. + * `tiltRad` is measured from local NADIR (0 = straight down, π = zenith) — + * never Cesium's complementary pitch; the datum is in the field name because + * carrying both conventions is how they get mixed (DESIGN-INPUT §2d). + */ +export type SurfaceReadout = { + readonly standpoint: LonLatDeg; + readonly headingRad: number; + readonly tiltRad: number; + readonly rangeM: number; + readonly altitudeM: number; +}; +``` + +```ts +// src/@types/camera/SurfaceGesture.d.ts +/** Per-gesture, latched at gesture start, dead at pointerup (ruled, Q3). */ +export type SurfaceGesture = { + readonly mode: 'pan' | 'orbit' | 'strafe' | 'look' | 'tilt'; + /** |first pick| — the FROZEN pan sphere, body-fixed metres (C §2.3, §6.2). */ + readonly anchorRadiusM: number; + /** Body-fixed, never world (C landmine #5). */ + readonly anchorLocalM: Vec3 | null; + /** Previous FRAME's end pixel, not the press point (C §2.1). */ + readonly prevPixel: Vec2; +}; +``` + +```ts +// src/@types/camera/CameraTuning.d.ts — the band edges and feel toggles as ONE +// value threaded into the camera math. Defaults live in +// `src/data/camera/cameraTuning.ts`, the cross-edge invariants in +// `clampCameraTuning`, and the live copy on the camera slice as +// `camera.tuning`, written only by `setCameraTuning`. +export type CameraTuning = { + /** h/R at which the body arm takes over. */ + readonly engageHR: number; + /** h/R at which it hands back (hysteresis). */ + readonly disengageHR: number; + /** h/R at or below which the reference up is the pure body ENU (full tilt). */ + readonly tiltFullHR: number; + /** h/R at or above which it is the scene up (zero tilt). */ + readonly tiltZeroHR: number; + /** 'log' because zoom is multiplicative: half weight at the geometric midpoint. */ + readonly blendSpace: 'log' | 'lin'; + /** Gates the heading+roll framing authority ONLY, never the tilt wall. */ + readonly northUp: boolean; +}; +``` + +**Ruling 19 (2026-09-09) superseded Q6's band edges** (~1.7 R / ~3.4 R → +0.2 R / 0.4 R, 2× hysteresis unchanged): the user found the Q6 band engaged +the body arm too far out. **The user's re-tune of 2026-09-10 supersedes ruling +19 in turn, to 0.45 R / 0.9 R**, again at 2× hysteresis. Every place below that +cites "3.4 R", "1.71 R", or the Q6 figures is describing the band shape at the +values in force at the time of writing; the live numbers are always +`tuning.engageHR` / `.disengageHR`, never restated. Ruling 19's engage +edge landed where the notch grid already put `bodyUpWeight` at 1 to rounding, +measuring a Δtilt of 0.00042 rad across the abs→body flip at the default +cadence (0.0134 on the Q6 edges); the 2026-09-10 edges have not been +re-measured — `engageFlipPop` is the standing guard either way. + +**The orientation blend has its own band since 2026-09-10** +(`tiltFullHR` / `tiltZeroHR` on the same record, tunable from the debug panel, +capped at `disengageHR` by `clampCameraTuning`): weight 1 at or below +`tiltFullHR`, 0 at or above `tiltZeroHR`. It started at the regime's own edges +and the same ruling re-tuned it to **0.06 R / 0.60 R** — so the flip no longer +happens where the weight is 1, and the blend spends most of its band inside the +body arm. Fixtures that need full weight key on `tiltFullHR`; the arm flip stays +on `engageHR` / `disengageHR`. + +No change to `BodyRelativePose`, `BodyPoseProvider`, `Slab`, `SlabFrame`, or +any layer type. The seam type does not move. + +## 4. The regime: a predicate, not a stored flag + +The discriminant is `h/R` — eye-based altitude over body radius, body-independent +(ruled, Q6; FW-A: altitude is `|eye| − R`, never pivot-derived). The engaged +body is the one minimizing `h/R` across the bodies the frame resolved; ties +cannot occur at these separations. The predicate reads **geometry only** — +never focus, never the drag mode, never a render path (X §4 item 4). + +**There is no regime boolean.** `camera.base.frame` _is_ the regime, so +hysteresis is free and an inconsistent pair is unrepresentable: in the +`absolute` arm the test is `min(h/R) < engageHR`, in a `body` arm it is +`h/R > disengageHR` for that body. This is the strongest available reading of +"one state, one consumer" — the boolean's single consumer was always "which +frame is authoritative", and the frame tag already answers it. The acceptance +test is therefore a grep: no module stores a regime flag (§11). + +**No flip during an active gesture** (ruled, Q6): the predicate is skipped while +a gesture is in flight and re-evaluated at gesture end. This subsumes FW-C's +mid-drag wheel guard and FW-D's gesture-scoped latch, which were both groping +toward the rule. + +**What flips, and what does not** (ruled, Q7-H1, and DESIGN-INPUT §3.1): + +| Continuous by construction | May snap, and should | +| ------------------------------------ | ---------------------------------------- | +| eye, sightline, screen-up, FOV | gesture mode, sensitivity constants | +| the rendered image at the flip frame | the derived heading/tilt readout | +| | which frame is held fixed (H1 hard flip) | + +Only the last is observable: outside, the ground drifts under an inertially +placed camera; inside, the ground is nailed and the sky sweeps. The band has +two edges and they differ by 2×: at the RELEASE edge (0.9 R) the real-time +ground-drift rate `ω⊕·R/h` is 8.1e-5 rad/s (0.0046°/s), 3.8× the rate at Q6's +old 3.4 R edge; at the ENGAGE edge (0.45 R) it is 1.6e-4 rad/s (0.0093°/s), +7.6× Q6's figure and about half the classical minimum-perceptible-velocity +threshold. The 2026-09-10 re-tune therefore pulled the inbound edge back under +that threshold (ruling 19's 0.2 R sat on it), but the flip is still the one +that has to be looked at rather than argued away — it needs the T22 feel gate +to attest it. **H1 ships; the measurement is an acceptance item under an +accelerated clock (§11); H2 — smoothstepping the co-rotation rate over ~1 s — +is the bounded escalation path and is spent only on adverse evidence** +(ruled, Q7). + +**The trade, stated for the record:** engaging this high means Earth stops +visibly rotating once engaged — geostationary hover, sun and stars sweep under +a fast clock; "planetarium Earth" lives above the band (ruled, Q6). + +## 5. Provider B: the body arm + +### 5.1 The two conversions + +```ts +// src/services/engine/camera/poseFrameConversion.ts +export function toBodyArm( + pose: CameraPose, + projection: CameraProjection, + bodyId: BodyId, + bodyState: BodyState, +): BodyFixedPose; + +export function toWorldArm(pose: BodyFixedPose, bodyState: BodyState): CameraPose; // carries `roll` — see §12-R1 +``` + +Entering captures nothing: `eyeRel = orientationᵀ · (camPosMpc − bodyPosMpc) · +MPC_TO_M`, basis through the same rotation. No epoch, no snapshot; FW-G's +`orientationAtEngage` and `R̃(t) = R(t)·R(t₀)⁻¹` have no successor because +co-rotation stops being a mechanism and becomes a property of the storage +(DESIGN-INPUT §3.3 — the pivot's largest conceptual deletion, and the reason +the engaged path cannot be moved by a fast clock). + +Leaving bakes the rotation back out: eye and basis to world, then the orbit +parameterization re-derived from them — `target` on the forward axis at the +range to the point under the screen centre, `yaw`/`pitch` from the eye +direction, `distance = |eye − target|`, `roll` from the residual screen-up +rotation. Exact for **any** pose, which is what makes §12-R1's field necessary +and the tilt memory's blend band a _feel_ mechanism rather than a correctness +crutch (ruled, Q4-iii, refined). + +`toWorldArm` is the second module permitted to import `MPC_TO_M` / `M_TO_MPC` +(§10); `bodyRelativePose` remains the first. + +### 5.2 The provider + +```ts +// frameContext.ts, at the existing seam (frameContext.ts:224-228) +const bodyPose: BodyPoseProvider = (bodyId) => + arm.frame !== 'absolute' && arm.frame.body === bodyId + ? poseFromBodyArm(arm.pose) // provider B — ~nm floor + : bodyRelativePose({ camPosMpc, camBasisWorld, bodyState }); // provider A +``` + +**B keeps A** (ruled, S1): every body that is not the engaged one still gets its +pose derived from the heliocentric camera, and on approach from deep space the +camera is heliocentric regardless. Both produce the same value at the flip, to +within provider A's floor — ≈2 ulp at heliocentric magnitude, ≈50 µm at 1 AU — +a unit test asserts it. + +`camBasisWorld` at that site is built with roll hard-coded 0 +(`frameContext.ts:222`). That is correct today because nothing sets roll; under +P5 it must read `cam.roll ?? 0`, or a rolled world-arm pose renders bodies +un-rolled against a rolled sky. Named here because it is a silent divergence, +not a crash. + +### 5.3 Re-anchoring + +The floor is set by the magnitude of the stored anchor, not by how close the +camera is to the ground: `ulp(6.37e6 m) ≈ 1 nm`, which stays invisible down to +roughly µm view scale and no further. Re-anchoring moves the anchor toward the +eye and subtracts the same delta, so the pair keeps naming the same point while +both stored magnitudes shrink. + +`reanchoredPose` was DELETED by the 2026-09-02 simplification wave: it +shipped with zero importers and a trigger the shipped descent floor can never +fire (admitted above). The floor analysis stands — re-derive the operation +from this section's contract (ulp-quantized shift, both updates exact) when a +deep-zoom feature actually needs sub-µm anchors. + +## 6. Gestures + +All of it runs in body-fixed metres, f64, and reads no world position (§4's +fast-clock property). Per-gesture anchors are body-fixed and die at pointerup +(ruled, Q3); there is no persistent target, which is what makes FW-H's proven +root cause — an accumulating stored pivot — unreachable rather than handled. + +**The control model is chosen by what the cursor is over** (C §5.1). Cursor +hits the body → anchored pan at any altitude; cursor misses → free-look (R1, +2026-09-02, deleted the trackball's free rotation and with it the altitude +tiebreak a miss used to consult). The mode is latched at gesture start and is +sticky for the gesture (C §6.1). + +New primitive, since no cursor-ray path exists on main: + +```ts +// src/utils/camera/cursorRayBodyLocal.ts +/** Ray through a CSS pixel, in body-fixed metres. Built from the basis and + * FOV directly — no matrix inverse, so it cannot drift from the slab's vp. */ +export function cursorRayBodyLocal( + pose: BodyFixedPose, + pixel: Vec2, + viewportPx: Vec2, + fovYRad: number, +): { readonly originM: Vec3; readonly dir: Vec3 }; +``` + +**(a) 1:1 drag — frozen pick sphere, two-ray rotation.** Freeze +`anchorRadiusM = |first pick|` at gesture start; each frame intersect the +previous and current cursor rays with _that sphere_ and rotate the pose — +position **and** basis — by the inverse of the quaternion carrying `p̂₀` to `p̂₁` +— the pose rotates _with_ its rays, so the camera turns the other way +(C §2.2-2.4). +Eight lines, pole-free, exact, identical at every latitude. No `cos(latitude)` +term exists to be wrong; dragging over the pole is an ordinary rotation with a +near-equatorial axis. A ray that misses the frozen sphere degrades the gesture +to the north-locked orbit — the same transported pan continued about the body +centre — stickily (C §2.6; R1 deleted the free trackball). At grazing incidence (`|ray·normal| < 0.05`) a +rotation is a teleport, so the gesture strafes in the plane through the anchor +instead (C §2.8) — a hard test, never MapLibre's blend, which would be a second +path hiding drift. + +**(b) Zoom to cursor, BOTH directions** (ruling 7, 2026-09-02 — supersedes +the original direction asymmetry and FW-H's zoom-out carve-out). `eye′ = +anchor + factor·(eye − anchor)`: stateless per tick, no accumulator (FW-B). +Two points stay separate — the distance _measure_ comes from the screen +centre, the _anchor_ from the cursor (C §3.1). A cursor hit anchors the step +in **both** wheel directions — at rest on the wheel's own cursor pixel, during +a gesture on the latched one (ruled 2026-09-01, §12-R4) — so the point under +the cursor stays pinned in and out alike (GM). **A miss falls back to the +surface point under the eye**, which sits on the eye's own radial, so a +missed recession is centre-directed while the step scales _altitude_ rather +than geocentric range. Guards, all +cheap and all evidence-backed: clamp step **magnitude** on both signs (C §6.15); +force a fresh anchor pick after an overshoot past the anchor's tangent plane +(C §6.7); gate the approach on _closing distance_, never on absolute altitude +(C #11107 — an altitude gate cannot predict a collision). + +**(c) Tilt about a ground point, and (d) look about the eye.** Two routes into +**one** tilt memory (ruled, Q5). Tilt orbits the pose about the latched ground +anchor: heading about the anchor's local up, then tilt about the +_already-yawed_ east — the intrinsic Z-X-Z order KML specifies, which the probe +measured as load-bearing (tilting about a fixed screen axis dragged ~10° of +unwanted heading per 60 px). Its own limit is the collision floor, not a +constant: orbiting past horizontal puts the eye under the surface, and the floor +already forbids that. Look rotates the basis about the eye, which never moves — +this is the only route to the sky, and the probe proved it out (eye and +altitude held to the bit while heading stayed live at full tilt). + +**The ceiling.** There is none. A tilt drag may raise the view to the horizon +and past it at any altitude; the remembered tilt's one cap is the constant +`MAX_REMEMBERED_TILT_RAD = π` (Cesium's altitude-free `maximumPitch`), applied +where `surfaceStep` writes the memory. One altitude ramp over tilt, not two +(ruling 10). On the **zoom** path `tiltZeroHR ≤ disengageHR` carries +the invariant Q4 names — `tilt = 0` at the disengage boundary, so the outbound +pose's forward axis points at the body centre and survives the world arm's pivot +pin — because display tilt is `remembered × bodyUpWeight(h/R)` (ruling 12) and +that weight is already 0 at the flip. The **drag** path authors display tilt +directly, at any altitude, and +above `zeroHR` the memory cannot record it — `unmappedTiltRad`'s `w > 1e-6` +guard skips the write where the un-map diverges — so that tilt is unbacked, and +the next zoom notch settles it by the bounded decay rather than at once. A +drag-authored tilt still held at the flip therefore crosses onto the absolute +arm as roll/tilt (the B7 symptom's second route). + +A receding zoom write still walks heading north-up, priced per notch by +`ORIENT_DECAY`, and the collision floor still bounds a lowering drag +(`tiltFloorBudgetRad`) — neither reads an altitude ramp. + +**Written down now, zero LOC** (DESIGN-INPUT §8, C landmines #6, X §4 item 3): +a coast, if one is ever added, replays in the body-fixed frame — ground-fixed, +not inertial. Sensitivity reads the reference radius only; a future DEM height +feeds the collision floor and nothing else. A collision push rotates the basis +by the same angle/axis it moved the eye, or the view jerks on every hill +(C §6.6). The floor is unconditional and resamples after the last position +write (O §4). + +## 7. The camera pipeline + +One pose, one writer, one apply per frame. Order inside `runFrame`, with the +new steps marked: + +1. Aggregate this frame's input into one gesture delta (**P6**). +2. Run the driver table; the winner produces a pose **in the arm it authors**. + The surface controller occupies the priority-100 slot the SpaceMouse driver + vacated, and is active only while a gesture is in flight in a body arm. +3. Commit-on-edge, unchanged. +4. Pivot pin — **world arm only**. In a body arm the frame co-rotates with the + body, so "keep the moving body centred" is structurally satisfied and the + pin has nothing to do. This is the same deletion as R̃'s, at the driver + layer. +5. **Evaluate the regime predicate** (§4) on the produced pose, unless a + gesture is in flight. +6. **Normalize to the resulting arm** — one call to §5.1's conversion pair, + idempotent when the arms already agree. +7. Assemble the render camera; derive the frame context; render. + +Steps 5–6 are the **fold, and they are last** (DESIGN-INPUT §3.3): below driver +arbitration, after every pose writer for the frame, exactly one site. FW-G's +round-1 finding was precisely that a commit-on-edge above the fold discards it +and the wrong writer wins. + +Two consequences worth stating. `applyWheelZoom`'s three distance owners +(follow target / spun base / plain base) are a world-arm concern: in a body arm +the wheel routes to §6(b), which owns the range, and the three-way branch is +not consulted. And the follow driver's `isActive` is false in a body arm — its +approach ease and idle hold have no meaning once the state co-rotates. + +## 8. Frames for keyframes, tours, and serialization + +**Convert now** (ruled, Q10-B): keyframes today interpolate in absolute Mpc, +which is wrong the moment the sim clock moves — the highest-expected-value +prediction in the research corpus (OpenSpace's equivalent, open since 2023). + +The animation system keeps its four channels and its `Space` mapping; each +base-layer endpoint (`set` / `setVec`) grows an optional `frame: PoseFrame`, +absent ⇒ `'absolute'` (ruled, T4 — the `FramedPose` rewrite is declined). +Relative writers (`spin`, `rate`, `osc`) act in whatever arm is current and are +untouched. **Interpolation runs in the endpoint's own frame**; a leg whose +endpoints disagree converts its start into the endpoint's frame once, at leg +start, through §5.1. Body-framed channel values are read in that body's +fixed axes, in metres: `target` is a body-fixed point, `distance` is a range, +`yaw`/`pitch` are angles about the body's own axes — a LookAt, decoded to a +`BodyFixedPose` at the driver's exit. Authored keyframes are decoded, never +accumulated, so the pole degeneracy that rules angles out as _state_ (§1) does +not reach them. + +Deep-space keyframes stay absolute Mpc and no existing clip changes: the grand +tour's near-body beats already reference ids resolved at play time +(`moveTargetId` / `dollyToId`), which are frame-free by construction. + +**Serialization** (ruled, Q10b): the serialized form names its frame; untagged +legacy input parses as `'absolute'`. The URL hash carries **no** camera pose +today — `HASH_PARAM_SOURCES` is `focus`, `t`, `orientation` — so there is +nothing to migrate and no legacy constraint. The deliverable is therefore the +rule plus its one live consumer: `logCameraState`'s debug print names the frame +and prints metres in a body arm. A future `cam` param inherits the tag +requirement from `FramedCameraPose` rather than from prose. + +## 9. Consumers that migrate + +`selectCameraBase` returns a `FramedCameraPose`; every reader either becomes +frame-aware or reads through a resolved accessor. The inventory, from the +current tree: + +| Site | Under the pivot | +| --------------------------------------------- | ----------------------------------------------------- | +| `lonLatFocusPose` + `watchFlyToLonLatSaga` | authors a **body-arm** pose directly (below) | +| `cameraDrivers` resting / autoRotate / follow | world-arm authors; follow inactive in a body arm (§7) | +| `cameraDrivers` tween / clip | frame-tagged endpoints (§8) | +| `applyWheelZoom` | world arm only (§7) | +| `applyFocusedBodyPivot` | world arm only (§7) | +| scale bar (`runFrame` snap), `focusFraming` | read the resolved range; the body arm reports metres | +| `seedCameraFromBase` | seeds the arm, not a bare `CameraPose` | +| `logCameraState` | prints the frame (§8) | + +**`lonLatFocusPose` is the deferred item from spec 1** (ledger: "STOPPED per +standing ruling — reaches `CameraPose`/`OrbitCamera` = spec-2 territory"). It +exists to put a body's geodetic point under the camera, and today it does that +by building a local direction, rotating it out to world, and recovering +`(yaw, pitch)` through `orbitAnglesLookingAlong` against the orientation frame — +a body-relative intent expressed as an Mpc round trip. Under the pivot it +becomes a body-arm constructor with no Mpc in it: standpoint from the lon/lat, +range preserved, tilt 0, heading preserved. If the resulting pose is outside the +band, §7's step 6 converts it — the instrument does not need to know. That is +the general rule: **a producer that is body-relative by nature authors a body +arm; the fold is the single site that reconciles.** + +## 10. Units, precision, and the one-seam test + +SI metres everywhere in the engaged path — state, gestures, readouts — f64 on +the CPU (ruled, S3). Body radii come from the registry's `radiusM` (P4). The +`h/R` currency means no threshold in this spec is an absolute distance (C's +do-not-copy on Cesium's three altitude constants). + +| Regime | Magnitude | f64 ulp | +| ----------------------- | ------------------- | ----------------- | +| body arm, centre anchor | `R⊕` = 6.371e6 m | ≈1 nm | +| body arm, re-anchored | shrinks with zoom | shrinks with zoom | +| world arm | 1 AU = 4.85e-12 Mpc | ≈ tens of µm | + +Spec 1's one-seam import test (`oneMpcSeam.test.ts`, 54 assertions) is +**amended, not relaxed**: `poseFrameConversion` joins `bodyRelativePose` as a +permitted importer of `MPC_TO_M` / `M_TO_MPC`, and no other module in the +engaged camera path may import either. The existing three-file cull/fade +allow-list is unchanged. + +The descent floor stays where it is for this landing: `SURFACE_STANDOFF_RADII` +re-expressed in metres from the same single declaration, so the two arms cannot +disagree about where the ground is. It is now a constant with content behind it +(the ~10 m f32 near-cancellation in the ocean-glint view vector, which spec 1's +metre migration weakened but did not measure), not an architectural limit — +lowering it is a separate, measurable change. + +## 11. Acceptance criteria + +**Behavioural — the nine waves, carried forward as tests** (DESIGN-INPUT §6). +Each is a requirement on the engaged arm, and each is one test: + +- **FW-A** every altitude read is `|eye| − R`, never pivot- or target-derived. +- **FW-B** zoom is stateless per tick; no bias state anywhere. +- **FW-C** a trackpad inertial burst neither registers as a new gesture nor + slides the view at rest. +- **FW-D** a gesture's rate currency does not alternate frame-to-frame across + the limb; per-event step magnitude is bounded on both signs. +- **FW-E** ground drift at the flip, real-time rate, stated per edge: at the + release edge 0.9 R `ω⊕·R/h` = 8.1e-5 rad/s (0.0046°/s), 3.8× the rate at Q6's + 3.4 R edge and below perception; at the engage edge 0.45 R it is 1.6e-4 rad/s + (0.0093°/s), 7.6× Q6's figure and about half the classical + minimum-perceptible-velocity threshold. "Trivially true" therefore no longer + holds on the inbound flip: that one is attested by the T22 feel gate, not by + this arithmetic. The perceptual derivation no longer sets the band (ruled Q6). +- **FW-F** while engaged the tracked ground point does not slide under an + accelerated clock: `ω × r` residual is exactly zero, not small. +- **FW-G** the rendered sightline and the interaction register are the same + pose; the fold runs below driver arbitration at one site. +- **FW-H** (amended by ruling 7, 2026-09-02) the wheel anchors on the cursor + pick in BOTH directions; with the cursor unmoved the picks coincide, so 260 + notches out and back return to the starting view. FW-H's surviving content + is the MISS fallback (sub-eye point) and the no-stored-pivot rule; recession + ORIENTATION carries no pixel promise. +- **FW-I** drag tracking is sub-pixel exact at every latitude and altitude, with + no best-iterate escape hatch. + +**Structural:** + +- Engage and disengage are pose-exact: eye, forward and screen-up round-trip to + within provider A's floor (≈2 ulp at heliocentric magnitude, ≈50 µm at 1 AU), + over a body with a **tilted pole** and a + non-identity orientation (the FW-F reviewer's fixture shape; the + quaternion-order landmine O §2.1 is what it catches). +- `tiltZeroHR <= disengageHR`, asserted as a `clampCameraTuning` invariant + against the tuning, not literals. +- Grep: no module stores a regime flag; the arm tag is the only discriminant + (§4). Grep: the amended one-seam test (§10). +- A gesture in flight cannot change the arm. +- `npm test`, `npm run typecheck` green. + +**Visual / feel — the user's eyes, dev server, f.lux off:** + +1. Descend from 5 R to the descent floor over Earth: no snap at either + crossing, no ground drift once engaged, the camera settles top-down on the + way back out with no tween. +2. Drag at 1:1 across the equator, over a pole, and at a grazing limb — the + grabbed point stays under the cursor; no twist, no teleport. +3. Tilt to the horizon and look to the zenith from ~2 m altitude; heading stays + live while pinned; the horizon is level at every latitude and azimuth + (probe defect 3's failing cells: due east from the frame equator, and + Denmark's latitude). +4. Zoom-out-then-in round trip with the cursor parked off-centre. +5. Accelerated clock at high rate, sitting engaged: the ground is nailed and + the sun and stars sweep; then cross the boundary and watch the drift onset + (this is the H1 measurement — adverse evidence, and only adverse evidence, + buys H2). +6. The same sequence over the Moon and over Mars: nothing in the path is + Earth-typed. + +**Perf.** `npm run perf` before and after, per the `perf` skill, against **this +worktree's own dev-server URL**. The work is CPU-side and small; neutral is the +expectation and the bar. A neutral-or-negative measurement **halts** the +landing pipeline — land/park is the user's ruling, never process momentum. + +## 12. Open questions + +**T2 — camera state: union, or both states synced? RULED 2026-09-01: the +union.** Deferred to this spec at the refactor-ground checkpoint (transcript +addendum). Both independently written spec variants proposed the union with +the same mirror-state rationale, which is what settled it. + +_Proposal: the union_ — `camera.base` becomes `FramedCameraPose` (§3), exactly +one arm authoritative at a time, with §5.1's lossless conversion at §7's fold. + +_Rationale._ A synced pair is mirror state: two writers, an ordering rule, and +a "which one is truth" question at every read — the family that produced the +848 km standing bias and the register-vs-render divergence during long drags, +and the shape X §4 item 4 names as OpenSpace's repeated sin. It is also lossy in +the direction that matters: the mirrored world pose quantizes at ~14 µm, so a +µm-scale surface state round-tripping through it every frame throws away exactly +what the pivot bought, and the world arm's clamp envelope, pivot pin and +three-way zoom routing would all act on the mirror and fight the body state. +The union's honest cost is the §9 migration — every reader of `camera.base` +becomes frame-aware — and it is bounded and enumerable, which the mirror's +failure mode is not. + +_Alternatives considered._ (a) **Both synced**, above. (b) **One +always-anchor-relative state** (OpenSpace's answer) — ruled out by Q1-A, and +their route to it is a scene graph the research comparison rejected as +out-of-scope. (c) **Union with a narrower body arm** (body-fixed target + angles +rather than pose + basis) — rejected: it reintroduces a pole singularity in the +one frame where the user walks over the pole (probe defect 1). + +**R1 — the disengage residual needs `roll`. RULED IN by the user 2026-09-01; +the transcript's mechanism was incomplete.** Q4-iii rules that the tilt +ceiling makes the outbound pose "near-nadir and roll-free by construction, and +`heading` maps exactly onto `yaw`". The first half holds; the second does not. +At nadir the eye's position fixes `yaw` and `pitch` entirely, so `heading` — the +one remaining orientation degree of freedom — appears in the world arm as +**screen roll**, not as yaw. After an anchored drag across the body the +accumulated difference between the camera's screen-up and the frame pole's is +the parallel-transport holonomy, up to 180°. Without somewhere to put it, the +crossing snaps the whole image — which is Q4-i, explicitly rejected. + +The minimal completion is Q4-ii's field, and most of it is already paid for: +`OrbitCameraInit.roll` exists and `computeViewProj` honours it; nothing sets +it; XR does not exist; tour authoring is unaffected because the field is +optional and defaults to 0. P5 adds it to `CameraPose`. The tilt ceiling then +does what Q4-iii wanted it to do — deliver a nadir, pin-compatible pose at the +boundary and the settle-to-top-down feel — while exactness comes from the +conversion being lossless for _any_ pose, which is the stronger property Q1-A +asks for. + +**R2 — the engaged body is chosen geometrically, never by focus. DECIDED HERE.** +The transcript fixes the band but not which body it applies to. `argmin h/R` +over the frame's resolved bodies keeps the predicate a pure geometric read; +gating on focus would make one state decide two things (pivot _and_ regime), +the exact pattern X §4 item 4 warns about. Consequence: a close unfocused +flyby engages that body's arm. Nothing is visible when it does — the pose is +identical and the ceiling is not applied on entry (R3) — and anchored drag +about the body you are next to is the better behaviour anyway. + +**R3 — the ceiling is enforced on driven writes, not on arm entry. DECIDED +HERE.** Enforcing it as an entry clamp would snap a pose that arrives above the +ceiling (a flyby aimed away from the body, a tour keyframe). Since altitude only +changes through zoom, and zoom re-levels through the ceiling (§6), every path +the user can drive still lands at `tilt = 0` by the disengage boundary — the Q4 +invariant holds where it is load-bearing without a special case at the seam. + +**R4 — the zoom's two anchors. RULED BY THE USER 2026-09-01** at the hands-on +feel check, superseding the interim recorded at Task 16. Two findings, one +mechanism apiece. (i) An at-rest wheel had no cursor pixel to pick through — +`InputStep`'s zoom arm carried only a factor — so it anchored at screen +centre and the ruling ("point at a point on the surface and zoom in on that +point") failed exactly when no pointer was down, the common case. The wheel +event now carries its cursor position through the recognizer and the +aggregator, alongside the drag arms' pixels. (ii) Zoom-out anchored at the +body CENTRE scaled the geocentric range, so near the ground one notch out +climbed ~700 km while the notch in it should undo had moved ~100 m — the +recession raced. The fallback is now the surface point under the eye: still +on the eye's radial, so a missed recession stays centre-directed and `f` and +`1/f` are reciprocal in ALTITUDE at every altitude. (Ruling 7, 2026-09-02, +later made a cursor HIT anchor the zoom-out too — the sub-eye point is the +miss fallback only.) A pinch, which has no single +cursor, keeps the screen-centre pick. + +**R4b — the retreat returns to the base pose, as a ceiling. RULED BY THE USER +2026-09-01.** Observed against Google Maps/Earth: zoom-in dives at the cursor's +point, zoom-out re-frames to the canonical globe view — body centred, north up, +converging top-down — smoothly, never as a snap. Implemented as a **scale-keyed +ceiling, not an animated blend**: `maxTiltRad(h/R)` limits heading as well as +tilt, enforced at the one site §12-R3 already writes. That is the shape Google +Maps documents ("the range of angles that can be used varies with the current +zoom level; values outside this range are clamped") and the shape the tilt +ceiling already had; convergence is then emergent and distance-keyed — it stops +when zooming stops and has no overshoot, because there is no animation state at +all. One authority scalar for both angles, so they can never disagree about how +constrained the pose is. + +Enforcement turns the basis **by each residual**, and is not the absolute +`(heading, tilt)` reconstruction it was through §12-R3: a reconstruction has no +roll term, so every tick that crossed a limit also discarded the pose's roll — +a 1e-5 rad heading violation could spin the image 90° in one frame at ~170 km +altitude, which is the pop this ruling forbids. The delta form is identical for +a roll-free pose, leaves roll alone otherwise, and retires the roll-snap +deviation R3 shipped with. The _centring_ half needs no +separate term: on a missed recession the sub-eye anchor keeps the body centre +along the eye's radial, and on a cursor-anchored one (ruling 7) the same +ceiling wall closes the tilt — either way the aim error the user sees as "the +globe slid off centre" IS the tilt the ceiling removes by the disengage +boundary. Prior art and its limits are recorded in +[`prior-art-cesium-ge.md`](../../../.superpowers/sdd/2026-09-01-camera-pivot/prior-art-cesium-ge.md) +Q3/Q4 — Cesium re-centres inward on the cursor and never returns to a canonical +pose, so the retreat attractor is ours, not adopted. + +The heading **limit** rides the recession only; drags never north-force (a drag +owns its heading), and the tilt half stays direction-blind as before. The +direction case was meant to fall out of the ceiling slackening on descent, and +does not: near nadir a dive generates heading faster than it slackens — +forward's horizontal component is the lean, and it swings to π as the eye +slides off the sub-anchor point — so a direction-blind clamp turned the view +mid-dive (0.37 rad of anchor drift over a 30-notch descent, measured). + +**The approach's own north-up rule.** "When zooming in, north is always up" +does not follow from the ceiling — no curve reaches a limit that is switched +off — and without a rule the dive lands tens of degrees off north (82° +measured, worst case near the pole), because the eye moving is itself what +turns the ENU under a fixed basis. So each approach notch also rotates the pose +— eye **and** basis — about the axis through the cursor anchor and the body +centre, easing screen-up back onto north by a capped share of the residual, +priced in the log-zoom the notch is allowed to SPEND: the residual multiplier +is `e^(−k·u)` and the cap is `capRadPerLogZoom · u`, calibrated so a deltaY-100 +mouse notch spends the 25 % / 0.1 rad it always did. Spent, not requested — +`u = |ln spentZoomFactor(factor)|` on the body arm, where a frame's folded wheel +events are clamped to a ×2 altitude step before the eye moves, so a burst can +never buy more settle than it bought zoom. _(User ruling 2026-09-10 (F1): +the share was spent per input STEP, so a deltaY-1 trackpad event cost as much +heading as a full notch and norths the view in ~0.2 s of scrolling. Every +zoom-driven settle — the dive heading, the recession ride's decay half, the +tilt-deviation decay, the zoom-time level cap and the world arm's +`frameAlignedRoll` — now reads `u`; drag-driven settles keep their per-step +amount. `u = 0` is inert, so a park no longer settles: the camera must zoom to +spend anything.)_ On +a sphere that axis is one line, so the rotation holds the anchor's camera-space +coordinates exactly (prior art Q4c): the picked point keeps its pixel to the +bit, and altitude is untouched. Moving the eye is not the thing §12-R3 forbids — +that rule is about enforcement correcting a pose the user drove; this is a +gesture-authored write, the form the tilt drag already uses. Its residual is +**screen-up's** azimuth (`refAzimuthOf`) rather than forward's heading: the two +agree for a roll-free pose, but a dive accumulates roll, and nulling forward's +azimuth instead drove a measured polar dive through north-up and back out. + +**Small-body engage feel — flagged, not built.** Every planet/moon registry row +can engage (R2's argmin over the roster is body-blind); on a ~10 km moon the +band engages at ~17 km altitude, which is correct but may feel abrupt. If the +feel gate objects, the remedy is a per-row engage floor — a registry +parameter, never a second regime. _(Amended 2026-09-03, fix round 10: the +argmin stays body-blind, but engage is no longer focus-blind — a BODY focus +must name the nearest body for the arm to be entered, and a differing body +focus releases an engaged arm; null and non-body focus leave the predicate as +specified here. See `regimeArmFor`.)_ + +**Known, out of scope.** A tour that ends on a non-body-centred pose while a +body is focused snaps when the resting driver's pivot pin resumes. That is an +incumbent property of the pin, unchanged by this spec, and it is not spec 2's +to fix. + +## 13. File inventory (indicative — the plan confirms exact paths) + +New: + +``` +src/@types/camera/PoseFrame.d.ts +src/@types/camera/BodyFixedPose.d.ts +src/@types/camera/FramedCameraPose.d.ts +src/@types/camera/SurfaceReadout.d.ts +src/@types/camera/SurfaceGesture.d.ts +src/data/camera/surfaceRegime.ts +src/services/engine/camera/poseFrameConversion.ts +src/services/engine/camera/regimeArmFor.ts +src/services/camera/surfaceController.ts (gestures → new body arm) +src/utils/camera/cursorRayBodyLocal.ts +src/utils/camera/anchoredDragRotation.ts +src/utils/camera/anchoredZoomStep.ts +tests/** mirroring the above + +(`reanchoredPose.ts` and `surfaceReadoutOf.ts` shipped here and were deleted +by the 2026-09-02 simplification wave — zero importers; see §5.3 and the +audit.) +``` + +Modified (prep P5/P6 — packaging per §2): + +``` +src/@types/camera/CameraPose.d.ts (roll) +src/utils/camera/reencodePose.ts +src/services/engine/camera/{poseOf,assembleOrbitCamera}.ts +src/services/camera/orbitControls.ts (recognizer only) +src/services/engine/frame/runFrame.ts (P6: per-frame gesture drain — exact site TBD, see §2) +``` + +Modified (feature): + +``` +src/@types/camera/CameraState.d.ts (base: FramedCameraPose) +src/state/camera/{cameraSlice,selectors,logCameraState}.ts +src/services/engine/camera/cameraDrivers.ts (surface driver; arm-aware isActive) +src/services/engine/frame/runFrame.ts (steps 5-6, the fold) +src/services/engine/frame/frameContext.ts (provider B branch; roll at :222) +src/services/engine/camera/applyWheelZoom.ts (world arm only) +src/services/engine/camera/applyFocusedBodyPivot.ts (world arm only) +src/utils/camera/lonLatFocusPose.ts (body-arm constructor) +src/state/camera/watchFlyToLonLatSaga.ts +src/@types/animation/CameraAction.d.ts (frame tag on set/setVec) +src/services/engine/animation/evaluateClip.ts (per-leg frame conversion) +tests/services/engine/camera/oneMpcSeam.test.ts (permitted-importer amendment) +``` + +Untouched: every slab, layer, shader and renderer file; the tile pipeline; the +`.bin` catalog path; `HASH_PARAM_SOURCES`. + +## 14. Verification plan + +**Unit.** The conversion round trip at Earth, a moon, and a tilted-pole body, +asserting pose exactness and provider A/B agreement at the flip; the anchored +drag rotation against hand-computed two-ray fixtures at the equator, at 80° +latitude, and across the pole; the zoom round trip (260 out, 260 in, cursor +parked) asserting return-to-start; the nadir escape (heading from the up vector inside ~0.08° of +vertical) and its pole escape, now pinned via `refAzimuthOf` / +`tiltFromNadirRad`. + +**Frame-loop.** The fold runs after every pose writer, at one site (assert the +call order, the FW-G finding); a gesture in flight blocks the arm change; the +pin and the follow driver are inert in a body arm. + +**Clock.** With the sim clock at high rate and the arm engaged, the tracked +ground point's body-fixed coordinates are bit-identical across frames — the +FW-F requirement stated as an equality, not a tolerance. + +**Grep.** No stored regime flag; the amended one-seam importer list; no +`Mpc`-suffixed field carrying metres in the engaged path. + +**Then:** `npm run perf` before/after at this worktree's URL, the §11 visual +list with the user, full suite, `/feature-done`. + +## References + +- [Grill session — globe-camera pivot, 2026-08-24](../../grill-sessions/globe-camera-pivot-2026-08-24.md) — the ruling record; every `(ruled, …)` citation resolves here, including the 2026-08-25 addendum (T1, T2, T4, packaging). +- [`docs/research/2026-08-24-camera-pivot/`](../../research/2026-08-24-camera-pivot/) — `DESIGN-INPUT.md` (`C`/`O`/`M`/`X` citations resolve through its §-refs), `probe-findings.md` (the three defects and the parameterization boundary), `skymap-seam-map.md`. +- [Body render slabs — design (spec 1)](completed/2026-08-25-body-render-slabs.md) §5 (the seam contract), §7.1, §10; and its [execution ledger](../plans/completed/2026-08-26-body-render-slabs.ledger.md) (the `lonLatFocusPose` deferral, the one-seam allow-list ruling). +- [ADR 0010 — continuous per-object floating origin](../../adrs/0010-continuous-floating-origin-for-free-zoom.md) — the anchor-relative lineage §5.3 extends to the camera state. +- `docs/superpowers/conventions/simplicity.md` §7 (the asymmetry STOP signal, applied in §4 and §12-R3); `conventions/plan-style.md` (what the downstream plan takes from §3 and §11). + +## Ground preparation — cameraRuntime single-writer (2026-09-09) + +Prep for §14's "Clock" verification (the plan's T17, superseding the parent plan's +T18) and for the follow-driver split R14-3 asks for. Judged against `1d44398e5`, +`cameraRuntime` is a bag of six `{ current }` boxes with eleven writers across five +files; both features are _second_ writers of state that has no single writer today, +so both are bolt-ons on the incumbent shape. The prep lands as five phases (P1–P5) on +this branch ahead of those two features. Plan: +[`plans/2026-09-09-camera-runtime-single-writer.md`](../plans/2026-09-09-camera-runtime-single-writer.md). +Trace, greenfield derivation and the signed-off checkpoint: +`.superpowers/sdd/2026-09-01-camera-pivot/runtime-{trace,greenfield,refactor-ground}.md`. + +**The value.** `CameraRuntime` is replaced once per frame, grouped by lifetime +and owner — five groups, no boxes: + +```ts +export type CameraRuntime = { + readonly register: { readonly pose: FramedCameraPose; readonly winner: string }; + readonly epochs: CameraEpochs; + readonly follow: FollowMemory | null; + readonly surface: SurfaceMemory; + readonly outputs: FrameOutputs; +}; +type Epoch = { readonly ref: Ref | null; readonly startMs: number | null }; +type CameraEpochs = { + readonly tween: Epoch; + readonly frameTween: Epoch; + // ref = the base the spin froze against, null while inactive: the active bit + // and the base identity are ONE reset condition, not two fields. + readonly autoRotate: Epoch; + readonly follow: Epoch; + readonly clip: Epoch>; +}; +type FollowMemory = { + readonly from: CameraPose | null; + readonly distanceTarget: number | null; + readonly panOffset: Vec3; + // The approach's hand-off signal — its ease reached 1 on the frame that set + // this. A phase fact of the produce, so pick and produce cannot disagree. + readonly saturated: boolean; +}; +type SurfaceMemory = { + readonly gesture: SurfaceGesture | null; + readonly pointerDown: boolean; + readonly rememberedTiltRad: number; + readonly memoryBodyId: string | null; +}; +type FrameOutputs = { + readonly displayed: FramedCameraPose; + readonly simDays: number; + readonly upBasis: Mat3; + readonly projection: CameraProjection; +}; +``` + +`register.pose` is the authored register and `register.winner` the id that wrote +it: one fact, one row, so "which driver produced the pose I am looking at" can +no longer disagree with itself. Every output the frame publishes is stored, not +re-derived, because a between-frame reader must agree with the frame that was +last DRAWN — a resize or a sim-clock advance must not retro-change the aspect or +the epoch a pick resolves against. Only elapsed is derived. + +**The step.** `runFrame` holds one assignment and one dispatch loop: + +```ts +stepCameraRuntime(prev: CameraRuntime, inputs: StepInputs): { + readonly next: CameraRuntime; readonly actions: readonly UnknownAction[]; readonly requestRender: boolean; + // The world arm the frame draws, PRE-flip on a crossing frame: the scale bar + // must read the pose the fold judged, not the one it flipped to. + readonly world: CameraPose; + // The effective snapshot the stages read; the keep-ticking vote has to be off + // the same reading, or a commit this frame parks the loop. + readonly rootState: RootState }; +``` + +`StepInputs` is values only: the frame's one store snapshot, the drained steps, the +sim instant + body snapshot, the clip epoch the player already ticked, the driver +table, and **two sizes** — `canvasPx` (CSS, what the cursor math maps through) and +`aspect` (the backing store's, resize-keyed). The focus row is read off the snapshot, +not passed. + +Five pure stages, in this order (the fold stays last, spec §7): + +```ts +replayInput(prev: { register; surface; follow }, steps, ctx) → { register; surface; follow; followDistanceTarget; actions } +advanceEpochs(prev.epochs, { intent; focus; clip; winnerEpoch; nowMs }) → CameraEpochs +pickWinner(drivers, s, approachDone) → CameraDriver, then winner.pose(ctx, mem) → { pose; memory } +commitOnEdge({ register; displayed; produced; prevWinner; winner; drivers }) → { render; authoredOverride; actions } +projectFramePose({ render; authoredOverride; surface; intent; … }) → { register; displayed; surface; world; actions; requestRender } +``` + +The fold's gesture skip reads `intent.dragging` off the effective snapshot — no +`dragging` argument of its own, so it cannot disagree with the intent the drivers +resolved against. `advanceEpochs`' winner-keyed rows (`tween`, `autoRotate`, and the +`follow` cell both follow ids share) replay `prev.ref` when their driver is not +winning, so an ease cannot burn under some other driver. + +Epoch arithmetic is two pure functions — `advanceEpoch(prev, ref, nowMs)` and +`elapsedMs(epoch, nowMs)` — and `advanceEpoch` is IDEMPOTENT for an unchanged +ref. That property is what retires the "call it at most twice, the second is a +no-op" guards at `runFrame.ts:153,168` and `applyWheelZoom.ts:39`: a second +advance cannot mean a second reset when nothing mutates. Elapsed is milliseconds +for all five rows; the clip's consumers divide at the point of use, so the +`clipElapsed`-returns-SECONDS asymmetry (`CameraDriver.d.ts:8`) disappears rather +than being documented again. + +**Effective intent.** Stages after `replayInput` read +`cameraReducer(intent, actionsSoFar)` — the real slice reducer applied locally — +so an at-rest wheel notch's commit is visible to the resting driver in the same +frame exactly as it is today through `runFrame.ts:117`'s post-drain `getState()`, +while the dispatch itself happens after `state.cameraRuntime = next`. Store +listeners then see the frame's runtime already installed — **the one ruled behaviour +change of the prep**; the relative order of the frame's actions is unchanged. + +**Four further deltas from the incumbent, recorded rather than argued away.** (i) +The authored-world reads inside the step resolve against **this** frame's body +snapshot and instant; the between-frame helpers still read `outputs.simDays`, the +instant the last frame DREW at, which is what a pick must agree with. (ii) The +`frameTween` and `clip` epoch rows null out where the incumbent left them stale — a +null `frameTween`, and the clip after `stop()` / a `pendingEnd` tick (20 `epochs.clip` +cells in the driver fixture, nothing else). (iii) On the single frame the approach +hands off to the spin, a wheel notch is **dropped**: `replayInput` routes a notch by +LAST frame's winner, so it resolves into follow memory the winning spin never adopts. +That is a pre-existing route defect — the key is a guess about this frame's winner — +pinned in the driver fixture; the fix is to pick the winner before the drain, which is +the user's call and not this prep's. (iv) `wireInput`'s re-seed replaces ALL FIVE groups +where the incumbent wrote three fields, and the first rAF can beat that async phase +(`engine.ts:265`): a follow capture taken on a pre-seed frame is against the placeholder +pose, so the re-seed discards it and the driver recaptures next frame; `register.winner` +resets to `'resting'` and `upBasis` holds `DEFAULT_ORIENTATION` for one input-free frame. +Benign and spec-sanctioned — the seed IS the one constructor, and a frame before the +recognizer exists has no gesture to lose. + +**Drivers own their memory.** `pose(ctx: DriverCtx, mem: FollowMemory | null) → +{ pose, memory }`; the winner's memory is adopted, the losers' discarded, and the +memory clears as data when the follow epoch's `ref` changes. `DriverCtx` is the +frame as values — the store snapshot, the winner's `elapsedMs`, the authored +`register` and its `authoredWorld` arm (the follow capture reads that eye, never the +displayed pose — R12b-1), `winnerLastFrame`, `simDays`, `projection`, plus the one +fact the follow pair needs: `followDistanceTarget`. `approachDone` reaches the rows +through `isActive`'s second argument, not the ctx. + +The wheel notch a following camera swallows is resolved by the drain (the one +`zoomedDistance` site) and arrives as `ctx.followDistanceTarget`, which the driver +adopts as its new `distanceTarget`, so `applyWheelZoom` stops being a writer. The +roll ride the notch carries (ruling 8) stays in the drain too, now on a pre/post +distance pair as **data**: moved after the drivers it lags the follow driver's roll +lerp by one frame, because the driver would read the ride's commit from a snapshot +taken before it. + +This is the joint R14-3 needs: `followApproach` (priority 55 — above `autoRotate` 20, +below `tween` 60, preserving today's follow-loses-to-tween ordering) and `followHold` +(priority 10) are two rows over one shared produce and one shared memory, not two +writers of one mutable clock. The approach is active while it has not yet authored a +**saturated** frame — `followActive(s) && !approachDone`, with `approachDone` = last +frame's `memory.saturated` and `false` for a fresh focus row — never a clock of the +pick's own: a window measured independently of the produce hands the next driver +whatever pose the ease happened to be at, which at 30 fps leaves most of the approach +untravelled. `commitOnEdge` treats the two rows as **one author**, or the +approach↔hold swap bakes the old body's distance into `base` for the pin to read +around the new one. + +**The clip epoch handshake.** `ClipPlayer.tick(clipEpoch, nowMs) → +{ clipEpoch }`: the player is handed the epoch and returns the one it used, +rebased when a looping clip wraps. It holds no reference to the runtime, which +retires the one true reference capture at `engine.ts:280` and lets the bag be +replaced wholesale. + +**Seed and guard.** `seedCameraRuntime({ committed, projection })` is the only +constructor; `wireInput.ts:119-122` calls it instead of writing four fields into +a half-built bag, and `engine.ts`'s boot value comes through it too. A ts-morph gate +test (the `oneMpcSeam.test.ts` shape) fails on any assignment whose left-hand side is +rooted at `.cameraRuntime` — or at a **local alias** of it, which is how six sagas +reach the bag — outside `runFrame.ts` and that seed, and the sim harness deep-freezes +the bag after every frame so a stray write throws in the suite rather than drifting. + +### Sketch + verdicts + +| Joint | Blocker today | Verdict | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | +| J1 driver returns its memory | `CameraDriver` has no memory member; `followBody` writes `cameraDrivers.ts:163,180,185`, `applyWheelZoom.ts:33` writes it too | bolt-on — R14-3's split adds a 2nd writer of one memory | +| J2 one epoch advance per frame | 5 mutating `*Elapsed` fns, up to 2 calls/frame behind identity guards (`runFrame.ts:153,168`, `applyWheelZoom.ts:39`, `clipPlayer.ts:241`) | bolt-on — every time-keyed driver adds a fn plus a second-call guard | +| J3 runtime as a value | 6 `{ current }` boxes; writers at `runFrame.ts:100,103,129,147,310-312`, `drainInput.ts:83,137,143,180,228`, `wireInput.ts:119-122` | bolt-on — single-writer is a discipline, not a shape | +| J4 surface memory as data | a closure over 3 variables behind 6 methods, driven from `drainInput.ts:72,149,170` and `runFrame.ts:223` | bolt-on — the only method-bearing object in a data bag | +| J5 pure input replay | `drainInput` returns void, writes 5 fields, dispatches 4 actions | bolt-on | +| J6 clipPlayer epoch handshake | `engine.ts:280` captures `cameraRuntime.clock` by reference; `clipPlayer.ts:270` rewinds `clipStartMs` | breaks outright on wholesale replacement | +| J7 seed | `wireInput.ts:119-122` is a second writer of the projection and both poses | growth, once J3 exists | +| G guard | none | new gate test + harness deep-freeze | diff --git a/src/@types/animation/CameraAction.d.ts b/src/@types/animation/CameraAction.d.ts index a8120f324a..209a0f4304 100644 --- a/src/@types/animation/CameraAction.d.ts +++ b/src/@types/animation/CameraAction.d.ts @@ -53,6 +53,7 @@ import type { Channel } from './Channel'; import type { Ease } from './Ease'; import type { Space } from './Space'; +import type { PoseFrame } from '../camera/PoseFrame'; import type { Vec3 } from '../math/Vec3'; export type CameraAction = @@ -63,6 +64,12 @@ export type CameraAction = readonly over: number; readonly ease: Ease; readonly space: Space; + /** + * The frame `to` is read in; absent ⇒ `'absolute'` (Mpc, orientation-frame + * angles). A body frame makes `to` body-FIXED: `distance` a range in + * metres, `yaw`/`pitch` angles about the body's own axes. + */ + readonly frame?: PoseFrame; } | { readonly kind: 'setVec'; @@ -71,6 +78,8 @@ export type CameraAction = readonly over: number; readonly ease: Ease; readonly space: 'lin'; + /** Absent ⇒ `'absolute'` (Mpc); a body frame makes `to` body-fixed metres. */ + readonly frame?: PoseFrame; } | { readonly kind: 'spin'; diff --git a/src/@types/animation/ClipFrameOptions.d.ts b/src/@types/animation/ClipFrameOptions.d.ts new file mode 100644 index 0000000000..3f070f1feb --- /dev/null +++ b/src/@types/animation/ClipFrameOptions.d.ts @@ -0,0 +1,24 @@ +import type { BodyId } from '../data/body/BodyId'; +import type { BodyState } from '../scene/BodyState'; +import type { Mat3 } from '../math/Mat3'; + +/** + * What `evaluateFramedClip` needs beyond the clip itself. Every field is + * optional because a clip with no frame-tagged endpoint never reads them; a + * frame change with any of them missing throws rather than guessing. + */ +export type ClipFrameOptions = { + /** The STEADY orientation basis the clip's absolute angles encode through. */ + readonly frameBasis?: Mat3; + /** + * Body states as of THIS call. A leg's start converts on the first call that + * reaches it, so the conversion stands at the leg-open instant. + */ + readonly bodies?: ReadonlyMap; + /** + * This playback's identity (the `camera.clip` / `camera.tween` object): a + * leg's start converts once per playback. Absent ⇒ keyed on the compiled + * clip, so a replay would reuse the first playback's capture. + */ + readonly playback?: object; +}; diff --git a/src/@types/animation/CompiledClip.d.ts b/src/@types/animation/CompiledClip.d.ts index ba0ff166d2..f505f64622 100644 --- a/src/@types/animation/CompiledClip.d.ts +++ b/src/@types/animation/CompiledClip.d.ts @@ -46,6 +46,7 @@ import type { Ease } from './Ease'; import type { Space } from './Space'; import type { SceneEffect } from './SceneEffect'; import type { CameraPose } from '../camera/CameraPose'; +import type { PoseFrame } from '../camera/PoseFrame'; import type { Vec3 } from '../math/Vec3'; // --------------------------------------------------------------------------- @@ -84,6 +85,9 @@ export type BaseSegment = { readonly to: number | Vec3; readonly ease: Ease; readonly space: Space; + /** Carried from the `set`/`setVec` endpoint; absent ⇒ `'absolute'`. A `spin` + * is a relative writer and never carries one. */ + readonly frame?: PoseFrame; /** Only meaningful for `spin`-kind segments: when true, the spin repeats * its `by`-delta sweep after `endSec` (the perpetual looping orbit idiom). * The evaluator (Task 6) reads this to gate completion logic. */ diff --git a/src/@types/animation/FramedClipPose.d.ts b/src/@types/animation/FramedClipPose.d.ts new file mode 100644 index 0000000000..bfba2f5db3 --- /dev/null +++ b/src/@types/animation/FramedClipPose.d.ts @@ -0,0 +1,14 @@ +import type { CameraPose } from '../camera/CameraPose'; +import type { PoseFrame } from '../camera/PoseFrame'; + +/** + * The four animation channels plus the frame they were interpolated in. + * + * `'absolute'` ⇒ `channels` is Mpc and orientation-frame angles. A body frame ⇒ + * body-FIXED metres and angles about the body's own axes — a LookAt, which the + * driver decodes to a `BodyFixedPose` on its way out (spec §8). + */ +export type FramedClipPose = { + readonly frame: PoseFrame; + readonly channels: CameraPose; +}; diff --git a/src/@types/animation/SceneEffect.ts b/src/@types/animation/SceneEffect.ts index c40bf8713f..b0a8a678ba 100644 --- a/src/@types/animation/SceneEffect.ts +++ b/src/@types/animation/SceneEffect.ts @@ -53,7 +53,7 @@ * against the pinned `clip.frame`, re-encoded into the CURRENT * `settings.orientation` — so `base` is not what's on screen and is not * what commit-on-edge reads either: it bakes the driver's own - * already-current-frame pose (`lastPose`) when the clip ends, never a + * already-current-frame pose (the register) when the clip ends, never a * stale `base`. The interactive path needs the explicit re-encode because * THERE `base` (or a driver derived from it) is what renders immediately; * inside a clip it never is until the clip is already gone. Re-derive diff --git a/src/@types/camera/BodyFixedPose.d.ts b/src/@types/camera/BodyFixedPose.d.ts new file mode 100644 index 0000000000..2e54220e12 --- /dev/null +++ b/src/@types/camera/BodyFixedPose.d.ts @@ -0,0 +1,17 @@ +import type { BodyId } from '../data/body/BodyId'; +import type { Mat3 } from '../math/Mat3'; +import type { Vec3 } from '../math/Vec3'; + +/** + * The camera in one body's FIXED axes, SI metres, f64 — anchor-relative so the + * stored magnitudes shrink with zoom instead of sitting at body-radius scale. + */ +export type BodyFixedPose = { + readonly bodyId: BodyId; + /** Body-fixed anchor point, metres. `[0,0,0]` = body centre (ruled, S2). */ + readonly anchorLocalM: Vec3; + /** Eye − anchor, body-fixed axes, metres. */ + readonly eyeRelAnchorM: Vec3; + /** right | up | forward as columns, body-fixed axes, orthonormal. */ + readonly basisLocal: Mat3; +}; diff --git a/src/@types/camera/BodyLocalRay.d.ts b/src/@types/camera/BodyLocalRay.d.ts new file mode 100644 index 0000000000..4800da8239 --- /dev/null +++ b/src/@types/camera/BodyLocalRay.d.ts @@ -0,0 +1,4 @@ +import type { Vec3 } from '../math/Vec3'; + +/** A pick/cast ray in body-fixed metres: origin + unit direction. */ +export type BodyLocalRay = { readonly originM: Vec3; readonly dir: Vec3 }; diff --git a/src/@types/camera/CameraDebugSnapshot.d.ts b/src/@types/camera/CameraDebugSnapshot.d.ts new file mode 100644 index 0000000000..893fbb4fd4 --- /dev/null +++ b/src/@types/camera/CameraDebugSnapshot.d.ts @@ -0,0 +1,45 @@ +import type { CameraDofAngles } from './CameraDofAngles'; +import type { OrientDeltas } from './OrientDeltas'; +import type { PoseFrame } from './PoseFrame'; +import type { Vec3 } from '../math/Vec3'; + +/** The DebugPanel's "Camera" section readout (spec 2026-09-01-camera-pivot §4/§6). */ +export type CameraDebugSnapshot = { + /** `camera.base.frame` — the regime itself. */ + readonly storedFrame: PoseFrame; + /** `cameraRuntime.register.pose.frame` — the arm actually drawn last frame. */ + readonly renderedFrame: PoseFrame; + readonly armMismatch: boolean; + /** h/R for `dofs.bodyId`; null when no scene body resolved this instant. */ + readonly hOverR: number | null; + /** Altitude above `dofs.bodyId`'s surface, metres; null alongside `hOverR`. */ + readonly altitudeM: number | null; + readonly distanceMpc: number; + /** `settings.orientation` — the configured scene frame. */ + readonly orientationFrame: string; + /** `bodyUpWeight(hOverR)` — BOTH arms' pole↔scene-up blend weight (ruling 10). */ + readonly bandUpWeight: number | null; + /** The session's Cesium-style remembered tilt (ruling 12), radians. */ + readonly rememberedTiltRad: number; + /** Heading / tilt / roll as current-target-residual, radians. */ + readonly dofs: CameraDofAngles; + /** The same three DOFs' per-FRAME motion; the 4 Hz poll cannot measure this. */ + readonly deltas: OrientDeltas; + /** `cameraRuntime.outputs.simDays` — the epoch last frame drew at. */ + readonly lastRenderedSimDays: number; + /** The live clock's instant, resolved at read time (not what any frame drew). */ + readonly liveSimDays: number; + readonly epochDeltaDays: number; + /** True when `epochDeltaDays` exceeds normal render-loop/poll drift. */ + readonly epochMismatch: boolean; + /** Body-fixed anchor, metres, when `renderedFrame` is a body arm; else null. */ + readonly anchorLocalM: Vec3 | null; + /** `|eyeRelAnchorM|`, metres, when `renderedFrame` is a body arm; else null. */ + readonly eyeRelAnchorMagM: number | null; + /** `cameraRuntime.register.winner` — last frame's driver-table winner. */ + readonly activeDriverId: string; + /** Latched gesture mode; 'down (unlatched)' between press and first step; null at rest. */ + readonly gestureMode: string | null; + /** Whether the latched gesture holds a cursor ground hit; null without a latch. */ + readonly gestureCursorHit: boolean | null; +}; diff --git a/src/@types/camera/CameraDofAngles.d.ts b/src/@types/camera/CameraDofAngles.d.ts new file mode 100644 index 0000000000..9a62abfd6e --- /dev/null +++ b/src/@types/camera/CameraDofAngles.d.ts @@ -0,0 +1,12 @@ +import type { BodyId } from '../data/body/BodyId'; +import type { CameraDofRow } from './CameraDofRow'; + +/** The orientation pipeline as three comparable DOF rows, plus the band context they are measured in. */ +export type CameraDofAngles = { + /** The engaged body when the stored frame is a body arm, else the roster nearest. */ + readonly bodyId: BodyId | null; + readonly hOverR: number | null; + readonly heading: CameraDofRow; + readonly tilt: CameraDofRow; + readonly roll: CameraDofRow; +}; diff --git a/src/@types/camera/CameraDofRow.d.ts b/src/@types/camera/CameraDofRow.d.ts new file mode 100644 index 0000000000..105c6fa069 --- /dev/null +++ b/src/@types/camera/CameraDofRow.d.ts @@ -0,0 +1,8 @@ +/** One orientation degree of freedom as the debug panel reads it, radians. */ +export type CameraDofRow = { + readonly currentRad: number | null; + /** What the settle converges this DOF to, whether or not anything is applying it. */ + readonly targetRad: number | null; + /** Wrapped `current − target`; null when either end is unresolved. */ + readonly residualRad: number | null; +}; diff --git a/src/@types/camera/CameraState.d.ts b/src/@types/camera/CameraState.d.ts index 3263e64529..64b7c78f94 100644 --- a/src/@types/camera/CameraState.d.ts +++ b/src/@types/camera/CameraState.d.ts @@ -1,34 +1,29 @@ /** * CameraState — Redux slice shape for the camera's full Intent state. * - * Holds the base orbit pose, an optional in-flight tween descriptor, - * and the auto-rotate and dragging state flags that the engine uses - * to decide each frame's pose and wake signals. - * - * `clip` carries the active animation clip's serializable descriptor while - * a clip is playing. Pose during the clip is DERIVED per frame by the - * driver table (clip@95 wins), not written here — same principle as `tween`. - * `frame` pins the frame the clip started under, so a mid-clip orientation - * switch re-expresses the pose rather than reinterpreting every authored yaw - * against a new pole. Null when no clip is active. - * - * `frameTween` carries the in-flight orientation-frame roll's serializable - * descriptor while the up-basis slerps to a new frame. The basis during the - * slerp is DERIVED per frame by a resolver, not written here — same principle - * as `tween`. Null when no frame roll is in flight. + * `clip` and `frameTween` hold serializable descriptors only: the pose during + * a clip and the up-basis during a frame slerp are DERIVED per frame (driver + * table / resolver), never written here — same principle as `tween`. + * `clip.frame` pins the frame the clip started under, so a mid-clip + * orientation switch re-expresses the pose rather than reinterpreting every + * authored yaw against a new pole. */ -import type { CameraPose } from './CameraPose'; +import type { FramedCameraPose } from './FramedCameraPose'; +import type { CameraTuning } from './CameraTuning'; import type { CameraTweenDescriptor } from './CameraTweenDescriptor'; import type { ClipData } from '../animation/ClipData'; import type { FrameTween } from './FrameTween'; import type { OrientationFrameId } from './OrientationFrameId'; export type CameraState = { - base: CameraPose; + /** The committed pose AND the frame it lives in — the regime itself (spec §4). */ + base: FramedCameraPose; tween: CameraTweenDescriptor | null; autoRotate: { active: boolean; rate: number }; dragging: boolean; clip: { data: ClipData; frame: OrientationFrameId } | null; frameTween: FrameTween | null; + /** The band edges the camera math is threaded with; session-only, never serialized. `readonly`: always replaced whole. */ + readonly tuning: CameraTuning; }; diff --git a/src/@types/camera/CameraTuning.d.ts b/src/@types/camera/CameraTuning.d.ts new file mode 100644 index 0000000000..32e5caf6a1 --- /dev/null +++ b/src/@types/camera/CameraTuning.d.ts @@ -0,0 +1,18 @@ +/** Live camera-band tuning (rulings 11 + 19): the two regime edges, the two + * orientation-blend edges and the two feel toggles, as ONE value threaded into + * the camera math. `clampCameraTuning` is the only producer of a legal one — + * the cross-edge invariants live there. */ +export type CameraTuning = { + /** h/R at which the body arm takes over. */ + readonly engageHR: number; + /** h/R at which it hands back (hysteresis). */ + readonly disengageHR: number; + /** h/R at or below which the reference up is the pure body ENU (full tilt). */ + readonly tiltFullHR: number; + /** h/R at or above which it is the scene up (zero tilt). */ + readonly tiltZeroHR: number; + /** 'log' because zoom is multiplicative: half weight at the band's geometric midpoint. */ + readonly blendSpace: 'log' | 'lin'; + /** Gates the heading+roll framing authority ONLY, never the tilt wall. */ + readonly northUp: boolean; +}; diff --git a/src/@types/camera/DragStep.d.ts b/src/@types/camera/DragStep.d.ts new file mode 100644 index 0000000000..fd3f7750ec --- /dev/null +++ b/src/@types/camera/DragStep.d.ts @@ -0,0 +1,3 @@ +import type { InputStep } from './InputStep'; + +export type DragStep = Extract; diff --git a/src/@types/camera/DraggedSurfacePose.d.ts b/src/@types/camera/DraggedSurfacePose.d.ts new file mode 100644 index 0000000000..19bc0ab6e6 --- /dev/null +++ b/src/@types/camera/DraggedSurfacePose.d.ts @@ -0,0 +1,7 @@ +import type { BodyFixedPose } from './BodyFixedPose'; +import type { SurfaceGesture } from './SurfaceGesture'; + +export type DraggedSurfacePose = { + readonly pose: BodyFixedPose; + readonly mode: SurfaceGesture['mode']; +}; diff --git a/src/@types/camera/EyeFrame.d.ts b/src/@types/camera/EyeFrame.d.ts new file mode 100644 index 0000000000..eff3904e57 --- /dev/null +++ b/src/@types/camera/EyeFrame.d.ts @@ -0,0 +1,15 @@ +import type { Vec3 } from '../math/Vec3'; + +/** + * The pose's orientation readout in the ENU at its own standpoint. + * `refAzimuthOf` owns which axis `azimuthRad` is read off; nulling forward's + * azimuth near nadir drove a measured polar dive THROUGH north-up and back out + * to 79° off. + */ +export type EyeFrame = { + readonly localUp: Vec3; + readonly tiltRad: number; + readonly east: Vec3; + readonly north: Vec3; + readonly azimuthRad: number; +}; diff --git a/src/@types/camera/FramedCameraPose.d.ts b/src/@types/camera/FramedCameraPose.d.ts new file mode 100644 index 0000000000..ccf19bc919 --- /dev/null +++ b/src/@types/camera/FramedCameraPose.d.ts @@ -0,0 +1,12 @@ +import type { BodyId } from '../data/body/BodyId'; +import type { BodyFixedPose } from './BodyFixedPose'; +import type { CameraPose } from './CameraPose'; + +/** + * The authoritative camera pose and the frame it lives in, in the + * tag-beside-channels form ruled for by T4 — the animation system is NOT + * framed this way and keeps its own four channels. + */ +export type FramedCameraPose = + | { readonly frame: 'absolute'; readonly pose: CameraPose } + | { readonly frame: { readonly body: BodyId }; readonly pose: BodyFixedPose }; diff --git a/src/@types/camera/InputGestureEvent.d.ts b/src/@types/camera/InputGestureEvent.d.ts index a55bd948f9..5a309f87bb 100644 --- a/src/@types/camera/InputGestureEvent.d.ts +++ b/src/@types/camera/InputGestureEvent.d.ts @@ -1,11 +1,11 @@ /** * InputGestureEvent — what the orbit-controls gesture recognizer emits. * Positions in CSS pixels, wheel deltas unconverted; `inputAggregator` folds a - * frame's worth into `InputStep`s and the frame's drain applies them. - * - * `*Anchor` arms carry no motion — they re-baseline the aggregator on a fresh - * contact, so a gesture's first run measures from the press point rather than - * from the previous gesture's last position. + * frame's worth into `InputStep`s. `*Anchor` arms carry no motion — they + * re-baseline the aggregator on a fresh contact, so a gesture's first run + * measures from the press point, not the previous gesture's last position. The + * wheel carries its own cursor pixel because the body arm zooms toward what the + * cursor is over with no pointer down (spec §6b), where no drag baseline exists. */ import type { DragMode } from './DragMode'; @@ -17,4 +17,4 @@ export type InputGestureEvent = | { kind: 'dragMove'; mode: DragMode; xPx: number; yPx: number } | { kind: 'pinchAnchor'; distPx: number } | { kind: 'pinchMove'; distPx: number } - | { kind: 'wheel'; deltaY: number; duringGesture: boolean }; + | { kind: 'wheel'; deltaY: number; duringGesture: boolean; xPx: number; yPx: number }; diff --git a/src/@types/camera/InputStep.d.ts b/src/@types/camera/InputStep.d.ts index ae7ebdb7e6..b0f959a453 100644 --- a/src/@types/camera/InputStep.d.ts +++ b/src/@types/camera/InputStep.d.ts @@ -1,12 +1,13 @@ /** * InputStep — one frame's worth of input, collapsed by `inputAggregator`. * - * A `drag` run carries absolute CSS pixels, not a delta: `startPx` is where the - * pointer stood at the END of the previous frame (or the press point). Today's - * consumer only needs `end − start`; the encoding is chosen for spec 2's - * anchored arm, which casts a ray through each pixel. `duringGesture` splits - * the two zoom owners: pointer down ⇒ the drag register renders, at rest ⇒ the - * store `base` does. Not readonly — the aggregator extends a run in place. + * A `drag` run carries absolute CSS pixels, not a delta, because the body arm + * casts a ray through each pixel: `startPx` is where the pointer stood at the + * END of the previous frame (or the press point). Not + * readonly — the aggregator extends a run in place. `duringGesture` splits the + * two zoom owners: pointer down ⇒ the gesture register renders, at rest ⇒ the + * store `base` does. `cursorPx` is where the wheel fired, in the same absolute + * pixels; `null` for a pinch, which has two contacts and no single cursor. */ import type { DragMode } from './DragMode'; @@ -16,4 +17,4 @@ export type InputStep = | { kind: 'gestureStart' } | { kind: 'gestureEnd' } | { kind: 'drag'; mode: DragMode; startPx: Vec2; endPx: Vec2 } - | { kind: 'zoom'; factor: number; duringGesture: boolean }; + | { kind: 'zoom'; factor: number; duringGesture: boolean; cursorPx: Vec2 | null }; diff --git a/src/@types/camera/OrbitCameraInit.d.ts b/src/@types/camera/OrbitCameraInit.d.ts index df21f89770..d1e2cc19d0 100644 --- a/src/@types/camera/OrbitCameraInit.d.ts +++ b/src/@types/camera/OrbitCameraInit.d.ts @@ -1,14 +1,7 @@ /** - * OrbitCameraInit — all parameters needed to construct an orbit camera. - * Separating init from live state lets createOrbitCamera accept a plain object - * literal and derive the rest (e.g. position) from it. - */ - -/** - * All parameters needed to construct an orbit camera. - * - * Separating init from state lets us pass a plain object literal to - * `createOrbitCamera` and derive the rest (e.g. `position`) from it. + * All parameters needed to construct an orbit camera. Separating init from + * live state lets `createOrbitCamera` take a plain object literal and derive + * the rest (e.g. `position`) from it. */ import type { Vec3 } from './math/Vec3'; import type { Mat3 } from '../math/Mat3'; @@ -17,121 +10,62 @@ export type OrbitCameraInit = { /** World-space point the camera orbits around and looks at. */ target: Vec3; - /** - * Radius of the orbit sphere — distance between camera and target. - * Must be > 0; should stay > `near` to avoid clipping the target. - */ + /** Radius of the orbit sphere. Must be > 0, and > `near` or the target clips. */ distance: number; /** - * Horizontal rotation angle in radians around the world +Y axis. - * - * Convention: yaw=0 places the camera on the +Z side of the target. - * Positive yaw rotates counter-clockwise when viewed from above. + * Horizontal rotation, radians: yaw=0 places the camera on the +Z side of + * the target, positive yaw turns counter-clockwise seen from above. */ yaw: number; /** - * Vertical tilt angle in radians above or below the horizontal plane. - * - * pitch=0 → camera is level with the target. - * pitch > 0 → camera tilts upward (toward +Y pole). - * pitch < 0 → camera tilts downward (toward −Y pole). - * - * ⚠ Singularity: at pitch = ±π/2 the camera sits exactly on the +Y or - * −Y pole. At that point the "forward" direction and the "up" vector - * `[0, 1, 0]` are collinear, and `lookAt` degenerates (produces NaN - * or a wildly wrong matrix). The controls module (Task 8) *must* clamp - * pitch to a range like ±(π/2 − ε) to avoid this. + * Vertical tilt off the horizontal plane, radians; positive tilts toward the + * +Y pole. ⚠ At ±π/2 forward and the `[0, 1, 0]` up are collinear and + * `lookAt` degenerates to NaN — the controls module clamps to ±(π/2 − ε). */ pitch: number; /** - * Camera-roll angle in radians around the view direction (the line - * from `target` to `position`). Rotates the up-vector that - * `mat4.lookAt` uses to orient the image plane. - * - * roll = 0 → world +Y stays "up" on screen (default; matches every - * pre-roll rendering the project produced). - * roll > 0 → image rotates counter-clockwise (the world tilts CW). - * - * Why expose roll on an orbit camera at all? In a cosmological - * scene there is no preferred up direction, so allowing the user - * to roll is physically meaningful and not just a cosmetic. - * - * Optional with a default of 0 to keep every existing call site - * (synthetic clouds, focus tween, controls) working unchanged. + * Camera roll about the view direction, radians. 0 keeps world +Y up on + * screen; positive rotates the image counter-clockwise. Optional, default 0. */ roll?: number; /** - * Frame-local → world orientation basis (column-major 3×3) the (yaw, pitch) - * DECODE runs through: `dir_world = poseBasis · dir_local`, where the - * frame-local zenith (elevation +π/2) is local +Y. `updatePosition` and - * `orbitAnglesLookingAlong` (its inverse) are the two consumers — both - * compile-time / decode-time reads, never draw-time. - * - * `poseBasis` and `upBasis` (below) used to be one field (`frameBasis`). - * They are split because they answer different questions: `poseBasis` is - * "which pole does yaw/pitch orbit around" (the committed frame — jumps once - * at a switch, never mid-slerp), `upBasis` is "which pole does screen-up - * follow" (the live, possibly mid-slerp `B(t)`). A pose baked mid-roll off a - * transient `upBasis` would decode wrong the instant the roll finished, so - * `updatePosition` must stay pinned to the steady `poseBasis`. - * - * Optional: absent ⇒ identity, i.e. the pre-feature decode where local +Y is - * world +Y. Every non-engine caller (synthetic clouds, focus tween, dev-tool - * cameras) omits it and is byte-for-byte unchanged — mirrors `roll?`. - * - * Mutable (like `roll`, `yaw`, `pitch`): the engine's drag register - * (`state.cam`) has this overwritten once per frame (alongside `upBasis`) - * with the resolved B(t), so a grab decodes through the current pole. + * Frame-local → world basis the (yaw, pitch) DECODE runs through: + * `dir_world = poseBasis · dir_local`, frame-local zenith is local +Y. + * Split from `upBasis` because they answer different questions: this one is + * "which pole does yaw/pitch orbit around" — the committed frame, which jumps + * once at a switch and never mid-slerp. A pose baked mid-roll off a transient + * `upBasis` would decode wrong the instant the roll finished, so + * `updatePosition` must stay pinned here. Absent ⇒ identity. */ poseBasis?: Mat3; /** - * Frame-local → world orientation basis screen-up is derived from: - * `frameUp(upBasis)` (the middle column) feeds `imagePlaneBasis`, which - * `computeViewProj`, `cameraBillboardBasis`, `horizonShellRenderer`, `slabs`, - * and `orbitControls`' pan drag all read. These are draw-time / per-frame - * reads, so `upBasis` is free to be the transient mid-slerp basis during an - * orientation-frame switch — see `poseBasis` above for why the decode can't - * share that transience. - * - * Optional, same identity-absent convention as `poseBasis`. + * Frame-local → world basis screen-up is derived from: `frameUp(upBasis)` + * feeds `imagePlaneBasis` and every draw-time reader, so this one is free to + * be the transient mid-slerp basis during an orientation-frame switch — see + * `poseBasis` for why the decode cannot share that transience. Absent ⇒ + * identity. */ upBasis?: Mat3; - /** - * Vertical field of view in **radians**. - * - * π/4 (45°) is a natural-looking default. Wider values (large fovYRad) - * create a fisheye look; narrower simulate a telephoto lens. - */ + /** Vertical field of view, **radians**. */ fovYRad: number; - /** - * Viewport width / height ratio. Must be updated whenever the canvas - * is resized, otherwise the projection will stretch or squash the scene. - */ + /** Viewport width / height; must be re-set on every canvas resize. */ aspect: number; /** - * Distance from the camera to the near clip plane. - * - * Fragments closer than `near` are discarded. Keep this as large as - * your scene allows — the precision of the depth buffer is distributed - * logarithmically between `near` and `far`, so a very small `near` - * (e.g. 0.001) wastes most of the depth-buffer precision in empty space - * close to the viewer, leading to z-fighting on distant geometry. + * Near clip distance. Keep it as large as the scene allows: depth-buffer + * precision is distributed logarithmically between `near` and `far`, so a + * very small `near` spends most of it on empty space and z-fights distant + * geometry. */ near: number; - /** - * Distance from the camera to the far clip plane. - * - * Fragments beyond `far` are discarded. Keep this as small as your - * scene allows (same reasoning as `near`). - */ + /** Far clip distance. Keep it as small as the scene allows (same reasoning). */ far: number; }; diff --git a/src/@types/camera/OrientDeltas.d.ts b/src/@types/camera/OrientDeltas.d.ts new file mode 100644 index 0000000000..2343c64e3b --- /dev/null +++ b/src/@types/camera/OrientDeltas.d.ts @@ -0,0 +1,8 @@ +import type { OrientDofDelta } from './OrientDofDelta'; + +/** Per-DOF pose motion, keyed the same as `CameraDofAngles`' rows. */ +export type OrientDeltas = { + readonly heading: OrientDofDelta; + readonly tilt: OrientDofDelta; + readonly roll: OrientDofDelta; +}; diff --git a/src/@types/camera/OrientDofDelta.d.ts b/src/@types/camera/OrientDofDelta.d.ts new file mode 100644 index 0000000000..2c049bf8cc --- /dev/null +++ b/src/@types/camera/OrientDofDelta.d.ts @@ -0,0 +1,7 @@ +/** One DOF's frame-to-frame motion with peak hold, radians. */ +export type OrientDofDelta = { + /** This frame − last frame, wrapped to ±π; 0 while the DOF is unresolved. */ + readonly deltaRad: number; + /** Largest `|deltaRad|` since the last clear. */ + readonly peakAbsRad: number; +}; diff --git a/src/@types/camera/PoseFrame.d.ts b/src/@types/camera/PoseFrame.d.ts new file mode 100644 index 0000000000..7786474f89 --- /dev/null +++ b/src/@types/camera/PoseFrame.d.ts @@ -0,0 +1,4 @@ +import type { BodyId } from '../data/body/BodyId'; + +/** The frame a stored or authored camera pose is expressed in (ruled, Q10). */ +export type PoseFrame = 'absolute' | { readonly body: BodyId }; diff --git a/src/@types/camera/SurfaceGesture.d.ts b/src/@types/camera/SurfaceGesture.d.ts new file mode 100644 index 0000000000..e47a1e655e --- /dev/null +++ b/src/@types/camera/SurfaceGesture.d.ts @@ -0,0 +1,13 @@ +import type { Vec2 } from '../math/Vec2'; +import type { Vec3 } from '../math/Vec3'; + +/** Per-gesture, latched at gesture start, dead at pointerup (ruled, Q3). */ +export type SurfaceGesture = { + readonly mode: 'pan' | 'orbit' | 'strafe' | 'look' | 'tilt'; + /** |first pick| — the FROZEN pan sphere, body-fixed metres (C §2.3, §6.2). */ + readonly anchorRadiusM: number; + /** Body-fixed, never world (C landmine #5). */ + readonly anchorLocalM: Vec3 | null; + /** Previous FRAME's end pixel, not the press point (C §2.1). */ + readonly prevPixel: Vec2; +}; diff --git a/src/@types/camera/SurfaceMemory.d.ts b/src/@types/camera/SurfaceMemory.d.ts new file mode 100644 index 0000000000..cd6a7814be --- /dev/null +++ b/src/@types/camera/SurfaceMemory.d.ts @@ -0,0 +1,13 @@ +import type { SurfaceGesture } from './SurfaceGesture'; + +/** + * The body arm's gesture memory (spec §6), replaced once per frame: the latched + * gesture (dies at pointerup) + the session's remembered tilt (ruling 12). + */ +export type SurfaceMemory = { + /** The gesture lifecycle: null = idle · 'down' = pressed, not yet latched · else latched. */ + readonly gesture: SurfaceGesture | 'down' | null; + /** Radians, un-mapped through the band weight; 0 until a tilt/look drag sets it. */ + readonly rememberedTiltRad: number; + readonly memoryBodyId: string | null; +}; diff --git a/src/@types/camera/SurfacePick.d.ts b/src/@types/camera/SurfacePick.d.ts new file mode 100644 index 0000000000..ce51ec80c7 --- /dev/null +++ b/src/@types/camera/SurfacePick.d.ts @@ -0,0 +1,4 @@ +import type { Vec3 } from '../math/Vec3'; + +/** `incidence` is `ray·normal` at the hit — 0 is edge-on. */ +export type SurfacePick = { readonly pointM: Vec3; readonly incidence: number }; diff --git a/src/@types/engine/ReadyEngineState.d.ts b/src/@types/engine/ReadyEngineState.d.ts index 1ef2d88862..56f9421249 100644 --- a/src/@types/engine/ReadyEngineState.d.ts +++ b/src/@types/engine/ReadyEngineState.d.ts @@ -13,7 +13,6 @@ */ import type { EngineState } from './state/EngineState'; -import type { OrbitCamera } from '../camera/OrbitCamera'; import type { GalaxyPointRenderer } from '../rendering/GalaxyPointRenderer'; import type { GalaxyPickRenderer } from '../rendering/GalaxyPickRenderer'; import type { Compositor } from '../rendering/Compositor'; @@ -21,7 +20,6 @@ import type { RenderTargets } from '../rendering/RenderTargets'; import type { TexturedDiskSubsystem } from './subsystems/TexturedDiskSubsystem'; export type ReadyEngineState = EngineState & { - cam: OrbitCamera; gpu: EngineState['gpu'] & { galaxyPointRenderer: GalaxyPointRenderer; galaxyPickRenderer: GalaxyPickRenderer; diff --git a/src/@types/engine/camera/CameraClock.d.ts b/src/@types/engine/camera/CameraClock.d.ts deleted file mode 100644 index 1740c58a6d..0000000000 --- a/src/@types/engine/camera/CameraClock.d.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * CameraClock — the engine Resource that converts 'a descriptor's identity - * changed' into 'ms elapsed since it started'. - * - * The store's `CameraTweenDescriptor` and the `autoRotate` flag are timeless: - * they say WHAT the camera should do, not WHEN it started. The clock is the - * transient bridge — it detects reference-identity changes on each frame and - * resets the relevant start time. This keeps store shapes free of wall-clock - * coupling while giving drivers an elapsed-ms value they can use for easing. - * - * Reference-identity reset (rather than a per-driver 'enter' hook): - * a new `startCameraTween` dispatch installs a NEW descriptor object; - * `!==` against `lastTweenRef` fires the zero exactly once, on the frame the - * new object arrives. No lifecycle hooks, no subscription machinery — the - * frame-loop pass of `tweenElapsed` is the only wire. - * - * Lives in engine transient state, not the Redux store. - */ - -import type { CameraTweenDescriptor } from '../../camera/CameraTweenDescriptor'; -import type { CameraPose } from '../../camera/CameraPose'; -import type { CameraState } from '../../camera/CameraState'; -import type { SelectionRow } from '../SelectionRow'; -import type { FrameTween } from '../../camera/FrameTween'; -import type { Vec3 } from '../../math/Vec3'; - -export type CameraClock = { - tweenStartMs: number | null; - autoRotateStartMs: number | null; - lastTweenRef: CameraTweenDescriptor | null; - lastAutoRotateActive: boolean; - // The frame-roll clock keys on the `FrameTween` REFERENCE, not its contents. - // An orientation-frame switch installs a NEW `FrameTween` object, so `!==` - // against `lastFrameTweenRef` fires the zero exactly once on the switch frame - // — the same identity-reset pattern `lastTweenRef` uses for camera tweens. - frameTweenStartMs: number | null; - lastFrameTweenRef: FrameTween | null; - // ── follow-body approach ease ────────────────────────────────────────────── - // The `followBody` driver eases the camera from wherever it was into a framing - // pose on the focused body, then translate-follows the body as the sim clock - // moves it. Like the tween/autoRotate arms, the ease is wall-clock-free in the - // store — the timer lives here. - // - // The follow clock keys on the focus ROW REFERENCE, not the body id: a - // re-select of the SAME body installs a fresh `selectionRows.focus` object, so - // `!==` fires the ease-restart exactly once on that transition (the same - // identity-reset pattern `lastTweenRef` uses). A drag mid-follow leaves the ref - // untouched, so the ease is NOT restarted on drag-release — the camera resumes - // its saturated follow rather than snapping back to re-approach. - followStartMs: number | null; - lastFollowRef: SelectionRow | null; - // The on-screen pose captured at the activation edge — the `from` the approach - // eases OUT of. Captured from the live rendered pose (`lastPose`), not `base`, - // so switching focus A→B eases from where the camera visibly IS (framing A), - // never jumping back to the committed resting pose first. Nulled on the edge by - // `followElapsed`; the first `pose()` after fills it (only the driver can see - // the live rendered pose, so the capture is split from the timer). - followFrom: CameraPose | null; - // The distance the approach eases TOWARD. Two distinct sources feed it, and - // conflating them is the bug this field un-braids: - // - // - INITIAL APPROACH target = the `bodyFocusDistance` framing distance. On a - // fresh focus (ref change), `followElapsed` nulls this alongside `followFrom` - // and the first `pose()` re-seeds it to the framing distance, so the camera - // flies in and frames the body regardless of where it started. - // - // - STEADY-STATE target = the user's committed `base.distance`. When a drag - // interrupts a follow (orbitDrag wins, commits a zoom into `base`) and follow - // then re-wins for the SAME focus ref, `pose()` re-captures `base.distance` - // here — so a drag-zoom sticks instead of snapping back to the framing - // distance every frame. The re-capture edge is 'follow won this frame but was - // not the previous winner' (`prevActiveId !== 'followBody'` with the focus ref - // unchanged), detected in the driver via the existing `prevActiveId` Resource. - // - // Null means 'no target seeded yet' — the fresh-focus signal that routes `pose()` - // to the framing branch. Non-null with an unchanged focus ref means an approach - // is (or was) in flight, so a not-follow-previous frame is a drag reactivation. - followDistanceTarget: number | null; - // ── follow-body pan (strafe) offset ──────────────────────────────────────── - // A right-drag strafe while following a body cannot move `cam.target` (the - // pivot-pin overwrites it with the body position every frame). Instead the - // strafe accumulates here as a WORLD-frame offset, and the pivot resolves to - // `bodyPosition + followPanOffset` — so the offset rides along with the body's - // motion (translate-follow keeps tracking the body, just shifted). World-frame - // is chosen for simplicity: at the scales a followed body is viewed, a fixed - // world-space offset reads as a stable screen strafe, and it needs no camera - // basis re-projection. The reset is winner-gated: `followElapsed` zeroes it - // (alongside the other follow fields) on a focus ROW ref change, but it only - // runs when followBody wins the frame. So an offset can outlive a focus switch - // while a higher driver (e.g. autoRotate) holds the win; it clears the next - // time followBody wins, and the new target starts centred from there. - followPanOffset: Vec3; - // The `cam.target` recorded on the previous follow-drag frame, so the strafe is - // folded in as the frame-to-frame DELTA of `cam.target` (which, during a drag, - // is pure pan — orbit changes yaw/pitch, and the body's own motion never - // touches `cam.target`). Null when no follow-drag is in progress, so each grab - // starts a fresh delta chain rather than re-basing the offset. - lastPanTarget: Vec3 | null; - // The `base` reference auto-rotate last spun from. A commit-on-edge installs a - // NEW base object while auto-rotate stays active; the spin clock resets when - // this changes so the freshly committed base spins from elapsed 0, not from - // the stale accumulated time (which would jump the camera on resume). - lastBaseRef: CameraPose | null; - // The clip clock keys on the `camera.clip` REFERENCE, not its contents. - // A `startClip` dispatch installs a NEW `{ data, frame }` object each time, - // so `!==` fires the zero exactly once on the transition frame — the same - // identity-reset pattern used by `lastTweenRef` for tweens. - clipStartMs: number | null; - lastClipRef: CameraState['clip']; -}; diff --git a/src/@types/engine/camera/CameraDriver.d.ts b/src/@types/engine/camera/CameraDriver.d.ts index f627ddc35a..4be5ae6d88 100644 --- a/src/@types/engine/camera/CameraDriver.d.ts +++ b/src/@types/engine/camera/CameraDriver.d.ts @@ -1,81 +1,30 @@ -/** - * CameraDriver — the seam that turns camera precedence into DATA. - * - * The engine has several things that all want to move the camera on a - * given frame: an in-flight tween, the idle auto-rotate, and a guided - * tour. Historically the winner was decided by *call order* inside the - * per-frame body — tween advanced first, then a hand-written guard - * suppressed auto-rotate when anything else was active. Precedence was - * an emergent property of how the statements happened to be sequenced, - * which meant inserting a new mover (or changing who beats whom) was a - * surgical edit to control flow rather than a one-line declaration. - * - * A CameraDriver makes each mover a uniform, self-describing unit so a - * single resolver can pick the winner by comparing `priority` instead - * of relying on statement order. The set of drivers is a registry; the - * ordering between them is a number. Adding a tour, or re-ranking the - * existing movers, becomes data — not a rewrite of the frame loop. - * - * The five members, and why each exists: - * - * - `id` — a stable string identity ('clip' | 'orbitDrag' | 'tween' | - * 'autoRotate' | 'resting'). Purely for debugging and logging: it lets - * a trace say "frame written by 'tween'" without the resolver needing - * to know any concrete driver's type. - * - * - `priority` — the sole thing the resolver orders by. The current - * ranking is clip 95 > orbitDrag 80 > tween 60 > autoRotate 20 > - * followBody 10 > resting 0; the gaps are deliberate headroom so a future driver can - * slot between two existing ones without renumbering. The 95 slot is - * occupied by the clip driver. `followBody` (10) sits BELOW autoRotate on - * purpose: a body focus pins the pivot (via `pivotsOnFocusedBody`), but - * autoRotate / a drag can still win the ORBIT terms and spin around the - * body — followBody only authors the initial approach ease + idle hold. - * - * - `isActive(s)` — answers two questions with one predicate. Per-driver - * it means "do I want to author the camera pose this frame?", which - * is how the resolver knows whether to even consider me. Collectively - * (any driver active) it is also the render-on-demand signal: if no - * animated driver is active and nothing else is animating, the frame - * loop can sleep. The `resting` driver is always active (priority 0) - * so the resolver always has a winner. Takes the store `RootState` - * so drivers can read intent without coupling to EngineState. - * - * - `pose(s, cam, elapsedMs)` — returns the `CameraPose` the resolver - * should apply this frame. Only the highest-priority active driver's - * `pose` is called — single-writer, no blending. The `cam` reference - * is forwarded so shim drivers (that still advance engine state) can - * read the live orbit params; real store-reading drivers read `s` - * instead. NOTE: the `elapsedMs` name is generic — the clip driver - * interprets it as SECONDS (not ms), because `evaluateClip` takes an - * `elapsedSec` parameter. Each driver owns its own elapsed unit. - * - * - `commitsOnEdge` — optional flag. When true, the frame loop bakes this - * driver's final pose into `camera.base` the frame it deactivates, so - * the camera holds the saturated pose rather than snapping back to the - * previous base. The frame loop reads this flag instead of hardcoding - * driver-id literals — adding a new committing driver is a one-line - * declaration here. - */ +/** CameraDriver — one precedence-table row; the ranking and its why live with the table. */ -import type { OrbitCamera } from '../../camera/OrbitCamera'; -import type { CameraPose } from '../../camera/CameraPose'; +import type { DriverCtx } from './DriverCtx'; +import type { DriverId } from './DriverId'; +import type { EpochRow } from './EpochRow'; +import type { FollowMemory } from './FollowMemory'; +import type { FramedCameraPose } from '../../camera/FramedCameraPose'; import type { RootState } from '../../../store/types'; export type CameraDriver = { - readonly id: string; + readonly id: DriverId; readonly priority: number; - // Drivers whose final pose must bake into `camera.base` when they DEACTIVATE - // set this (so the frame loop freezes the saturated pose instead of snapping - // back). The frame loop (Task 10) reads this flag instead of hardcoding - // driver-id literals. + // The epoch this row's `ctx.elapsedMs` measures on; unset for the rows that + // read no clock (orbitDrag, resting). Both follow rows name `follow`, so the + // approach's ease and the hold's saturation share one epoch. + readonly epoch?: EpochRow; + // Bake this row's final register into `camera.base` as it DEACTIVATES, so the + // loop freezes the saturated pose instead of snapping back to the old base. readonly commitsOnEdge?: boolean; - // Drivers that author an ORBIT pose (yaw / pitch / distance around a target) - // set this so the frame loop re-centres their `target` on the focused scene - // body while one is focused (the pivot-pin, `applyFocusedBodyPivot`). The body - // owns the pivot; the driver owns the orbit terms. clip / tween keyframe a full - // path (target included) and leave this unset so their target is honoured. + // This row authors ORBIT terms, so `applyFocusedBodyPivot` re-centres its `target` + // on the focused body; clip / tween keyframe a target of their own and leave it unset. readonly pivotsOnFocusedBody?: boolean; - isActive(s: RootState): boolean; - pose(s: RootState, cam: OrbitCamera, elapsedMs: number): CameraPose; + // `approachDone` = the follow memory saturated last frame; only `followApproach` reads it. + isActive(s: RootState, approachDone?: boolean): boolean; + // A row that owns no memory hands `mem` back, so the winner's adoption needs no branch. + pose( + ctx: DriverCtx, + mem: FollowMemory | null, + ): { readonly pose: FramedCameraPose; readonly memory: FollowMemory | null }; }; diff --git a/src/@types/engine/camera/CameraEpochs.d.ts b/src/@types/engine/camera/CameraEpochs.d.ts new file mode 100644 index 0000000000..8e05729823 --- /dev/null +++ b/src/@types/engine/camera/CameraEpochs.d.ts @@ -0,0 +1,19 @@ +/** + * CameraEpochs — one immutable `Epoch` per timed camera channel; replaced + * wholesale once per frame. `autoRotate`'s ref is `active ? base : null`, so + * a deactivation and a base re-commit are both ref changes. + */ +import type { Epoch } from './Epoch'; +import type { CameraTweenDescriptor } from '../../camera/CameraTweenDescriptor'; +import type { FrameTween } from '../../camera/FrameTween'; +import type { FramedCameraPose } from '../../camera/FramedCameraPose'; +import type { SelectionRow } from '../SelectionRow'; +import type { CameraState } from '../../camera/CameraState'; + +export type CameraEpochs = { + readonly tween: Epoch; + readonly frameTween: Epoch; + readonly autoRotate: Epoch; + readonly follow: Epoch; + readonly clip: Epoch>; +}; diff --git a/src/@types/engine/camera/DriverCtx.d.ts b/src/@types/engine/camera/DriverCtx.d.ts new file mode 100644 index 0000000000..98854711fa --- /dev/null +++ b/src/@types/engine/camera/DriverCtx.d.ts @@ -0,0 +1,35 @@ +/** + * DriverCtx — every value a camera driver may read this frame; the table is a + * module constant, so a driver sees the frame ONLY through this bag. + */ + +import type { BodyId } from '../../data/body/BodyId'; +import type { BodyState } from '../../scene/BodyState'; +import type { CameraPose } from '../../camera/CameraPose'; +import type { CameraProjection } from '../../camera/CameraProjection'; +import type { DriverId } from './DriverId'; +import type { FramedCameraPose } from '../../camera/FramedCameraPose'; +import type { Mat3 } from '../../math/Mat3'; +import type { RootState } from '../../../store/types'; + +export type DriverCtx = { + readonly state: RootState; + /** Elapsed on the winner's own `epoch` row (`CameraDriver`); 0 for the untimed rows. */ + readonly elapsedMs: number; + /** The AUTHORED register (`cameraRuntime.register.pose`), pre-projection — R12b-1. */ + readonly register: FramedCameraPose; + /** World arm of `register`; the follow capture reads its eye. */ + readonly authoredWorld: CameraPose; + readonly winnerLastFrame: DriverId; + /** The frame's COMMITTED orientation basis (`stepCameraRuntime`); the live + * `upBasis` is the fold's, and no driver reads it. `Readonly` because it + * ALIASES the `ORIENTATION_FRAMES` entry — a write here corrupts the registry. */ + readonly poseBasis: Readonly; + /** Julian days. */ + readonly simDays: number; + readonly projection: CameraProjection; + /** This instant's body states — a frame-tagged keyframe converts through them. */ + readonly bodies: ReadonlyMap; + /** Mpc; null on a frame with no swallowed wheel notch. */ + readonly followDistanceTarget: number | null; +}; diff --git a/src/@types/engine/camera/DriverId.d.ts b/src/@types/engine/camera/DriverId.d.ts new file mode 100644 index 0000000000..61874da1e6 --- /dev/null +++ b/src/@types/engine/camera/DriverId.d.ts @@ -0,0 +1,15 @@ +/** + * DriverId — the row names of the `cameraDrivers` table. Every "who won?" + * comparison outside the table (`stepCameraRuntime`'s tween cancel, the wheel's + * spin-preserving zoom, `isFollowDriverId`) spells one of these by literal, so + * the union is what makes a renamed or mistyped row a compile error. + */ + +export type DriverId = + | 'clip' + | 'orbitDrag' + | 'followApproach' + | 'followHold' + | 'tween' + | 'autoRotate' + | 'resting'; diff --git a/src/@types/engine/camera/Epoch.d.ts b/src/@types/engine/camera/Epoch.d.ts new file mode 100644 index 0000000000..78943fc67c --- /dev/null +++ b/src/@types/engine/camera/Epoch.d.ts @@ -0,0 +1,2 @@ +/** Epoch — reset-on-reference-change, then measure since. See `cameraEpochs.ts`. */ +export type Epoch = { readonly ref: Ref | null; readonly startMs: number | null }; diff --git a/src/@types/engine/camera/EpochRow.d.ts b/src/@types/engine/camera/EpochRow.d.ts new file mode 100644 index 0000000000..70b61f3780 --- /dev/null +++ b/src/@types/engine/camera/EpochRow.d.ts @@ -0,0 +1,8 @@ +/** + * EpochRow — the epoch a driver measures on, and the row its win makes eligible + * to advance. `frameTween` is excluded: no driver authors the roll, so + * `resolveFrameBasis` reads that row whoever won. + */ +import type { CameraEpochs } from './CameraEpochs'; + +export type EpochRow = Exclude; diff --git a/src/@types/engine/camera/FollowMemory.d.ts b/src/@types/engine/camera/FollowMemory.d.ts new file mode 100644 index 0000000000..4550afa9c4 --- /dev/null +++ b/src/@types/engine/camera/FollowMemory.d.ts @@ -0,0 +1,18 @@ +/** + * FollowMemory — what the follow rows carry for ONE focus row; `null` = none yet. + * `stepCameraRuntime` drops it on a follow-epoch ref change; the produce refills. + * `from` is captured eye-preserving against the NEW body (R12b-1); `saturated` is + * the approach's hand-off signal, set on the frame its ease reached 1. + */ + +import type { CameraPose } from '../../camera/CameraPose'; +import type { Vec3 } from '../../math/Vec3'; + +export type FollowMemory = { + readonly from: CameraPose | null; + /** Mpc; three sources, resolved in `followPose`. */ + readonly distanceTarget: number | null; + /** WORLD frame — a stable screen strafe at follow scales, no basis re-projection. */ + readonly panOffset: Vec3; + readonly saturated: boolean; +}; diff --git a/src/@types/engine/camera/StepInputs.d.ts b/src/@types/engine/camera/StepInputs.d.ts new file mode 100644 index 0000000000..08f28fce34 --- /dev/null +++ b/src/@types/engine/camera/StepInputs.d.ts @@ -0,0 +1,29 @@ +/** + * StepInputs — everything `stepCameraRuntime` reads for one frame, as values: + * the frame's ONE store snapshot, the drained input steps, the sim instant and + * body snapshot derived before the step, and the clip epoch the player already + * ticked. Two sizes on purpose: `canvasPx` is the CSS size the cursor math + * maps through; `aspect` is the backing store's, which only changes on a resize. + */ + +import type { BodyId } from '../../data/body/BodyId'; +import type { BodyState } from '../../scene/BodyState'; +import type { CameraDriver } from './CameraDriver'; +import type { CameraState } from '../../camera/CameraState'; +import type { Epoch } from './Epoch'; +import type { InputStep } from '../../camera/InputStep'; +import type { Vec2 } from '../../math/Vec2'; +import type { RootState } from '../../../store/types'; + +export type StepInputs = { + readonly nowMs: number; + readonly simDays: number; + readonly rootState: RootState; + readonly canvasPx: Readonly; + /** `canvas.width / canvas.height` after `resizeCanvasToDisplay`. */ + readonly aspect: number; + readonly steps: readonly InputStep[]; + readonly bodies: ReadonlyMap; + readonly clipEpoch: Epoch>; + readonly drivers: readonly CameraDriver[]; +}; diff --git a/src/@types/engine/frame/RunFrameDeps.d.ts b/src/@types/engine/frame/RunFrameDeps.d.ts index 8af2bf1a54..261ec98066 100644 --- a/src/@types/engine/frame/RunFrameDeps.d.ts +++ b/src/@types/engine/frame/RunFrameDeps.d.ts @@ -37,11 +37,10 @@ export type RunFrameDeps = { */ timingService: GpuTimingService; /** - * Camera-control drivers, built once at loop start. The resolver - * (`runCameraDrivers`) picks the single highest-priority active winner - * each frame and is also the source of truth for "is the camera - * animating" (render-on-demand gate). Order in this array is not - * significant — `priority` decides. + * Camera-control drivers (`CAMERA_DRIVERS`, overridable by a fixture). + * `pickWinner` picks the single highest-priority active winner each frame and + * is also the source of truth for "is the camera animating" (render-on-demand + * gate). Order in this array is not significant — `priority` decides. */ readonly drivers: readonly CameraDriver[]; }; diff --git a/src/@types/engine/handles/EngineDebugHandle.d.ts b/src/@types/engine/handles/EngineDebugHandle.d.ts index e97a92481c..feff2f016a 100644 --- a/src/@types/engine/handles/EngineDebugHandle.d.ts +++ b/src/@types/engine/handles/EngineDebugHandle.d.ts @@ -1,88 +1,51 @@ /** - * EngineDebugHandle — the engine's observability sub-handle. + * EngineDebugHandle — the engine's observability sub-handle: debug/inspection + * surfaces the React shell reads. * - * Hosts debug/inspection surfaces the React shell reads. Today's - * inhabitants are `timingService` (GPU timing), `frameStats` (the - * always-on CPU-side fps + JS-frame-time readout), and `passOverrides` - * (read-only pass-name list); further additions (render-stat counters, - * frame-timeline exports) cluster here rather than sprawling across the - * top-level handle. - * - * ### Why a sub-handle for one field - * - * The engine handle is cluster-shaped — every related method lives - * under a topical namespace. Adding `timingService` at the root - * would regress toward a flat surface. A `debug` namespace - * telegraphs intent ("this is dev/debug scaffolding, not a knob") - * and gives future debug additions a natural place to land without - * re-litigating placement each time. - * - * ### Why a getter rather than a copied reference - * - * `state.gpu.timingService` is reassigned by the async `initGpu` - * IIFE that runs AFTER `createEngine` returns (an eager no-op stub - * is replaced with the device-aware service). A copied reference - * captured at handle construction would point at the stub forever. - * The getter reads the live slot every time the React shell asks - * for it. + * Every member is a getter, never a copied reference. The things behind them + * (`timingService`, the asset slots, `earthTiles`, `cameraRuntime`) are + * reassigned or minted by the async bootstrap that runs AFTER `createEngine` + * returns, so a reference captured at handle construction would point at an + * eager stub — or at nothing — forever. */ import type { GpuTimingService } from '../../gpu/timing/GpuTimingService'; import type { FrameStats } from '../FrameStats'; import type { EarthTileDebugSnapshot } from '../../scene/EarthTileDebugSnapshot'; +import type { CameraDebugSnapshot } from '../../camera/CameraDebugSnapshot'; /** - * `passOverrides` — read-only pass-name list for the DebugPanel's - * renderer-toggle section. - * - * `allNames` is materialised from the encoder's HDR + UI pass - * registries in draw order so the React panel can render one - * checkbox per pass without enumerating kebab-case names itself. - * The one-way override semantics (can hide a passing pass, cannot - * force-enable a gated one) are enforced in the encoder loop; - * `selectDisabledPasses` reads the live record back from the store. - * - * Toggle writes are dispatched directly via `setPassDisabled` - * (RTK action) — no write surface lives on this handle. + * Read-only pass-name list for the DebugPanel's renderer-toggle section. + * Toggle writes go to the store via `setPassDisabled`; the one-way override + * semantics (can hide a passing pass, cannot force-enable a gated one) are + * enforced in the encoder loop. */ export type PassOverridesHandle = { - /** Every pass name across HDR + UI registries, in draw order. */ + /** Every pass name across the HDR + UI registries, in draw order. */ readonly allNames: readonly string[]; }; export type EngineDebugHandle = { /** - * The GPU timing service (always non-null). Check `.enabled` - * before subscribing — disabled means either the user didn't - * set `?gpuTimings` or the adapter lacks `timestamp-query`. - * The `DebugPanel`'s `GpuTimingsSection` shows a fallback message - * in either case. + * Always non-null: check `.enabled` before subscribing — disabled means + * either no `?gpuTimings` or an adapter without `timestamp-query`. */ readonly timingService: GpuTimingService; - /** Rolling CPU-side frame stats (fps + JS-body ms + idle), always available — no GPU query. */ + /** Rolling CPU-side frame stats (fps + JS-body ms + idle) — no GPU query. */ readonly frameStats: () => FrameStats; - /** - * Read-only pass-name list for the DebugPanel's renderer-toggle - * section. `allNames` is the source of truth for which passes - * exist; checkbox writes go to the store via `setPassDisabled`. - */ readonly passOverrides: PassOverridesHandle; - /** - * Authored fetch rank per slot name (lower fetches first), derived from - * `ASSET_WIRING` — the panel's ordering key and rank column. - * - * A function, not a snapshot Map, for the same reason `timingService` is a - * getter: slots are minted by the async bootstrap IIFE well after this handle - * is built, so a Map captured at construction would be permanently empty. - */ + /** Authored fetch rank per slot name (lower fetches first), from `ASSET_WIRING`. */ readonly assetPriorities: () => ReadonlyMap; /** - * Earth surface tile atlas residency for the DebugPanel's "Earth tile atlas" - * section — a getter, not a snapshot, for the same reason as `frameStats`: - * `state.subsystems.earthTiles` is minted well after this handle is built, - * and stands down to `null` on destroy. Returns a quiet empty snapshot - * (`engaged: false`) rather than `null` so the panel never needs its own - * absent-subsystem branch. + * Earth surface tile atlas residency. Returns a quiet empty snapshot + * (`engaged: false`) rather than `null` after destroy, so the panel never + * needs its own absent-subsystem branch. */ readonly earthTiles: () => EarthTileDebugSnapshot; + /** + * Camera-pivot readout for the DebugPanel's "Camera" section. A fresh + * derivation off live Resources at call time, never a per-frame write, so an + * unopened panel costs nothing. + */ + readonly cameraDebug: () => CameraDebugSnapshot; }; diff --git a/src/@types/engine/handles/EngineSubsystemHandles.d.ts b/src/@types/engine/handles/EngineSubsystemHandles.d.ts index 22a79c84ef..3c330e7c24 100644 --- a/src/@types/engine/handles/EngineSubsystemHandles.d.ts +++ b/src/@types/engine/handles/EngineSubsystemHandles.d.ts @@ -81,7 +81,7 @@ export type EngineSubsystemHandles = { inputBindings: InputBindings | null; /** * Queue between the orbit-controls gesture recognizer (which only emits) and - * `drainInput`, the frame's one input-apply site. Eager — the recognizer is + * `runFrame`'s replay, the frame's one input-apply site. Eager — the recognizer is * attached in `wireInput`, but `runFrame` drains from its first tick, which * can precede that async phase. */ diff --git a/src/@types/engine/state/CameraRuntime.d.ts b/src/@types/engine/state/CameraRuntime.d.ts index 0f663f6613..dcfa785534 100644 --- a/src/@types/engine/state/CameraRuntime.d.ts +++ b/src/@types/engine/state/CameraRuntime.d.ts @@ -1,96 +1,23 @@ /** - * CameraRuntime — the engine-owned mutable Resources that bridge the timeless - * Redux store intent to the live, per-frame camera computation. - * - * The store (`camera` slice) holds WHAT the camera should do: a committed resting - * pose, an optional tween descriptor, an auto-rotate config, and a drag flag. It - * is deliberately timeless — no wall-clock values live there. - * - * The per-frame produce step needs transient Resources that DO depend on the - * passage of real time and on the precise sequence of frame-produced poses: - * - * `clock` — the `CameraClock` that converts 'this tween descriptor was - * seen before' / 'auto-rotate is active' into elapsed-ms for - * the driver's `pose` function. Mutated exactly once per frame - * by `tweenElapsed` / `autoRotateElapsed`. Lives here, not in - * the store, because it is meaningless outside the running - * engine session — it would survive a serialise/restore as - * stale wall-clock timestamps. - * - * `projection` — the camera's projection geometry (fovYRad, aspect, near, - * far). `aspect` is patched on every canvas resize; - * `assembleOrbitCamera` merges it with any produced pose into - * a full `OrbitCamera` each frame. Lives here rather than in - * the store because it is derived from the DOM canvas and the - * FOV setting at bootstrap, not user camera intent. - * - * `lastPose` — the produced `CameraPose` from the previous frame, wrapped - * in a `{ current }` box so it can be updated in place without - * reconstructing the reference that `wireInput` and the focus - * handlers hold. The commit-on-edge logic reads this to bake - * the last animated pose into `base` exactly once when a - * tween/auto-rotate driver deactivates. The gesture seed also - * reads it to grab the live mid-animation pose rather than the - * (potentially stale) `base`. - * - * `prevActiveId` — the winning driver id from the previous frame, wrapped in a - * `{ current }` box. The commit-on-edge gate compares it to - * the current winner to detect a driver transition exactly once - * per transition frame. - * - * `lastRenderedSimDays` — the sim instant (Julian days) the last frame derived - * its bodies at, boxed for the same in-place-update reason as - * `lastPose`. The pick path pairs it with `lastPose.current` to - * re-derive the pickable bodies at the exact epoch the frame - * drew them, keeping a pick target welded to its on-screen - * sprite. `runFrame` is the SINGLE writer — it writes this once - * per frame beside the body-snapshot prime; no other caller may - * touch it, so a construction-time `deriveBodyStates(CONST_J2000)` - * can never poison the pick epoch (the value-and-place braid the - * old module-level memo accessor carried). - * - * `upBasis` — this frame's resolved UP basis B(t): the steady registry - * basis at rest, or the mid-slerp basis while an - * orientation-frame switch is in flight. (Named for what it - * IS, not the generic `frameBasis` clip-authoring parameter - * used elsewhere for "which basis a clip's world-space - * content decodes through" — a different, wider concept; - * do not conflate the two.) Boxed for the same - * in-place-update reason as `lastPose`, so the saga context and - * `applySceneEffect` (which read it to seed a switch's `fromQuat`) - * share the live reference. `runFrame` resolves it ONCE per frame - * and is its SINGLE writer — the one place that answers 'which - * way is up this frame' for every reader. - * - * Constructed in `engine.ts` alongside `frameRef`, this bag is the single source - * of truth for every one of them: `wireInput`, `startLoop`, `runFrame`, and the - * focus handlers all read from `state.cameraRuntime`, so there is no duplication - * and no 'which copy is live?' ambiguity. + * CameraRuntime — the camera's live half: everything that depends on wall-clock + * time or on the sequence of produced poses, which is why it is not in the + * timeless Redux `camera` slice. `seedCameraRuntime` is the only constructor, + * `runFrame` the only writer (once per frame, through `stepCameraRuntime`). */ -import type { CameraClock } from '../camera/CameraClock'; -import type { CameraProjection } from '../../camera/CameraProjection'; -import type { CameraPose } from '../../camera/CameraPose'; -import type { Mat3 } from '../../math/Mat3'; +import type { CameraEpochs } from '../camera/CameraEpochs'; +import type { DriverId } from '../camera/DriverId'; +import type { FollowMemory } from '../camera/FollowMemory'; +import type { FramedCameraPose } from '../../camera/FramedCameraPose'; +import type { SurfaceMemory } from '../../camera/SurfaceMemory'; +import type { FrameOutputs } from './FrameOutputs'; export type CameraRuntime = { - /** The animation clock — mutated by tweenElapsed / autoRotateElapsed once per frame. */ - clock: CameraClock; - /** Live projection config; aspect patched on each canvas resize. */ - projection: CameraProjection; - /** Last produced pose; boxed so wireInput and focus handlers share the live reference. */ - lastPose: { current: CameraPose }; - /** Winning driver id from the previous frame; boxed for the same reason. */ - prevActiveId: { current: string }; - /** - * Sim instant (Julian days) the last frame derived its bodies at; boxed so the - * pick path reads the live value. Single-writer: only `runFrame` writes it. - */ - lastRenderedSimDays: { current: number }; - /** - * This frame's resolved orientation basis B(t); boxed so both switch surfaces - * (saga context + `applySceneEffect`) read the live value. Single-writer: only - * `runFrame` writes it, once per frame from `resolveFrameBasis`. - */ - upBasis: { current: Mat3 }; + /** The AUTHORED pose (pre-projection — a projected one walks ~8,500 km/frame, + * R12b-1) and the driver id that wrote it. */ + readonly register: { readonly pose: FramedCameraPose; readonly winner: DriverId }; + readonly epochs: CameraEpochs; + readonly follow: FollowMemory | null; + readonly surface: SurfaceMemory; + readonly outputs: FrameOutputs; }; diff --git a/src/@types/engine/state/EngineState.d.ts b/src/@types/engine/state/EngineState.d.ts index e3362d265f..c3c79aa649 100644 --- a/src/@types/engine/state/EngineState.d.ts +++ b/src/@types/engine/state/EngineState.d.ts @@ -1,75 +1,10 @@ /** - * EngineState — the canonical shape of every mutable runtime value the - * engine owns. - * - * ### Why this type exists - * - * Phases 1–3 of the engine.ts refactor pulled per-frame GPU dispatch, - * pointer/keyboard input wiring, click resolution, the thumbnail - * subsystem, and the camera-tween facade out into siblings - * under `src/services/engine/`. Each extraction shrank `frame()` and - * the public-handle setters to thin orchestrators — but the *opening* - * of `createEngine` still declared ~30 individual `let` bindings: - * settings, bias thresholds, source visibility, picking flags, GPU - * pipeline handles, subsystem handles, the camera, the framing - * snapshot, and a handful of in-flight signals. - * - * Reading any one of those bindings was easy. Answering "what state - * does the engine own?" was hard — the bindings were scattered down - * 250+ lines of header comments and weren't grouped by concern. A - * fresh reader (or a future Claude session) had to scroll the whole - * preamble to learn the answer. - * - * Consolidating the bindings into a single `EngineState` value, with - * sub-bags organised by concern, gives the engine one obvious answer. - * The mental model becomes: - * - * - `state.settings` — the appearance knobs the SettingsPanel surfaces. - * - `state.tier` — the data-resolution tier (its own root slice). - * - `state.data` — per-type data stores (galaxies, structures, …). - * - `state.picking` — hover / click / drag mutables. - * - `state.gpu` — pipelines / textures allocated lazily. - * - `state.subsystems` — owned long-lived helpers. - * - `state.cam` — the orbit camera (null until first cloud). - * - * ### Why a single `const` instead of a class? - * - * The engine is a singleton: one canvas → one `createEngine` call → - * one closure. A class would gain only a `this.*` access pattern and - * lose the clarity that the *outer* binding is immutable while every - * *inner* field is mutated in place. We use `const state: EngineState - * = { ... }` so the closure cannot accidentally rebind the whole bag, - * but `state.settings.brightness = 1.5` is still a one-liner. - * - * ### Why mutable in place rather than an immutable redux-style store? - * - * Per-frame writes happen in the rAF loop and the public-handle - * setters fire several times per user interaction. An immutable - * setter (`state = { ...state, settings: { ...state.settings, brightness } }`) - * would allocate two intermediate objects per slider drag — fine - * for a React form, wasteful inside a 60 fps render loop. Mutation - * in place keeps allocations off the hot path and matches how the - * subsystem facades (e.g. the impostor planners) already manage - * their own internal state. - * - * ### What this type is NOT - * - * - It does not own any *behaviour*. It only declares the shape. The - * factory that builds an `EngineState` value lives in `engine.ts`'s - * closure because it needs the `device` / `canvas` / callbacks that - * the engine receives. - * - It does not list every transient closure variable. Helpers like - * `lastScaleSig`, `detachControls`, and `cssToTexPx` stay as plain - * bindings — they're either single-use or scoped to one helper, not - * part of the engine's runtime state surface. - * - It does not capture *initial values*. Defaults live in - * `data/defaults.ts`; the consumer constructs an `EngineState` by - * pulling those constants into the right sub-bag. - * - * The sub-bag types live in their own `.d.ts` siblings — one type per - * file matches the rest of the `@types/` convention and lets each bag - * carry its own multi-paragraph rationale without bloating the root - * type's docstring. + * EngineState — every mutable runtime value the engine owns, grouped by + * concern. Shape only, no behaviour: the factory lives in `engine.ts`'s closure + * (it needs the device / canvas / callbacks), and initial values come from + * `data/defaults.ts`. Fields are mutated IN PLACE — per-frame writes and the + * handle setters run inside the rAF loop, where an immutable spread would + * allocate two objects per slider drag. */ import type { Tier } from '../../data/Tier'; @@ -79,7 +14,6 @@ import type { EnginePickingState } from './EnginePickingState'; import type { EngineAssetSlots } from './EngineAssetSlots'; import type { EngineGpuHandles } from '../handles/EngineGpuHandles'; import type { EngineSubsystemHandles } from '../handles/EngineSubsystemHandles'; -import type { createOrbitCamera } from '../../../utils/camera/createOrbitCamera'; import type { RequestKey } from '../../loading/RequestKey'; import type { CameraRuntime } from './CameraRuntime'; import type { SkyCubemapCaptureRuntime } from './SkyCubemapCaptureRuntime'; @@ -89,52 +23,25 @@ import type { FamousGalaxyMetaEntry } from '../../loading/FamousGalaxyMetaEntry' export type EngineState = { settings: EngineSettingsState; - /** - * The live data-resolution tier. A getter delegating to the injected store - * (`store.getState().tier`), mirroring the `settings` delegation above — - * reads hand back the authoritative value with no parallel mirror to drift. - * The tier saga owns the write (it dispatches the `tier` slice action); the - * engine reads here. - */ + /** A getter onto `store.getState().tier` — no engine-side mirror to drift. */ tier: Tier; - /** - * The selection identity Intent (hover/select/focus refs). A getter - * delegating to the injected store (`store.getState().selection`), like - * `settings`/`tier` — the store is the single home, no engine-side mirror. - * The pick path dispatches the writes; the engine reads here. - */ + /** A getter onto `store.getState().selection`; the pick path dispatches the writes. */ selection: SelectionState; - /** - * The saga-reconciled selection display rows. A getter delegating to - * `store.getState().selectionRows`; the per-frame selection-ring + structure - * focus readers read this. - */ + /** A getter onto `store.getState().selectionRows` — the saga-reconciled display rows. */ selectionRows: SelectionRowsState; - /** - * The famous-galaxy metadata sidecar. A getter delegating to - * `store.getState().engine.meta.famousGalaxies`; the asset slot is the sole - * writer, the engine reads here. - */ + /** A getter onto `store.getState().engine.meta.famousGalaxies`. */ readonly famousGalaxiesMeta: readonly FamousGalaxyMetaEntry[]; - /** - * Per-type data stores — the authoritative app-side home for each - * data type (galaxies, structures, volumes, filaments). Slot commits - * write; producers / UI / pick / camera read. See `EngineData`. - */ + /** Per-type data stores — the authoritative app-side home for each. See `EngineData`. */ data: EngineData; picking: EnginePickingState; gpu: EngineGpuHandles; subsystems: EngineSubsystemHandles; - cam: ReturnType | null; + /** True once `wireInput` seeded the first real camera pose. The gate every + * pre-bootstrap bail reads; there is no boot camera object to read from. */ + booted: boolean; /** - * Live camera Resources: the animation clock, the projection config, and - * the commit-on-edge bookkeeping (lastPose + prevActiveId). Constructed in - * `engine.ts` alongside `frameRef` and seeded with placeholders; - * `wireInput`'s bootstrap seed fills real values once the initial camera - * exists. All three callers that need these Resources — `wireInput` - * (gesture seed + focus `from`), `startLoop` (RunFrameDeps), and `runFrame` - * (produce + commit-on-edge) — read from this single bag, eliminating the - * 'which copy is live?' ambiguity. + * Live camera Resources, seeded with placeholders in `engine.ts` and filled + * by `wireInput`'s bootstrap seed once the initial camera exists. */ cameraRuntime: CameraRuntime; /** @@ -146,12 +53,9 @@ export type EngineState = { assetSlots: EngineAssetSlots; /** * One-shot transient request flags read by demand predicates via - * `DemandCtx.request(k)`. A `Set` rather than a field on - * `sources` because these are edge-triggered UI events (palette opened, - * lazy alias requested) with no persistent settings or loaded-data home. - * A flag is set and left set; the demand loop's idle-guard keeps the - * triggered slot from re-fetching, so no clear-on-ready is needed. Empty - * until the first such event fires. + * `DemandCtx.request(k)`. A flag is set and left set: the demand loop's + * idle-guard keeps the triggered slot from re-fetching, so no clear-on-ready + * is needed. */ requests: Set; }; diff --git a/src/@types/engine/state/FrameOutputs.d.ts b/src/@types/engine/state/FrameOutputs.d.ts new file mode 100644 index 0000000000..3d776527fc --- /dev/null +++ b/src/@types/engine/state/FrameOutputs.d.ts @@ -0,0 +1,20 @@ +/** + * FrameOutputs — what the last frame DREW, stored rather than re-derived so a + * between-frame reader (pick, demand, debug) agrees with it: a resize or a + * sim-clock advance must not retro-change the aspect or the epoch a pick + * resolves against. `displayed` = register + render-side tilt; `upBasis` = the + * live B(t). `simDays` (Julian days) is the instant the pick path reads — NOT + * `deriveBodyStates`' memo key, which a between-frames + * `deriveBodyStates(CONST_J2000)` can repoint under it. + */ + +import type { CameraProjection } from '../../camera/CameraProjection'; +import type { FramedCameraPose } from '../../camera/FramedCameraPose'; +import type { Mat3 } from '../../math/Mat3'; + +export type FrameOutputs = { + readonly displayed: FramedCameraPose; + readonly simDays: number; + readonly upBasis: Mat3; + readonly projection: CameraProjection; +}; diff --git a/src/@types/engine/subsystems/ClipPlayer.d.ts b/src/@types/engine/subsystems/ClipPlayer.d.ts index 8d8313477f..ca555fc941 100644 --- a/src/@types/engine/subsystems/ClipPlayer.d.ts +++ b/src/@types/engine/subsystems/ClipPlayer.d.ts @@ -1,76 +1,31 @@ /** - * ClipPlayer — the side-effecting Resource that drives the clip's scene cues, - * owns the `clipOpacity` channel, and manages clip-completion lifecycle. - * - * ### Role in the animation architecture - * - * `clipPlayer` is a tick-phase Resource, NOT a camera driver. It fires edge- - * triggered scene cues (fade / show / hide / scene / focus) as elapsed time - * crosses each cue's `atSec` boundary. The camera pose comes from the clip - * DRIVER (Task 9 — `evaluateClip`) running in the driver table. `clipPlayer` - * handles the side-effects the pure evaluator cannot: opacity fades, visibility - * toggles, settings dispatches, selection focus, and clip-end bookkeeping. - * - * ### Eager (no GPU dep) - * - * Constructed alongside `structureFocus` in the subsystems literal — before - * any GPU init. No GPU resources, so it is non-null from t=0 and never - * requires the `| null` guard that the GPU-owned subsystems carry. - * - * ### Destroyable guard - * - * `EngineSubsystemHandles._EnforceDestroyable` requires every subsystem to - * satisfy `Destroyable` (has `destroy(): void`). `clipPlayer.destroy()` resets - * the `clipOpacity` channel and clears internal bookkeeping — no GPU to free, - * but the contract must be met. + * ClipPlayer — the tick-phase Resource that fires a clip's scene cues, owns + * the `clipOpacity` channel and runs clip-completion lifecycle. NOT a camera + * driver: the pose comes from the pure clip driver. Eager (no GPU dep), so + * it is non-null from t=0. */ import type { VisibilityLayerKey } from '../../animation/VisibilityLayerKey'; +import type { CameraEpochs } from '../camera/CameraEpochs'; import type { Destroyable } from '../../rendering/Destroyable'; export type ClipPlayer = { /** - * Per-frame tick. Call BEFORE the camera produce step (Task 12 contract): - * reads the store for the active clip, fires any due scene cues, advances - * the clipOpacity channel, and records clip-end for the two-frame deferred - * completion (see implementation module for the ordering rationale). + * runFrame's FIRST statement, by contract: a cue's `frameTween` must be in + * the store before the frame derives its basis. Advances `clipEpoch` against + * the live `camera.clip`; the frame threads the result into `advanceEpochs`. */ - tick(nowMs: number): void; + tick( + clipEpoch: CameraEpochs['clip'], + nowMs: number, + ): { readonly clipEpoch: CameraEpochs['clip'] }; - /** - * Stop the active clip immediately: dispatch `endClip()` and reset all - * internal state (cue cursor, clipOpacity, compile cache). Called when the - * engine needs to abort a clip without waiting for natural completion — - * e.g. a `[CANCEL]` hook on the `playClip` Promise pre-empts the tour. - * - * `stop()` fires the end-resolver (if registered) AFTER dispatching - * `endClip()` — the same edge `playClip` uses for natural completion. This - * ensures cancellation RESOLVES the Promise rather than rejecting it. - */ + /** Dispatches `clipEnded()` and fires the end-resolver — cancellation RESOLVES `playClip`. */ stop(): void; - /** - * Register a one-shot callback to invoke when this clip ends, either by - * natural completion (two-frame deferred `endClip` dispatch in `tick`) or - * by `stop()`. The callback is fired exactly once and then cleared, so a - * second call to `registerEndResolver` can register for the NEXT clip. - * - * Used by `createPlayClip` to resolve the Promise it returns: when the - * callback fires the Promise settles, and `yield* call(playClip, clip, frame)` - * in a saga resumes. Registering BEFORE dispatching `startClip` avoids the - * narrow race where the clip completes on the same JS microtask. - */ + /** Register BEFORE `clipStarted`, or a zero-duration clip completes with no resolver. */ registerEndResolver(onEnd: () => void): void; - /** - * The clip-owned transient opacity factor for `layer` at `nowMs`. - * - * Delegates to the internal `clipOpacity` channel's `factorOf`. Returns 1 - * for any layer the current clip has never touched (the 'unchanged' default). - * Resets to 1 when the clip ends (both natural completion and `stop()`). - * - * The renderer composites this into final alpha: - * `finalAlpha = registryFactor × intentBridgeFactor × clipOpacityOf(layer, now)` - */ + /** 1 for any layer the current clip never touched; back to 1 when the clip ends. */ clipOpacityOf(layer: VisibilityLayerKey, nowMs: number): number; } & Destroyable; diff --git a/src/@types/loading/DemandCtx.d.ts b/src/@types/loading/DemandCtx.d.ts index 14d13c1ba1..3797474ee6 100644 --- a/src/@types/loading/DemandCtx.d.ts +++ b/src/@types/loading/DemandCtx.d.ts @@ -56,7 +56,7 @@ * and releases it on retreat, both measured as `distanceMpc(cameraPosMpc, * bodyPos)`. It reads the LAST produced pose because `reevaluateDemand` runs * at the frame top, before this frame's camera is derived; the boxed - * `lastPose` is the live cross-driver position (wheel-zoom, tour clips, and + * `cameraRuntime.register.pose` is the live cross-driver position (wheel-zoom, tour clips, and * the fly-to-Earth tween all converge to `CameraPose`), and a * one-frame-stale position is immaterial for a multi-frame async fetch. * @@ -102,7 +102,7 @@ export type DemandCtx = { cameraPosMpc: Readonly; /** * The sim instant (Julian days) the last frame derived its bodies at — the - * clock's live position, read from `cameraRuntime.lastRenderedSimDays`. The + * clock's live position, read from `cameraRuntime.outputs.simDays`. The * proximity gate needs it because a host body MOVES: its world position is * `deriveBodyStates(simDays)`, not a fixed epoch, so the body-texture family's * `distanceMpc(cameraPosMpc, bodyPos)` must measure against where the body sits diff --git a/src/@types/settings/EngineSettingsState.d.ts b/src/@types/settings/EngineSettingsState.d.ts index 353b244ef9..9a7232824e 100644 --- a/src/@types/settings/EngineSettingsState.d.ts +++ b/src/@types/settings/EngineSettingsState.d.ts @@ -81,7 +81,7 @@ export type EngineSettingsState = { /** * Camera lens control — the vertical field of view in degrees. The engine - * converts this to radians and writes it onto `cameraRuntime.projection.fovYRad` + * converts this to radians and writes it onto `cameraRuntime.outputs.projection.fovYRad` * once per frame (`runFrame`), which every downstream consumer (view-proj, * screen-space pixel math, the WGSL camera uniform) already reads live off * the projection Resource. Default `DEFAULT_FOV_DEG` (60°). diff --git a/src/components/DebugPanel/CameraBandBar.module.css b/src/components/DebugPanel/CameraBandBar.module.css new file mode 100644 index 0000000000..45eadb7bc6 --- /dev/null +++ b/src/components/DebugPanel/CameraBandBar.module.css @@ -0,0 +1,65 @@ +/* + * CameraBandBar — a percent-positioned strip. The root reserves the vertical + * room the absolutely-positioned marker and the two staggered label rows need; + * without it they overlap the sliders below. + */ + +.root { + position: relative; + height: 44px; + margin: 14px 0 2px; + font-family: var(--font-family-mono); + font-size: 9px; +} + +.track { + position: absolute; + top: 14px; + left: 4px; + right: 4px; + height: 3px; + background: rgba(255, 255, 255, 0.18); + border-radius: 2px; +} + +.tickLow, +.tickHigh { + position: absolute; + top: -3px; + width: 1px; + height: 9px; + background: rgba(255, 255, 255, 0.55); +} + +.tickLabel { + position: absolute; + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + opacity: 0.7; +} + +.tickLow .tickLabel { + top: 11px; +} + +.tickHigh .tickLabel { + top: 22px; +} + +.marker { + position: absolute; + top: -6px; + width: 1px; + height: 15px; + background: #cfc; +} + +.markerLabel { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + color: #cfc; +} diff --git a/src/components/DebugPanel/CameraBandBar.tsx b/src/components/DebugPanel/CameraBandBar.tsx new file mode 100644 index 0000000000..61af43f762 --- /dev/null +++ b/src/components/DebugPanel/CameraBandBar.tsx @@ -0,0 +1,69 @@ +/** + * CameraBandBar — the regime band as one drawn object: a log-scale strip with + * the four tunable edges as ticks and the camera's own h/R as a marker. Log, + * because zoom is multiplicative — the same reason `CameraTuning.blendSpace` + * defaults there. The domain is pinned to the ticks (not to the marker) so the + * edges hold still while the camera flies; a marker outside it clamps to the + * end and points off-scale. Percent offsets, not SVG: text in a stretched + * viewBox distorts. + */ + +import type { ReactElement } from 'react'; +import styles from './CameraBandBar.module.css'; + +export type BandTick = { + readonly label: string; + readonly hOverR: number; +}; + +export type CameraBandBarProps = { + /** The band edges, any order — the bar sorts them for placement and stagger. */ + readonly ticks: readonly BandTick[]; + readonly hOverR: number | null; + /** Pre-formatted marker caption; the bar does no unit maths. */ + readonly markerLabel: string; +}; + +/** Two decades of headroom either side keeps every default edge well inboard. */ +const DOMAIN_PAD = 4; + +function percentOf(value: number, minHR: number, maxHR: number): number { + const span = Math.log(maxHR) - Math.log(minHR); + return ((Math.log(value) - Math.log(minHR)) / span) * 100; +} + +function CameraBandBar({ ticks, hOverR, markerLabel }: CameraBandBarProps): ReactElement { + const sorted = [...ticks].sort((a, b) => a.hOverR - b.hOverR); + const minHR = sorted[0]!.hOverR / DOMAIN_PAD; + const maxHR = sorted[sorted.length - 1]!.hOverR * DOMAIN_PAD; + + const raw = hOverR === null || hOverR <= 0 ? null : percentOf(hOverR, minHR, maxHR); + const clamped = raw === null ? null : Math.max(0, Math.min(100, raw)); + const offScale = raw === null ? '' : raw < 0 ? '‹ ' : raw > 100 ? '› ' : ''; + + return ( +
+
+ {sorted.map((tick, i) => ( +
+ {tick.label} +
+ ))} + {clamped === null ? null : ( +
+ + {offScale} + {markerLabel} + +
+ )} +
+
+ ); +} + +export default CameraBandBar; diff --git a/src/components/DebugPanel/CameraStateSection.module.css b/src/components/DebugPanel/CameraStateSection.module.css new file mode 100644 index 0000000000..c67dbb7267 --- /dev/null +++ b/src/components/DebugPanel/CameraStateSection.module.css @@ -0,0 +1,90 @@ +/* + * CameraStateSection — a six-column DOF table over the panel's mono face, plus + * the key/value grid the collapsed `raw` block reuses. `.dofRow` and `.row` are + * `display: contents` so their cells join the parent grid and the columns line + * up across rows; opacity therefore lives on the grid, which has a box. + */ + +.headerLine { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-family-mono); + font-size: 11px; +} + +.badge { + color: #f66; + font-weight: bold; +} + +.dofGrid { + display: grid; + grid-template-columns: max-content repeat(5, 1fr); + column-gap: 8px; + margin-top: 6px; + font-family: var(--font-family-mono); + font-size: 11px; + opacity: 0.85; + text-align: right; +} + +.colHead { + opacity: 0.6; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.dofRow { + display: contents; +} + +.dofName { + text-align: left; + opacity: 0.75; +} + +.offMark { + opacity: 0.55; +} + +.grid { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 10px; + font-family: var(--font-family-mono); + font-size: 11px; + opacity: 0.85; +} + +.row { + display: contents; +} + +.key { + opacity: 0.75; +} + +.rawBlock { + margin-top: 6px; +} + +.rawSummary { + composes: summary from './debugSection.module.css'; + font-family: var(--font-family-mono); + font-size: 10px; + opacity: 0.7; +} + +.smallButton { + composes: button from './debugSection.module.css'; + align-self: flex-end; + font-size: 10px; + padding: 0 6px; +} + +.copyButton { + composes: button from './debugSection.module.css'; + align-self: flex-start; +} diff --git a/src/components/DebugPanel/CameraStateSection.tsx b/src/components/DebugPanel/CameraStateSection.tsx new file mode 100644 index 0000000000..baa6c22ad9 --- /dev/null +++ b/src/components/DebugPanel/CameraStateSection.tsx @@ -0,0 +1,237 @@ +// src/components/DebugPanel/CameraStateSection.tsx +/** + * CameraStateSection — the camera-pivot readout, organised by the question it + * answers (grill 2026-09-10): who is driving (header), is each DOF where it + * should be (three rows), and where in the band are we (the drawn ruler). One + * model feeds the degrees on screen AND the full-precision radians `copy all` + * dumps, so a pasted bug report is the thing that was looked at. Polls at 4 Hz + * like every textual DebugPanel readout — Δ and peak are measured in the frame + * loop, so the poll rate cannot blur them. + */ + +import { useEffect, useState, type ReactElement } from 'react'; +import type { CameraDebugSnapshot } from '../../@types/camera/CameraDebugSnapshot'; +import type { CameraDofRow } from '../../@types/camera/CameraDofRow'; +import type { OrientDofDelta } from '../../@types/camera/OrientDofDelta'; +import type { CameraTuning } from '../../@types/camera/CameraTuning'; +import type { PoseFrame } from '../../@types/camera/PoseFrame'; +import { clearOrientPeaks, watchOrientDeltas } from '../../services/engine/camera/orientDeltas'; +import { selectCameraTuning } from '../../state/camera/selectors'; +import { useAppSelector } from '../../store/hooks'; +import DebugSection from './DebugSection'; +import OrientationTuning from './OrientationTuning'; +import styles from './CameraStateSection.module.css'; + +export type CameraStateSectionProps = { + cameraDebug: () => CameraDebugSnapshot; +}; + +const POLL_MS = 250; +const RAD_TO_DEG = 180 / Math.PI; + +type DofModel = { + readonly name: string; + /** North-up unchecked: the target is still a field property, nothing applies it. */ + readonly off: boolean; + readonly row: CameraDofRow; + readonly delta: OrientDofDelta; +}; +type RawRow = { readonly key: string; readonly value: string }; +type PanelModel = { + readonly header: string; + readonly badge: string | null; + readonly dofs: readonly DofModel[]; + /** Radians/raw for the dump; the `*Readout` strings are the same band on screen. */ + readonly band: readonly RawRow[]; + readonly markerReadout: string; + readonly weightReadout: string; + readonly rememberedTiltReadout: string; + readonly raw: readonly RawRow[]; +}; + +function frameLabel(frame: PoseFrame): string { + return frame === 'absolute' ? 'absolute' : `body:${frame.body}`; +} + +/** Full JS precision (shortest round-trip form); em-dash for absent values. */ +function num(n: number | null | undefined): string { + return n === null || n === undefined ? '—' : String(n); +} + +function deg(rad: number | null): string { + return rad === null ? '—' : `${(rad * RAD_TO_DEG).toFixed(1)}°`; +} + +function modelOf(snap: CameraDebugSnapshot, tuning: CameraTuning): PanelModel { + const { dofs, deltas } = snap; + const off = !tuning.northUp; + return { + header: `${frameLabel(snap.renderedFrame)} · ${snap.activeDriverId} · gesture: ${snap.gestureMode ?? 'none'}`, + badge: snap.armMismatch ? 'ARM MISMATCH' : snap.epochMismatch ? 'EPOCH MISMATCH' : null, + dofs: [ + { name: 'heading', off, row: dofs.heading, delta: deltas.heading }, + { name: 'tilt', off: false, row: dofs.tilt, delta: deltas.tilt }, + { name: 'roll', off, row: dofs.roll, delta: deltas.roll }, + ], + band: [ + { key: 'h_over_R', value: num(snap.hOverR) }, + { key: 'altitude_m', value: num(snap.altitudeM) }, + { key: 'band_up_weight', value: num(snap.bandUpWeight) }, + { key: 'engage/disengage_hr', value: `${tuning.engageHR} / ${tuning.disengageHR}` }, + { key: 'tilt_full/zero_hr', value: `${tuning.tiltFullHR} / ${tuning.tiltZeroHR}` }, + { key: 'blend_space', value: tuning.blendSpace }, + { key: 'north_up', value: String(tuning.northUp) }, + { key: 'remembered_tilt_rad', value: num(snap.rememberedTiltRad) }, + ], + markerReadout: + snap.hOverR === null + ? '—' + : `h/R ${snap.hOverR.toFixed(3)} · ${ + snap.altitudeM === null + ? '—' + : `${Math.round(snap.altitudeM).toLocaleString('en-US')} m` + }`, + weightReadout: snap.bandUpWeight === null ? '—' : snap.bandUpWeight.toFixed(3), + rememberedTiltReadout: deg(snap.rememberedTiltRad), + raw: [ + { key: 'stored_regime', value: frameLabel(snap.storedFrame) }, + { key: 'rendered_arm', value: frameLabel(snap.renderedFrame) }, + { key: 'scene_frame', value: snap.orientationFrame }, + { key: 'distance_mpc', value: num(snap.distanceMpc) }, + { + key: 'gesture_cursor_hit', + value: snap.gestureCursorHit === null ? '—' : String(snap.gestureCursorHit), + }, + { + key: 'anchor_local_m', + value: snap.anchorLocalM === null ? '—' : `[${snap.anchorLocalM.map(String).join(', ')}]`, + }, + { key: 'eye_rel_anchor_m', value: num(snap.eyeRelAnchorMagM) }, + { key: 'rendered_sim_days', value: num(snap.lastRenderedSimDays) }, + { key: 'live_sim_days', value: num(snap.liveSimDays) }, + { key: 'delta_s', value: String(snap.epochDeltaDays * 86_400) }, + ], + }; +} + +/** Radians at full precision — the paste target, whatever the screen shows. */ +function copyTextOf(model: PanelModel): string { + const lines = ['camera-debug (rad = radians, m = metres, mpc = megaparsec)']; + lines.push(`[header] ${model.header}${model.badge === null ? '' : ` ⚠ ${model.badge}`}`); + lines.push('[dof, radians]'); + for (const dof of model.dofs) { + lines.push( + `${dof.name}: current=${num(dof.row.currentRad)} target=${num(dof.row.targetRad)} ` + + `residual=${num(dof.row.residualRad)} delta=${num(dof.delta.deltaRad)} ` + + `peak=${num(dof.delta.peakAbsRad)}` + + (dof.off ? ' (north-up off)' : ''), + ); + } + for (const [title, rows] of [ + ['band', model.band], + ['raw', model.raw], + ] as const) { + lines.push(`[${title}]`); + for (const row of rows) lines.push(`${row.key}: ${row.value}`); + } + return lines.join('\n'); +} + +function CameraStateSection({ cameraDebug }: CameraStateSectionProps): ReactElement { + const [snap, setSnap] = useState(cameraDebug); + const [copied, setCopied] = useState(false); + // The ONE reader of the live tuning: two readers at two rates (here and the + // 4 Hz snapshot) would be a 250 ms mirror of the value the sliders write. + const tuning = useAppSelector(selectCameraTuning); + + useEffect(() => { + // This mount is what turns the frame loop's Δ/peak record on. + const stop = watchOrientDeltas(); + const id = setInterval(() => setSnap(cameraDebug()), POLL_MS); + return () => { + clearInterval(id); + stop(); + }; + }, [cameraDebug]); + + const model = modelOf(snap, tuning); + + return ( + +
+ {model.header} + {model.badge === null ? null : ⚠ {model.badge}} +
+ +
+ + current + target + residual + Δ + peak + {model.dofs.map((dof) => ( +
+ + {dof.name} + {dof.off ? (off) : null} + + {deg(dof.row.currentRad)} + {deg(dof.row.targetRad)} + {deg(dof.row.residualRad)} + {deg(dof.delta.deltaRad)} + {deg(dof.delta.peakAbsRad)} +
+ ))} +
+ + + + +
+ raw +
+ {model.raw.map((row) => ( +
+ {row.key} + {row.value} +
+ ))} +
+
+ + +
+ ); +} + +export default CameraStateSection; diff --git a/src/components/DebugPanel/DebugPanel.tsx b/src/components/DebugPanel/DebugPanel.tsx index 66ccba51ab..01c45baffd 100644 --- a/src/components/DebugPanel/DebugPanel.tsx +++ b/src/components/DebugPanel/DebugPanel.tsx @@ -1,43 +1,11 @@ /** - * DebugPanel — the umbrella for the dev panel. + * DebugPanel — the umbrella for the dev panel, mounted by `App.tsx` on the `d` + * shortcut. Every section that touches the store owns its own container, so this + * component takes only the engine-handle props App reads off `handleRef`, and + * section-level visibility is each section's own concern. * - * Sections: `AssetLoadingSection` (slot-progress rows), - * `GpuTimingsSection` (per-pass GPU timing live readout), - * `RenderTogglesSectionContainer` (per-pass on/off checkboxes for visual - * debugging), `FlowTuningSectionContainer`, `MilkyWayTuningSectionContainer` - * (the Milky-Way star cloud's look knobs), - * `ZoneOfAvoidanceTuningSectionContainer` (the galactic-plane guide band's - * look knobs), `SgrAStarLensingTuningSectionContainer` (the Sgr A* lens - * pass's tuning knobs), `DebugOverlaysSectionContainer` - * (pick-buffer / disk-radius-ring toggles), `EarthTileAtlasSectionContainer` - * (textual atlas-residency readout — slot pressure, per-level resident/pending - * counts, last plan shape), `GalaxyProvenanceSectionContainer` - * (a per-axis table of missing / highlight / show controls over measured-vs- - * estimated tallies), and `ClipTriggersSectionContainer` (play/stop a registered clip - * + launch a guided tour) / `ClipPathInspectorSectionContainer` (precompute + - * scrub a clip's debug camera path) — every section that touches the store - * owns its own container, so DebugPanel itself receives only the engine-handle - * props (`slots`, `timingService`, `frameStats`, `passNames`, `engineHandleRef`) - * that App reads off `handleRef`. Mount is owned by `App.tsx` (toggled by the `d` keyboard - * shortcut); when this component renders, all sections always render — - * section-level visibility (e.g. "GPU timings unavailable") is each section's - * own concern. - * - * `memo` is load-bearing here: this is App's memo boundary for the panel, so - * an unrelated App re-render doesn't cascade into every section's store reads. - * - * ### Why collapsible sections - * - * The asset-loading rows churn during startup (every catalog, - * filaments, the font atlas, etc.), but go quiet once everything - * is `ready` — a collapsed `
` keeps the panel compact - * during steady-state runs. GPU timings is the opposite (always - * live), but the user might want to focus on one or the other. - * `RenderTogglesSection`, `DebugOverlaysSection`, and - * `GalaxyProvenanceSection` all default to closed (most sessions won't - * need to flip a renderer off, a raw overlay, or audit orientation/size - * provenance); the other two default to open because their data - * is the primary reason for opening the panel. + * `memo` is load-bearing: this is App's memo boundary for the panel, so an + * unrelated App re-render doesn't cascade into every section's store reads. */ import { memo } from 'react'; @@ -50,6 +18,7 @@ import AssetLoadingSection from './AssetLoadingSection'; import { FrameStatsRow } from './FrameStatsRow'; import { GpuTimingsSection } from './GpuTimingsSection'; import EarthTileAtlasSectionContainer from '../containers/EarthTileAtlasSectionContainer'; +import CameraStateSectionContainer from '../containers/CameraStateSectionContainer'; import RenderTogglesSectionContainer from '../containers/RenderTogglesSectionContainer'; import FlowTuningSectionContainer from '../containers/FlowTuningSectionContainer'; import MilkyWayTuningSectionContainer from '../containers/MilkyWayTuningSectionContainer'; @@ -94,6 +63,7 @@ function DebugPanel({ GPU timings section, which is dark without `?gpuTimings`. */} + diff --git a/src/components/DebugPanel/OrientationTuning.module.css b/src/components/DebugPanel/OrientationTuning.module.css new file mode 100644 index 0000000000..0b107a8d3b --- /dev/null +++ b/src/components/DebugPanel/OrientationTuning.module.css @@ -0,0 +1,24 @@ +.root { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; +} + +.readoutRow { + display: flex; + gap: 10px; + font-family: var(--font-family-mono); + font-size: 11px; + opacity: 0.85; +} + +.toggle { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-family-mono); + font-size: 11px; + opacity: 0.85; + cursor: pointer; +} diff --git a/src/components/DebugPanel/OrientationTuning.tsx b/src/components/DebugPanel/OrientationTuning.tsx new file mode 100644 index 0000000000..244579d3c2 --- /dev/null +++ b/src/components/DebugPanel/OrientationTuning.tsx @@ -0,0 +1,136 @@ +/** + * OrientationTuning — the band as one visual object (grill Q5): the drawn + * ruler, then the four edge sliders directly under it, so dragging an edge + * moves its own tick instead of a number in a different block. Every control + * dispatches `setCameraTuning`; the live record arrives as a prop from the one + * reader (`CameraStateSection`), so a clamp that moved the OTHER knob shows on + * the next render. + */ + +import type { ReactNode } from 'react'; + +import type { CameraTuning } from '../../@types/camera/CameraTuning'; +import { CAMERA_TUNING_LIMITS } from '../../data/camera/cameraTuning'; +import { setCameraTuning } from '../../state/camera/cameraSlice'; +import { useAppDispatch } from '../../store/hooks'; +import CameraBandBar from './CameraBandBar'; +import DebugSlider from './DebugSlider'; +import styles from './OrientationTuning.module.css'; + +export type OrientationTuningProps = { + readonly tuning: CameraTuning; + /** Where the camera sits on the ruler; null when no scene body resolved. */ + readonly hOverR: number | null; + /** + * Marker caption, band-weight readout, and the session's remembered tilt + * (ruling 12). Pre-formatted by the caller, the same contract as + * DebugSlider's `readout` — one formatting home per panel. + */ + readonly markerReadout: string; + readonly weightReadout: string; + readonly rememberedTiltReadout: string; +}; + +/** Ruling 19's ×1.10 clamp, surfaced live rather than left to the tooltip. */ +function hysteresisReadoutOf(tuning: CameraTuning): string { + const { minRatio } = CAMERA_TUNING_LIMITS; + const ratio = tuning.disengageHR / tuning.engageHR; + if (Math.abs(ratio - minRatio) < 1e-9) return 'AT FLOOR'; + return `${ratio.toFixed(2)} (floor ${minRatio.toFixed(2)})`; +} + +function OrientationTuning({ + tuning, + hOverR, + markerReadout, + weightReadout, + rememberedTiltReadout, +}: OrientationTuningProps): ReactNode { + const dispatch = useAppDispatch(); + const limits = CAMERA_TUNING_LIMITS; + return ( +
+ +
+ w {weightReadout} +
+ dispatch(setCameraTuning({ engageHR: v }))} + /> + dispatch(setCameraTuning({ disengageHR: v }))} + /> + dispatch(setCameraTuning({ tiltFullHR: v }))} + /> + dispatch(setCameraTuning({ tiltZeroHR: v }))} + /> +
+ hysteresis (dis/eng) + {hysteresisReadoutOf(tuning)} +
+ + +
+ remembered_tilt + {rememberedTiltReadout} +
+
+ ); +} + +export default OrientationTuning; diff --git a/src/components/containers/CameraStateSectionContainer.tsx b/src/components/containers/CameraStateSectionContainer.tsx new file mode 100644 index 0000000000..644d3ab8f5 --- /dev/null +++ b/src/components/containers/CameraStateSectionContainer.tsx @@ -0,0 +1,24 @@ +/** + * CameraStateSectionContainer — engine-handle boundary for the "Camera" debug + * readout. `cameraDebug` is engine-only data (the store read for + * `camera.base.frame` happens inside the getter), so nothing is selected off Redux. + */ + +import { memo, type ReactElement } from 'react'; +import type { RefObject } from 'react'; +import CameraStateSection from '../DebugPanel/CameraStateSection'; +import type { EngineHandle } from '../../@types/engine/EngineHandle'; + +export type CameraStateSectionContainerProps = { + readonly engineHandleRef: RefObject; +}; + +function CameraStateSectionContainer({ + engineHandleRef, +}: CameraStateSectionContainerProps): ReactElement | null { + const handle = engineHandleRef.current; + if (!handle) return null; + return ; +} + +export default memo(CameraStateSectionContainer); diff --git a/src/data/camera/bodyLocalFrame.ts b/src/data/camera/bodyLocalFrame.ts new file mode 100644 index 0000000000..c2f41f19b9 --- /dev/null +++ b/src/data/camera/bodyLocalFrame.ts @@ -0,0 +1,7 @@ +import type { Vec3 } from '../../@types/math/Vec3'; + +/** Body-fixed origin and pole — `[0,0,0]` is the body centre (ruled, S2). */ +export const BODY_LOCAL_FRAME: { readonly centreM: Vec3; readonly pole: Vec3 } = { + centreM: [0, 0, 0], + pole: [0, 0, 1], +}; diff --git a/src/data/camera/cameraTuning.ts b/src/data/camera/cameraTuning.ts new file mode 100644 index 0000000000..05994d1fda --- /dev/null +++ b/src/data/camera/cameraTuning.ts @@ -0,0 +1,34 @@ +/** + * The camera band's shipped defaults and slider ranges — pure data, the only + * file that names a number. Every consumer takes a `CameraTuning` VALUE (the + * store's `camera.tuning`, seeded from here); nothing reads this record live. + */ + +import type { CameraTuning } from '../../@types/camera/CameraTuning'; + +/** Ruling 19, re-tuned 2026-09-10: 0.45 R ≈ 2,870 km over Earth. */ +export const DEFAULT_CAMERA_TUNING = { + engageHR: 0.45, + disengageHR: 0.9, + tiltFullHR: 0.06, + tiltZeroHR: 0.6, + blendSpace: 'log', + northUp: true, +} as const satisfies CameraTuning; + +/** Slider ranges + the hysteresis-window floor both edge pairs keep. */ +export const CAMERA_TUNING_LIMITS = { + engageMin: 0.1, + engageMax: 3.0, + disengageMin: 0.2, + disengageMax: 6.0, + tiltFullMin: 0.01, + tiltFullMax: 3.0, + tiltZeroMin: 0.05, + tiltZeroMax: 6.0, + minRatio: 1.1, +} as const; + +/** The only cap on the remembered tilt: π = zenith, altitude-free (Cesium's + * `maximumPitch`). Not tunable — it never had a slider. */ +export const MAX_REMEMBERED_TILT_RAD = Math.PI; diff --git a/src/data/camera/orientDecay.ts b/src/data/camera/orientDecay.ts new file mode 100644 index 0000000000..78f8cfd16a --- /dev/null +++ b/src/data/camera/orientDecay.ts @@ -0,0 +1,21 @@ +/** + * The one bounded orientation decay both arms' settles read (R1, ruling 8): + * `clamp(residual·(1 − e^(−perLogZoom·u)), ±capRadPerLogZoom·u)`, with `u` the log-zoom + * the notch is ALLOWED to spend (`spentZoomFactor`) on the body arm; the world arm spends + * its notch raw (`frameAlignedRoll`). + */ + +import { WHEEL_ZOOM_K } from '../../services/engine/subsystems/inputAggregator'; + +const NOTCH_LOG_ZOOM = 100 * WHEEL_ZOOM_K; + +export const ORIENT_DECAY = { + perLogZoom: -Math.log(0.75) / NOTCH_LOG_ZOOM, + capRadPerLogZoom: 0.1 / NOTCH_LOG_ZOOM, + /** The calibration notch both rates above are priced in; test-only reader. */ + notchLogZoom: NOTCH_LOG_ZOOM, + /** A drag runs per STEP, not per log-zoom, so its level settle has its own cap (rad). */ + dragLevelCapRad: 0.1, + /** A reference move beyond this in ONE notch is unauthored, so a blend flip cannot whip. */ + rideBoundRad: 0.3, +} as const; diff --git a/src/data/camera/tiltGain.ts b/src/data/camera/tiltGain.ts new file mode 100644 index 0000000000..c8e7807c1e --- /dev/null +++ b/src/data/camera/tiltGain.ts @@ -0,0 +1,4 @@ +/** The tilt handle's own rate multiplier (user feel ruling, 2026-09-03): it spans + * ~90° where orbit spans a hemisphere, so it alone breaks the + * one-FOV-per-screen-height rate law. */ +export const TILT_GAIN = 1.6; diff --git a/src/services/camera/applyInputToCamera.ts b/src/services/camera/applyInputToCamera.ts index bf71fe1cfb..b8c859e3f4 100644 --- a/src/services/camera/applyInputToCamera.ts +++ b/src/services/camera/applyInputToCamera.ts @@ -1,46 +1,46 @@ /** - * applyInputToCamera — apply one aggregated input step to the drag register. - * Only the register is written; committing to the store is the drain's job. - * + * applyInputToCamera — fold one aggregated input step over a world-arm pose. + * Pure: returns the next pose; committing to the store is the drain's job. * Drag right (+dx) DECREASES yaw: the world follows the hand ("globe" drag) * rather than the camera swinging rightward (FPS look). */ import { vec3 } from 'wgpu-matrix'; -import { updatePosition } from '../../utils/camera/updatePosition'; import { zoomedDistance } from '../../utils/camera/zoomedDistance'; import { orbitRadPerPixel } from '../../utils/camera/orbitRadPerPixel'; import { imagePlaneBasis } from '../../utils/camera/imagePlaneBasis'; import { frameUp } from '../../utils/camera/frameUp'; +import { eyeMpcOf } from '../../utils/camera/eyeMpcOf'; -import type { OrbitCamera } from '../../@types/camera/OrbitCamera'; +import type { CameraPose } from '../../@types/camera/CameraPose'; import type { InputStep } from '../../@types/camera/InputStep'; import type { PivotFraming } from '../../@types/camera/PivotFraming'; +import type { Mat3 } from '../../@types/math/Mat3'; import type { Vec3 } from '../../@types/math/Vec3'; -/** - * Pitch ceiling. At exactly ±π/2 forward is collinear with the reference up and - * `lookAt` degenerates to an all-NaN view matrix (gimbal lock); the 0.01 rad - * (≈0.57°) gap is invisible. - */ +// Pitch ceiling: at exactly ±π/2 forward is collinear with the reference up and +// `lookAt` degenerates to an all-NaN view matrix (gimbal lock). The 0.01 rad +// (≈0.57°) gap is invisible. const PITCH_LIMIT = Math.PI / 2 - 0.01; /** * `cssHeight` is the CSS height, NOT the backing store — gesture feel must not * depend on devicePixelRatio. `pivot` (radius `null`: no surface) damps the - * orbit rate and floors the zoom. + * orbit rate and floors the zoom. `poseBasis` decodes the eye for the pan's + * image plane; `upBasis` supplies the pole the pan tracks. */ export function applyInputToCamera( - cam: OrbitCamera, + pose: CameraPose, step: Extract, cssHeight: number, pivot: PivotFraming, -): void { + fovYRad: number, + poseBasis: Readonly, + upBasis: Readonly, +): CameraPose { if (step.kind === 'zoom') { - cam.distance = zoomedDistance(cam.distance, step.factor, pivot); - updatePosition(cam); - return; + return { ...pose, distance: zoomedDistance(pose.distance, step.factor, pivot) }; } const dx = step.endPx[0] - step.startPx[0]; @@ -49,28 +49,31 @@ export function applyInputToCamera( if (step.mode === 'pan') { // Approximate "the point under the cursor follows the cursor" by translating // the target along the screen axes — no depth reprojection. Reference up is - // the frame pole (world +Y absent a basis), so the pan tracks the frame. + // the frame pole, so the pan tracks the frame. + const eye = eyeMpcOf(pose, poseBasis); const forward: Vec3 = [0, 0, 0]; - vec3.subtract(cam.target, cam.position, forward); + vec3.subtract(pose.target, eye, forward); vec3.normalize(forward, forward); - const basis = imagePlaneBasis(forward, 0, frameUp(cam.upBasis)); + const basis = imagePlaneBasis(forward, 0, frameUp(upBasis)); // World units per CSS pixel at the target depth, both axes (pixels square). - const pxToWorld = (2 * cam.distance * Math.tan(cam.fovYRad / 2)) / cssHeight; + const pxToWorld = (2 * pose.distance * Math.tan(fovYRad / 2)) / cssHeight; // Drag right → world slides right → target slides left. CSS y grows down // and cam-up points up-screen, so +dy → +up needs no extra flip. - const panDelta = vec3.create(); - vec3.scale(basis.right, -dx * pxToWorld, panDelta); - vec3.addScaled(panDelta, basis.up, dy * pxToWorld, panDelta); - vec3.add(cam.target, panDelta, cam.target); - updatePosition(cam); - return; + const target: Vec3 = [ + pose.target[0] + basis.right[0] * -dx * pxToWorld + basis.up[0] * dy * pxToWorld, + pose.target[1] + basis.right[1] * -dx * pxToWorld + basis.up[1] * dy * pxToWorld, + pose.target[2] + basis.right[2] * -dx * pxToWorld + basis.up[2] * dy * pxToWorld, + ]; + return { ...pose, target }; } // Damped by altitude above a focused body so the ground tracks the drag. - const radPerPixel = orbitRadPerPixel(cam.fovYRad, cam.distance, cssHeight, pivot.radiusMpc); - cam.yaw -= dx * radPerPixel; - cam.pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, cam.pitch + dy * radPerPixel)); - updatePosition(cam); + const radPerPixel = orbitRadPerPixel(fovYRad, pose.distance, cssHeight, pivot.radiusMpc); + return { + ...pose, + yaw: pose.yaw - dx * radPerPixel, + pitch: Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, pose.pitch + dy * radPerPixel)), + }; } diff --git a/src/services/camera/orbitControls.ts b/src/services/camera/orbitControls.ts index 63882ed670..8e16004ce0 100644 --- a/src/services/camera/orbitControls.ts +++ b/src/services/camera/orbitControls.ts @@ -1,12 +1,12 @@ /** * Orbit controls — the DOM gesture recognizer. Recognizes what the pointer / * wheel stream means and emits `InputGestureEvent`s, touching no camera and no - * engine state: `inputAggregator` folds a frame's events and `drainInput` - * applies them. One apply per frame rather than one per event is what keeps a + * engine state: `inputAggregator` folds a frame's events and `runFrame` + * replays them. One apply per frame rather than one per event is what keeps a * second controller from becoming a second writer of the same register. * - * Pointer events, not mouse events: one handler covers mouse, pen and touch, - * where `mousedown`/`mousemove` are never dispatched for touch at all. + * Pointer events, not mouse events: `mousedown`/`mousemove` are never dispatched + * for touch at all. */ import type { InputGestureEvent } from '../../@types/camera/InputGestureEvent'; @@ -17,13 +17,11 @@ export function attachOrbitControls( emit: (event: InputGestureEvent) => void, options?: OrbitControlsOptions, ): () => void { - // `null` means no drag in progress. type DragMode = 'orbit' | 'pan' | 'pinch'; let dragMode: DragMode | null = null; - // Every active pointer (id → last x/y), so pinch geometry can come from any - // two. Contacts other than `dragPointerId` are tracked only so they tear - // down cleanly; they never drive a single-pointer gesture. + // Contacts other than `dragPointerId` are tracked only so they tear down + // cleanly; they never drive a single-pointer gesture. const activePointers = new Map(); let dragPointerId: number | null = null; @@ -41,15 +39,12 @@ export function attachOrbitControls( let downY = 0; const CLICK_THRESHOLD_SQ = 4 * 4; - // ── Pointer down — begin drag ────────────────────────────────────────────── - const onDown = (e: PointerEvent) => { - // A mouse is strictly single-pointer, so a fresh mouse-down always begins a - // new gesture. Clearing heals state stranded by a `pointerup` we never saw: - // with no pointer capture (the iOS fix below), a button released outside the - // viewport fires no `window` pointerup, and the stale entry would promote the - // next click to a bogus two-pointer pinch. Touch is left alone — its up / - // cancel reach the window listeners via implicit capture. + // A mouse is strictly single-pointer, so clearing heals state stranded by a + // `pointerup` never seen: with no pointer capture (the iOS fix below), a button + // released outside the viewport fires no `window` pointerup, and the stale entry + // would promote the next click to a bogus two-pointer pinch. Touch reaches the + // window listeners via implicit capture, so it is left alone. if (e.pointerType === 'mouse') { activePointers.clear(); dragMode = null; @@ -78,8 +73,6 @@ export function attachOrbitControls( // 3+ pointers change nothing — pinch stays a two-finger gesture. }; - // ── Pointer up — end drag (or click) ────────────────────────────────────── - const onUp = (e: PointerEvent) => { if (!activePointers.has(e.pointerId)) return; activePointers.delete(e.pointerId); @@ -108,8 +101,6 @@ export function attachOrbitControls( } }; - // ── Pointer move ─────────────────────────────────────────────────────────── - const onMove = (e: PointerEvent) => { // Fires for ALL active pointers: update the map first, decide after. const ptr = activePointers.get(e.pointerId); @@ -133,39 +124,38 @@ export function attachOrbitControls( emit({ kind: 'dragMove', mode: dragMode, xPx: e.clientX, yPx: e.clientY }); }; - // ── Wheel — zoom ─────────────────────────────────────────────────────────── - const onWheel = (e: WheelEvent) => { - // Suppress page scroll while zooming. Needs `{ passive: false }` below, - // since a passive listener may not call preventDefault. + // Suppresses page scroll, which needs `{ passive: false }` below: a passive + // listener may not call preventDefault. e.preventDefault(); - // With a pointer down the drag register is what renders (`orbitDrag`), so - // the zoom folds into it; at rest the store `base` renders instead. - emit({ kind: 'wheel', deltaY: e.deltaY, duringGesture: activePointers.size > 0 }); + // With a pointer down the gesture register is what renders, so the zoom folds + // into it; at rest the store `base` renders instead. Client coords match the + // drag arms, so the pick ray is built from one pixel space either way. + emit({ + kind: 'wheel', + deltaY: e.deltaY, + duringGesture: activePointers.size > 0, + xPx: e.clientX, + yPx: e.clientY, + }); }; - // ── Register listeners ───────────────────────────────────────────────────── - - // `pointerdown` on the canvas (a gesture must START on the drawing surface), - // but move / up / cancel on `window`: a drag that leaves the canvas must keep - // orbiting, and — load-bearing on iOS — touch pointers get IMPLICIT capture on - // pointerdown, which WebKit then mishandles if an explicit - // `setPointerCapture()` is layered on top, silently killing `pointermove` / - // `pointerup` for single-finger orbit and pinch while desktop mouse is fine. - // So: never call `setPointerCapture`, listen on `window` instead. - // (`touch-action: none` on the canvas — global.css — still suppresses native - // scroll/zoom; that is keyed off the touch's TARGET element.) + // `pointerdown` on the canvas (a gesture must START on the drawing surface), but + // move / up / cancel on `window`, so a drag that leaves the canvas keeps orbiting + // — and, load-bearing on iOS, NEVER call `setPointerCapture`: touch pointers get + // IMPLICIT capture on pointerdown, and WebKit mishandles an explicit capture + // layered on top, silently killing `pointermove`/`pointerup` for single-finger + // orbit and pinch while desktop mouse stays fine. // https://github.com/openseadragon/openseadragon/issues/1962 canvas.addEventListener('pointerdown', onDown); window.addEventListener('pointerup', onUp); window.addEventListener('pointermove', onMove); - // `pointercancel` = the system pre-empted the gesture (notification shade, - // call, OS gesture shelf). No meaningful end coordinate, so routing it through - // the shared teardown simply will not fire the click branch. + // `pointercancel` = the system pre-empted the gesture, with no meaningful end + // coordinate — routing it through the shared teardown skips the click branch. window.addEventListener('pointercancel', onUp); - // Double-click detection is the browser's: it follows the OS threshold and - // the user's accessibility settings, and fires for a touch double-tap. + // The browser's own detection follows the OS threshold and the user's + // accessibility settings, and fires for a touch double-tap. const onDblClick = (e: MouseEvent) => { options?.onDoubleClick?.(e.clientX, e.clientY); }; diff --git a/src/services/camera/seedCameraFromBase.ts b/src/services/camera/seedCameraFromBase.ts deleted file mode 100644 index c082ffd6d5..0000000000 --- a/src/services/camera/seedCameraFromBase.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * seedCameraFromBase — copy an orbit pose into the live drag register. - * - * The live `OrbitCamera` (`state.cam`) is the drag register: `orbitControls` - * reads its fields while the user holds a pointer and mutates them on every - * pointermove. Between gestures `state.cam` is stale — the produce step - * derives the rendered pose from the Redux store, not from `state.cam`. Before - * beginning a new gesture the register must be seeded from the last LIVE - * produced pose so the drag continues from exactly where the animation left - * the camera, rather than from whatever state the register holds from the - * previous gesture. - * - * The parameter is named `pose` rather than `base` because the caller passes - * `state.cameraRuntime.lastPose.current` — the live produced pose — NOT - * `store.getState().camera.base`. At rest `lastPose == base`; mid-tween - * `lastPose` is the animation's current interpolated position while `base` is - * still the pre-tween committed value. Seeding from `lastPose` makes both the - * at-rest grab and the mid-animation grab jump-free. The name `seedCameraFromBase` - * matches the plan's naming contract and documents the domain purpose; callers - * document the live-pose source in their own comments. - */ - -import type { OrbitCamera } from '../../@types/camera/OrbitCamera'; -import type { CameraPose } from '../../@types/camera/CameraPose'; -import { updatePosition } from '../../utils/camera/updatePosition'; - -/** - * Copy the orbit parameters (including `roll`) from `pose` onto the live drag - * register `cam` and recompute `cam.position` so the first drag delta is - * relative to exactly where the animation left the camera. - * - * `cam.fovYRad`, `cam.aspect`, `cam.near`, and `cam.far` are NOT touched — - * those come from the projection Resource (`state.cameraRuntime.projection`) - * and are merged in by `assembleOrbitCamera` on each produced frame. The drag - * register only needs the orbit parameters to feed `orbitControls` math. - */ -export function seedCameraFromBase(cam: OrbitCamera, pose: CameraPose): void { - // Copy target element-by-element; never alias the pose's array, since the - // register mutates target in place during pans. - cam.target[0] = pose.target[0]; - cam.target[1] = pose.target[1]; - cam.target[2] = pose.target[2]; - cam.yaw = pose.yaw; - cam.pitch = pose.pitch; - cam.distance = pose.distance; - cam.roll = pose.roll; - // Recompute the world-space position from the updated orbit params so the - // first pointermove delta is computed from the correct starting position. - updatePosition(cam); -} diff --git a/src/services/camera/surfaceStep.ts b/src/services/camera/surfaceStep.ts new file mode 100644 index 0000000000..a68a62a4f4 --- /dev/null +++ b/src/services/camera/surfaceStep.ts @@ -0,0 +1,128 @@ +/** + * surfaceStep — the body arm's gesture register as a pure step over its memory + * (spec §6). Body-fixed metres, no world position, so a fast clock cannot slide + * the ground under a gesture; every mode moves the pose so the grabbed content + * follows the cursor, which fixes each sign below. One orientation authority + * (R1): gestures never create roll. Tilt is Cesium-style (ruling 12): display = + * `remembered × bodyUpWeight(h/R)`; only tilt/look write it. + */ + +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { CameraTuning } from '../../@types/camera/CameraTuning'; +import type { InputStep } from '../../@types/camera/InputStep'; +import type { SurfaceMemory } from '../../@types/camera/SurfaceMemory'; +import type { Vec2 } from '../../@types/math/Vec2'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { BODY_LOCAL_FRAME } from '../../data/camera/bodyLocalFrame'; +import { MAX_REMEMBERED_TILT_RAD } from '../../data/camera/cameraTuning'; +import { ORIENT_DECAY } from '../../data/camera/orientDecay'; +import { bodyFixedEyeM } from '../../utils/camera/bodyFixedEyeM'; +import { bodyUpWeight } from '../../utils/camera/bodyUpWeight'; +import { draggedSurfacePose } from '../../utils/camera/draggedSurfacePose'; +import { eyeFrameOf } from '../../utils/camera/eyeFrameOf'; +import { flooredBodyPose } from '../../utils/camera/flooredBodyPose'; +import { latchSurfaceGesture } from '../../utils/camera/latchSurfaceGesture'; +import { levelledPose } from '../../utils/camera/levelledPose'; +import { surfaceZoomStep } from '../../utils/camera/surfaceZoomStep'; +import { unmappedTiltRad } from '../../utils/camera/unmappedTiltRad'; + +type SurfaceStepCtx = { + readonly viewportPx: Readonly; + readonly fovYRad: number; + readonly bodyRadiusM: number; + /** Scene-frame up in BODY-FIXED axes (unit); the body rotates under it, so resample per drain. */ + readonly sceneUpLocal: Readonly; + readonly tuning: CameraTuning; +}; + +/** The engine's boot value; immutable, so one shared object is fine. */ +export const EMPTY_SURFACE_MEMORY: SurfaceMemory = { + gesture: null, + rememberedTiltRad: 0, + memoryBodyId: null, +}; + +/** Once per frame with the camera's current body; a DIFFERENT body wipes the tilt (ruling 18), null keeps it. */ +export function noteBody(prev: SurfaceMemory, bodyId: string | null): SurfaceMemory { + if (bodyId === null || bodyId === prev.memoryBodyId) return prev; + const wipe = prev.memoryBodyId !== null; + return { ...prev, rememberedTiltRad: wipe ? 0 : prev.rememberedTiltRad, memoryBodyId: bodyId }; +} + +export function surfaceStep( + prev: SurfaceMemory, + arm: BodyFixedPose, + step: InputStep, + ctx: SurfaceStepCtx, +): { readonly pose: BodyFixedPose; readonly next: SurfaceMemory } { + const { viewportPx, fovYRad, bodyRadiusM, sceneUpLocal, tuning } = ctx; + if (step.kind === 'zoom') { + return { + pose: surfaceZoomStep( + arm, + prev.gesture === 'down' ? null : prev.gesture, + step.factor, + step.cursorPx, + viewportPx, + fovYRad, + bodyRadiusM, + sceneUpLocal, + prev.rememberedTiltRad, + tuning, + ), + next: prev, + }; + } + // The press and release reach the memory at `replayInput`'s two gesture + // edges; nothing latches here from idle. + if (step.kind !== 'drag' || prev.gesture === null) return { pose: arm, next: prev }; + const gesture = + prev.gesture === 'down' + ? latchSurfaceGesture(arm, step, viewportPx, fovYRad, bodyRadiusM) + : prev.gesture; + // The step's ENTRY heading, which pan transports. Drags level against the + // PURE body ENU — the band blend is the zoom's authority; a drag-created + // deviation from the blend is "unauthored" and the next notch's decay + // settles it. + const preInPoleFrame = eyeFrameOf(arm, 1, BODY_LOCAL_FRAME.pole); + const { pose, mode } = draggedSurfacePose(arm, gesture, step, viewportPx, fovYRad); + // One floor site, after every position write — `anchoredZoomStep` owns + // its own, so the zoom arm above is already floored. The level runs on the + // FLOORED pose: the floor moves the eye radially, and the ENU it settles + // against has to be the final standpoint. + const floored = flooredBodyPose(pose, bodyRadiusM); + // Drags stay heading-free (ruled) — only zoom walks north up — but no drag + // may ROLL: pan and orbit hold their entry heading (the transport that makes + // holonomy unrepresentable), look and tilt level around the heading they + // authored. Strafe translates with its basis untouched, a known small hole in + // the no-roll rule: it lives in a few-pixel grazing-incidence latch window at + // the limb (~0.03 rad over 30 steps, measured), settled by the next notch. + const final = + mode === 'strafe' || preInPoleFrame === null + ? floored + : levelledPose(floored, { + blendW: 1, + sceneUpLocal: BODY_LOCAL_FRAME.pole, + heldAzimuthRad: mode === 'pan' || mode === 'orbit' ? preInPoleFrame.azimuthRad : null, + pivotM: null, + capRad: ORIENT_DECAY.dragLevelCapRad, + }); + // Ruling 12: tilt-authoring handles update the memory. Un-mapping + // through the band weight keeps the just-set display a FIXED POINT of + // the zoom mapping — a notch at the set altitude must not move it + // (zoom never authors tilt). Near w → 0 the ratio diverges: + // `MAX_REMEMBERED_TILT_RAD` is the only cap on the memory, and a degenerate + // weight leaves it untouched (no intent is readable there). + let rememberedTiltRad = prev.rememberedTiltRad; + if (mode === 'tilt' || mode === 'look') { + const f = eyeFrameOf(final, 1, BODY_LOCAL_FRAME.pole); + const hr = Math.hypot(...bodyFixedEyeM(final)) / bodyRadiusM - 1; + if (f !== null && bodyUpWeight(hr, tuning) > 1e-6) { + rememberedTiltRad = Math.min(unmappedTiltRad(f.tiltRad, hr, tuning), MAX_REMEMBERED_TILT_RAD); + } + } + return { + pose: final, + next: { ...prev, gesture: { ...gesture, mode, prevPixel: step.endPx }, rememberedTiltRad }, + }; +} diff --git a/src/services/engine/animation/channelSpace.ts b/src/services/engine/animation/channelSpace.ts index 8cd641ecff..faff81ad53 100644 --- a/src/services/engine/animation/channelSpace.ts +++ b/src/services/engine/animation/channelSpace.ts @@ -12,7 +12,7 @@ * any consumer of `CHANNEL_SPACE` that does interpolation would be incomplete * without `lerpInSpace`. The cohesion is tighter than the one-export-per-file * rule requires, so this is an intentional exception (the same reasoning that - * keeps, say, `cameraClock.ts`'s factory + helpers in one file). + * keeps, say, `cameraEpochs.ts`'s primitives in one file). * * ### Why is CHANNEL_SPACE in exactly one place? * diff --git a/src/services/engine/animation/compileClip.ts b/src/services/engine/animation/compileClip.ts index 52d26e33f6..b23cbb45d8 100644 --- a/src/services/engine/animation/compileClip.ts +++ b/src/services/engine/animation/compileClip.ts @@ -154,6 +154,9 @@ function walk(effect: Effect, atSec: number, acc: Accum): number { to: effect.to, ease: effect.ease, space: effect.space, + // Conditional so an untagged endpoint compiles to the byte-identical + // segment it always did — `frame` absent, not `frame: undefined`. + ...(effect.frame !== undefined ? { frame: effect.frame } : {}), }); return effect.over; } @@ -168,6 +171,7 @@ function walk(effect: Effect, atSec: number, acc: Accum): number { to: effect.to, ease: effect.ease, space: 'lin', + ...(effect.frame !== undefined ? { frame: effect.frame } : {}), }); return effect.over; } diff --git a/src/services/engine/animation/effectHelpers.ts b/src/services/engine/animation/effectHelpers.ts index d6e32d0760..e936211fea 100644 --- a/src/services/engine/animation/effectHelpers.ts +++ b/src/services/engine/animation/effectHelpers.ts @@ -46,6 +46,7 @@ import type { Effect } from '../../../@types/animation/Effect'; import type { Channel } from '../../../@types/animation/Channel'; import type { Ease } from '../../../@types/animation/Ease'; import type { Space } from '../../../@types/animation/Space'; +import type { PoseFrame } from '../../../@types/camera/PoseFrame'; import type { Vec3 } from '../../../@types/math/Vec3'; import type { VisibilityLayerArg } from '../../../@types/animation/VisibilityLayerArg'; import type { ScopedVisibilityArg } from '../../../@types/animation/ScopedVisibilityArg'; @@ -84,7 +85,7 @@ import { */ export function tween( ch: 'distance' | 'yaw' | 'pitch', - opts: { to: number; over: number; ease?: Ease; space?: Space }, + opts: { to: number; over: number; ease?: Ease; space?: Space; frame?: PoseFrame }, ): CameraAction & { kind: 'set' } { return { kind: 'set', @@ -93,6 +94,9 @@ export function tween( over: opts.over, ease: opts.ease ?? 'easeInOutCubic', space: opts.space ?? CHANNEL_SPACE[ch], + // Omitted, never `undefined`: an untagged action must stay the object it + // has always been (spec §8 — absent ⇒ 'absolute'). + ...(opts.frame !== undefined ? { frame: opts.frame } : {}), }; } @@ -115,7 +119,12 @@ export function dollyTo(mpc: number, over: number, ease?: Ease): CameraAction & * log-space is undefined for signed values). See `CameraAction.d.ts` for the * full rationale on the `setVec` / `set` split. */ -export function moveTarget(to: Vec3, over: number, ease?: Ease): CameraAction & { kind: 'setVec' } { +export function moveTarget( + to: Vec3, + over: number, + ease?: Ease, + frame?: PoseFrame, +): CameraAction & { kind: 'setVec' } { return { kind: 'setVec', ch: 'target', @@ -123,6 +132,7 @@ export function moveTarget(to: Vec3, over: number, ease?: Ease): CameraAction & over, ease: ease ?? 'easeInOutCubic', space: 'lin', + ...(frame !== undefined ? { frame } : {}), }; } diff --git a/src/services/engine/animation/playClip.ts b/src/services/engine/animation/playClip.ts index 1fc84baeb0..c6fcbc245c 100644 --- a/src/services/engine/animation/playClip.ts +++ b/src/services/engine/animation/playClip.ts @@ -1,45 +1,8 @@ /** - * playClip — the non-reactive seam between clip authoring and the animation - * runtime. + * playClip — dispatch-time seam between clip authoring and the animation runtime. * - * ### Why a factory - * - * `playClip(clip, frame): Promise` needs three injected deps (store.dispatch, - * clipPlayer, and a live-pose accessor) that are only available once the engine - * has bootstrapped. A factory `createPlayClip(deps)` captures them in a closure - * and returns the bound `playClip` function. Callers (sagas or imperative spikes) - * receive the plain `(clip, frame) => Promise` with no dep-plumbing at the - * call site. `frame` is the caller's orientation at dispatch time — pinned onto - * `camera.clip` (see cameraSlice.ts), never re-derived. - * - * ### Promise resolution contract - * - * The returned Promise resolves on BOTH clip-end edges — natural completion (the - * two-frame deferred `endClip` dispatch in `clipPlayer.tick`) and abort - * (`clipPlayer.stop()` via the `[CANCEL]` hook). It never rejects, so - * `yield* call(playClip, clip, frame)` in a saga needs no try/catch, and a bare - * `await playClip(clip, frame)` in an imperative spike is always safe. - * - * ### 'live' resolution and fresh-reference invariant - * - * `resolveClipStart` is called AT DISPATCH TIME, not at authoring time. This - * has two effects: - * 1. `start: 'live'` (or `undefined`) is replaced with the actual pose the - * user currently sees (`getLivePose()`), so `evaluateClip` stays pure with - * no sentinel values. - * 2. The returned object is always a FRESH spread (`{ ...data, start }`), even - * when `start` was already concrete. The fresh reference is the clock-reset - * trigger: `clipElapsed` in `clipPlayer.tick` keys on `camera.clip` reference - * identity; a new object reference guarantees the clock resets to zero on the - * transition frame, regardless of whether the clip content changed. - * - * ### [CANCEL] hook - * - * redux-saga invokes `p[CANCEL]()` when a task that called `yield call(p)` is - * cancelled (e.g. a `race` whose sibling wins). The hook calls `clipPlayer.stop()`, - * which dispatches `endClip()` and fires the registered end-resolver. Because the - * resolver fires on the cancel edge too, the Promise resolves (not rejects) and - * the saga unwinds cleanly without an error boundary. + * The returned Promise resolves on BOTH clip-end edges — natural completion and + * abort via the redux-saga `[CANCEL]` hook — and never rejects. */ import { CANCEL } from 'redux-saga'; @@ -51,77 +14,37 @@ import type { OrientationFrameId } from '../../../@types/camera/OrientationFrame import type { AppDispatch } from '../../../store/types'; import type { ClipPlayer } from '../../../@types/engine/subsystems/ClipPlayer'; -// --------------------------------------------------------------------------- -// Deps shape -// --------------------------------------------------------------------------- - export type PlayClipDeps = { - /** - * Narrow store accessor — only `dispatch` is needed. The full AppStore - * satisfies this shape at the wiring site without exposing the state accessor - * to this module (playClip is write-only toward the store). - */ store: { dispatch: AppDispatch }; - /** - * The running clip player. `registerEndResolver` is called before dispatching - * `startClip` to avoid the (theoretical) race where a zero-duration clip - * completes synchronously. `stop()` is invoked by the `[CANCEL]` hook. - */ clipPlayer: Pick; - /** - * Accessor for the live produced camera pose. Reads - * `state.cameraRuntime.lastPose.current` (the pose the user actually sees, - * not the potentially-stale `camera.base`). - * - * A closure accessor (not the pose value itself) so 'live' resolution always - * captures the pose at dispatch time rather than at factory-creation time. - */ + /** Live produced camera pose, in world Mpc. */ getLivePose: () => CameraPose; }; -// --------------------------------------------------------------------------- -// Factory -// --------------------------------------------------------------------------- - -/** - * Create the bound `playClip` function from its engine dependencies. - * - * Call once at engine bootstrap after the store, clipPlayer, and camera runtime - * are all available. The returned function can be handed to sagas via - * `setSagaContext` or called directly in imperative spikes. - */ export function createPlayClip( deps: PlayClipDeps, ): (clip: ClipData, frame: OrientationFrameId) => Promise { const { store, clipPlayer, getLivePose } = deps; return function playClip(clip: ClipData, frame: OrientationFrameId): Promise { - // Resolve 'live' → concrete at DISPATCH TIME (not authoring time) and wrap - // in a fresh object to trigger the clipElapsed clock reset. + // Fresh object every call, even when `start` was already concrete: the clip + // epoch resets on `camera.clip` reference identity. const resolvedClip = resolveClipStart(clip, getLivePose()); - // Build the Promise first and register its resolver with the clip player - // BEFORE dispatching clipStarted. This ordering is defensive: if a - // zero-duration clip somehow completes synchronously the resolver is already - // in place when clipEnded fires. + // Resolver registered before clipStarted dispatches, so a zero-duration clip + // completing synchronously still finds it in place. const p = new Promise((resolve) => { clipPlayer.registerEndResolver(resolve); }); - // Attach the redux-saga [CANCEL] hook. When a saga task is cancelled (e.g. - // by a race sibling), the middleware calls p[CANCEL](). stop() dispatches - // clipEnded() and fires the resolver → Promise resolves, never rejects. (p as Promise & { [CANCEL]: () => void })[CANCEL] = () => { clipPlayer.stop(); }; - // Activate the clip@95 driver. The fresh `resolvedClip` reference triggers - // the clipElapsed clock reset on the first tick. `frame` is pinned here — - // the caller's orientation AT DISPATCH TIME — so a later orientation switch - // re-expresses the clip's pose instead of reinterpreting it (see the clip - // row in cameraDrivers.ts). + // `frame` is pinned at dispatch time so a later orientation switch re-expresses + // the clip's pose instead of reinterpreting it (clip row in cameraDrivers.ts). store.dispatch(clipStarted({ data: resolvedClip, frame })); return p; diff --git a/src/services/engine/camera/activeDriverId.ts b/src/services/engine/camera/activeDriverId.ts deleted file mode 100644 index 3498d97db2..0000000000 --- a/src/services/engine/camera/activeDriverId.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * activeDriverId — report which driver is winning this frame, without - * re-resolving the pose. - * - * `runCameraDrivers` and `activeDriverId` both delegate to `pickWinner`, so - * they are guaranteed to agree on the winning driver (invariant 1 of the - * commit-on-edge ordering): the commit-on-edge path cannot key on a different - * driver than the one that produced the pose. A caller that called - * `runCameraDrivers(drivers, s, cam, clock, nowMs)` and then called this - * function with the SAME `drivers` and `s` will always receive the same id as - * the driver whose `pose` was called. There is no separate 'who won' bookkeeping - * — one function, one scan, one answer. - * - * The function lives in its own file (one function per file per project - * convention) rather than inlined in `runFrame` because the caller intent - * ('what was the winning driver?') deserves a named home, and because naming it - * makes the invariant expressible in comments and tests. - */ - -import type { CameraDriver } from '../../../@types/engine/camera/CameraDriver'; -import type { RootState } from '../../../store/types'; -import { pickWinner } from './cameraDrivers'; - -/** - * Return the `id` of the highest-priority active driver in `drivers` for the - * given store state `s`. - * - * Always returns a string: the always-active `resting` floor (priority 0) - * guarantees that at least one driver is active. Calling this with an empty - * `drivers` list returns `drivers[0].id` (the same defensive fallback - * `pickWinner` uses). - */ -export function activeDriverId(drivers: readonly CameraDriver[], s: RootState): string { - return pickWinner(drivers, s).id; -} diff --git a/src/services/engine/camera/applyFocusedBodyPivot.ts b/src/services/engine/camera/applyFocusedBodyPivot.ts index 54e6d2fe25..90bd91f87d 100644 --- a/src/services/engine/camera/applyFocusedBodyPivot.ts +++ b/src/services/engine/camera/applyFocusedBodyPivot.ts @@ -1,59 +1,43 @@ /** - * applyFocusedBodyPivot — re-centre a produced pose on the focused body. - * - * The unifying rule for body focus: the focused body owns the PIVOT (the pose's - * `target`), while whichever driver won the frame owns the ORBIT terms - * (yaw / pitch / distance). Rather than teach every orbit driver to read the - * body snapshot, the driver table produces a pose as usual and the frame loop - * pins its target to the live body position here, in one place. - * - * Applied only for drivers that declare `pivotsOnFocusedBody` (orbitDrag, - * autoRotate, followBody, resting — the drivers that author an orbit around a - * target). clip and tween keyframe a full path including the target, so they - * opt out and keep their own target term. - * - * Only a MOVING focus is pinned (`bodyMovesThisFrame`). A static focus — the - * Sun, a famous star — has a snapshot position but no orbit to chase, so - * gating on presence instead would hand it the pin, and with it `panOffset`, - * for a body that never needed re-centring. - * - * The pin is IDEMPOTENT and ABSOLUTE — it SETS the target to `bodyPosition + - * panOffset`, never adds a delta to the existing target — so it can never - * double-apply across a commit-on-edge boundary. A one-frame-stale `base.target` - * baked on an edge is simply overwritten by the next frame's pin, never accumulated. - * - * `panOffset` is the world-frame strafe the user has panned away from the body - * (zero on a fresh focus); resolving `bodyPosition + panOffset` here keeps the - * pivot a SINGLE target-resolution home — the strafe is stored on the clock and - * only READ here, never a second per-driver target path. - * - * The result target is a fresh per-frame array (read-only downstream), so no - * defensive copy of the snapshot position is needed. + * applyFocusedBodyPivot — re-centre a produced pose on the focused body: the body + * owns the PIVOT (the pose's `target`), the winning driver owns the orbit terms. + * Applied only for drivers declaring `pivotsOnFocusedBody` — clip and tween + * keyframe a full path including the target, so they opt out. Only a MOVING focus + * is pinned: a static one has no orbit to chase, and gating on presence would hand + * it the pin and with it `panOffset`. The pin SETS `bodyPosition + panOffset` + * rather than adding a delta, so it cannot double-apply across a commit-on-edge + * boundary. */ import { liveBodyPosition } from './liveBodyPosition'; +import { absoluteArm } from '../../../utils/camera/absoluteArm'; import { bodyMovesThisFrame } from '../../../utils/scene/bodyMovesThisFrame'; -import type { CameraPose } from '../../../@types/camera/CameraPose'; +import type { BodyState } from '../../../@types/scene/BodyState'; +import type { FramedCameraPose } from '../../../@types/camera/FramedCameraPose'; import type { SelectionRow } from '../../../@types/engine/SelectionRow'; import type { Vec3 } from '../../../@types/math/Vec3'; export function applyFocusedBodyPivot( - pose: CameraPose, + framed: FramedCameraPose, pivotsOnFocusedBody: boolean, focusRow: SelectionRow | null, - simDays: number, + bodies: ReadonlyMap, panOffset: Vec3, -): CameraPose { - if (!pivotsOnFocusedBody) return pose; - if (!bodyMovesThisFrame(focusRow)) return pose; - const pivot = liveBodyPosition(focusRow, simDays); +): FramedCameraPose { + // A body arm co-rotates with its body, so "keep the moving body centred" is + // structurally satisfied and the pin has nothing to do (spec §7 step 4). + if (framed.frame !== 'absolute') return framed; + if (!pivotsOnFocusedBody) return framed; + if (!bodyMovesThisFrame(focusRow)) return framed; + const pivot = liveBodyPosition(focusRow, bodies); // A moving body is in the snapshot by construction; the guard is the narrowing. - if (pivot === null) return pose; - return { + if (pivot === null) return framed; + const pose = framed.pose; + return absoluteArm({ target: [pivot[0] + panOffset[0], pivot[1] + panOffset[1], pivot[2] + panOffset[2]], yaw: pose.yaw, pitch: pose.pitch, distance: pose.distance, roll: pose.roll, - }; + }); } diff --git a/src/services/engine/camera/applyWheelZoom.ts b/src/services/engine/camera/applyWheelZoom.ts index e42a5760df..269705392e 100644 --- a/src/services/engine/camera/applyWheelZoom.ts +++ b/src/services/engine/camera/applyWheelZoom.ts @@ -1,83 +1,27 @@ /** - * applyWheelZoom — route a discrete wheel-zoom factor to whichever owner - * authors the camera distance this frame. - * - * Three distance owners, split by who wins the driver arbitration: - * - * - followBody. While a scene body is focused, the followBody driver owns the - * pose distance and reads it from `clock.followDistanceTarget`. The resting - * driver (which renders `camera.base`) is NOT the winner, so committing a - * zoomed `base` would be invisible — followBody re-asserts its own distance - * target every frame and swallows the zoom. Instead we scale the follow's - * OWN distance target in place: the wheel edits the follow's distance - * directly (the same slot a post-drag recapture writes), and the driver - * eases to it. Returns null — nothing to commit into the store. - * - * - autoRotate. The auto-rotate driver renders `spinAutoRotate(base, rate, - * elapsed)` — yaw advances from a FROZEN base by the cumulative elapsed the - * clock accumulates. `camera.base` IS the rendered distance, so a zoom must - * commit a new base. But committing the raw zoomed `base` installs a fresh - * base reference with the OLD (un-spun) yaw, and `autoRotateElapsed` resets - * its start on any base-identity change — so the rendered yaw would snap - * from `base.yaw + spin` back to `base.yaw`: a visible pop on every wheel - * tick. We fold the accumulated spin into the committed pose instead: zoom - * the ALREADY-spun pose, so the elapsed reset lands on a base that already - * carries the spin and the spin continues seamlessly from there. - * `autoRotateElapsed` here is an idempotent READ — the frame's drain runs - * before this frame's driver, so the last call was the PREVIOUS frame's, - * with the same (active, base) refs; the start is not reset either way. - * Passing the REAL active bit matters: if auto-rotate was - * switched off between frames, elapsed reads 0 and this branch degrades to - * the plain zoomed base. - * - * - everyone else (resting / tween …). `camera.base` IS the rendered - * distance, so return the zoomed `base` for the caller to commit. - * - * `prevActiveId` is the previous frame's winning driver id - * (`state.cameraRuntime.prevActiveId.current`). The wheel event fires BETWEEN - * frames, so last frame's winner is this frame's winner too — no driver change - * can intervene between the event and the next produce. A null distance target - * means the follow driver's `pose` has not run yet to seed it (the one-frame - * window right after focus); fall through to the base commit, which is harmless - * because the follow approach re-seeds the framing distance on its first produce. - * - * This keeps the follow driver's two distance sources un-braided: fresh focus - * seeds the framing distance; any USER zoom writes the user's distance. The - * drag path writes it via the recapture edge (a committed base.distance); this - * function is the WHEEL half of 'any user zoom'. - * - * `pivot` bundles the radius + floor of whatever the camera orbits (see - * `pivotRadiusMpc.ts`'s `pivotFraming`), forwarded to every arm's - * `zoomedDistance` call so the zoom tapers into just off a focused body's - * surface instead of scaling raw distance to the centre. All three arms need - * it: follow orbits the body by definition, and the autoRotate / resting arms - * orbit it too whenever the frame loop's pivot-pin is centring them on it. + * applyWheelZoom — the at-rest wheel notch as a pose to commit, for the case + * where `camera.base` IS what the frame renders; a notch a following camera + * swallows goes to that driver instead. The spin epoch restarts on any base + * change, so committing an un-spun base pops the yaw — zoom the spun pose. */ -import { autoRotateElapsed } from './cameraClock'; import { spinAutoRotate } from './spinAutoRotate'; -import { zoomedDistance } from '../../../utils/camera/zoomedDistance'; import { zoomedPose } from '../../../utils/camera/zoomedPose'; -import type { CameraClock } from '../../../@types/engine/camera/CameraClock'; import type { CameraPose } from '../../../@types/camera/CameraPose'; +import type { FramedCameraPose } from '../../../@types/camera/FramedCameraPose'; import type { PivotFraming } from '../../../@types/camera/PivotFraming'; -export function applyWheelZoom( - clock: CameraClock, - prevActiveId: string, - base: CameraPose, - factor: number, - autoRotate: { active: boolean; rate: number }, - nowMs: number, - pivot: PivotFraming, -): CameraPose | null { - if (prevActiveId === 'followBody' && clock.followDistanceTarget !== null) { - clock.followDistanceTarget = zoomedDistance(clock.followDistanceTarget, factor, pivot); - return null; - } - if (prevActiveId === 'autoRotate') { - const elapsed = autoRotateElapsed(clock, autoRotate.active, base, nowMs); - return zoomedPose(spinAutoRotate(base, autoRotate.rate, elapsed), factor, pivot); - } - return zoomedPose(base, factor, pivot); +export function applyWheelZoom(args: { + readonly base: FramedCameraPose; + readonly factor: number; + /** `owns` = the spin authored the pose last frame, not merely enabled. */ + readonly spin: { readonly owns: boolean; readonly rate: number }; + readonly spinElapsedMs: number; + readonly pivot: PivotFraming; +}): CameraPose | null { + const { base, factor, spin, spinElapsedMs, pivot } = args; + // World arm only (spec §7): in a body arm the wheel routes to the surface gesture. + if (base.frame !== 'absolute') return null; + const pose = spin.owns ? spinAutoRotate(base.pose, spin.rate, spinElapsedMs) : base.pose; + return zoomedPose(pose, factor, pivot); } diff --git a/src/services/engine/camera/approachTiltedPose.ts b/src/services/engine/camera/approachTiltedPose.ts new file mode 100644 index 0000000000..480cd9d2fb --- /dev/null +++ b/src/services/engine/camera/approachTiltedPose.ts @@ -0,0 +1,96 @@ +/** + * approachTiltedPose — ruling 13: the world arm's in-window expression of the ONE + * display-tilt mapping (`mappedTiltRad`), so the engage edge changes ownership of + * the tilt, never the image. A pure per-frame projection between the pivot pin and + * the fold: pitch the view off the FOCUSED body's nadir by the mapped amount, + * rotating about the rolled screen-right with the eye fixed. The output reaches + * ONLY `outputs.displayed`, never the authored register or `camera.base` (the + * centre-looking invariant at `commitCameraPose`) — and the fold converts THIS pose, + * so engage inherits `remembered × 1`. Zero remembered returns the input BY REFERENCE. + */ + +import { bodyMovesThisFrame } from '../../../utils/scene/bodyMovesThisFrame'; +import { hOverR } from './hOverR'; +import { absoluteArm } from '../../../utils/camera/absoluteArm'; +import { eyeMpcOf } from '../../../utils/camera/eyeMpcOf'; +import { frameUp } from '../../../utils/camera/frameUp'; +import { imagePlaneBasis } from '../../../utils/camera/imagePlaneBasis'; +import { mappedTiltRad } from '../../../utils/camera/mappedTiltRad'; +import { orbitAnglesLookingAlong } from '../../../utils/camera/orbitAnglesLookingAlong'; +import { normalize3 } from '../../../utils/math/normalize3'; +import type { BodyState } from '../../../@types/scene/BodyState'; +import type { CameraTuning } from '../../../@types/camera/CameraTuning'; +import type { FramedCameraPose } from '../../../@types/camera/FramedCameraPose'; +import type { Mat3 } from '../../../@types/math/Mat3'; +import type { SelectionRow } from '../../../@types/engine/SelectionRow'; +import type { Vec3 } from '../../../@types/math/Vec3'; + +export function approachTiltedPose( + framed: FramedCameraPose, + pivotsOnFocusedBody: boolean, + focusRow: SelectionRow | null, + bodies: ReadonlyMap, + rememberedTiltRad: number, + poseBasis: Readonly, + upBasis: Readonly, + tuning: CameraTuning, +): FramedCameraPose { + if (framed.frame !== 'absolute') return framed; + if (!pivotsOnFocusedBody || rememberedTiltRad === 0) return framed; + if (focusRow === null || focusRow.type !== 'body' || !bodyMovesThisFrame(focusRow)) { + return framed; + } + // `hOverR` is the sanctioned Mpc↔metre seam for the altitude. + const bodyState = bodies.get(focusRow.id); + if (bodyState === undefined) return framed; + const centreMpc = bodyState.positionMpc; + + const pose = framed.pose; + const eye = eyeMpcOf(pose, poseBasis); + const rel: Vec3 = [eye[0] - centreMpc[0], eye[1] - centreMpc[1], eye[2] - centreMpc[2]]; + if (Math.hypot(...rel) === 0) return framed; + const hr = hOverR(eye, bodyState, focusRow.radiusM); + const tau = mappedTiltRad(rememberedTiltRad, hr, tuning); + if (tau < 1e-12) return framed; // at/above the band top — inert, by reference + + const n = normalize3(rel); + const forward = normalize3([ + pose.target[0] - eye[0], + pose.target[1] - eye[1], + pose.target[2] - eye[2], + ]); + // Inlined rather than `tiltFromNadirRad`: the two round differently by an + // ulp, and at heliocentric magnitudes the yaw/pitch decode amplifies that + // to ~1e-9 — the golden trace holds this world-arm readout bit-for-bit. + const vert = forward[0] * n[0] + forward[1] * n[1] + forward[2] * n[2]; + const currentTilt = Math.acos(Math.max(-1, Math.min(1, -vert))); + const delta = tau - currentTilt; + if (Math.abs(delta) < 1e-12) return framed; + + // Tip the view toward screen-up by the residual: rotation about the rolled + // right in the forward/up plane (`right·forward = 0`, so Rodrigues reduces + // to a plain rotation of forward toward up) — the same axis the tilt + // handle drags about, so heading and roll are untouched. + const { up } = imagePlaneBasis(forward, pose.roll ?? 0, frameUp(upBasis)); + const c = Math.cos(delta); + const s = Math.sin(delta); + const f: Vec3 = normalize3([ + forward[0] * c + up[0] * s, + forward[1] * c + up[1] * s, + forward[2] * c + up[2] * s, + ]); + const { yaw, pitch } = orbitAnglesLookingAlong(f, [...poseBasis] as Mat3); + return absoluteArm({ + // Same recipe as the fold's disengage retarget: target on the new view + // ray at the incumbent distance, so the decode reconstructs the SAME eye. + target: [ + eye[0] + f[0] * pose.distance, + eye[1] + f[1] * pose.distance, + eye[2] + f[2] * pose.distance, + ], + yaw, + pitch, + distance: pose.distance, + roll: pose.roll, + }); +} diff --git a/src/services/engine/camera/bodyHomePose.ts b/src/services/engine/camera/bodyHomePose.ts index 5bf5d45f15..e6c2942e5b 100644 --- a/src/services/engine/camera/bodyHomePose.ts +++ b/src/services/engine/camera/bodyHomePose.ts @@ -40,8 +40,8 @@ * ### Why the distance is `bodyLikeFraming`'s, not a bespoke home distance * * This is forced by the follow mechanics, not taste. When home focus lands on - * a body the follow driver takes over, and `followElapsed` (`cameraClock.ts`) - * nulls `followDistanceTarget` on every focus-row change; the driver then + * a body the follow driver takes over, and `runFrame` drops the follow memory + * on every focus-row change; the driver then * re-seeds it to the body's framing distance (`bodyFocusDistance`, via * `bodyLikeFraming`). Any other landing distance would be glided away from the * instant the tween ends — a visible lurch. Ending the pose at the framing diff --git a/src/services/engine/camera/cameraClock.ts b/src/services/engine/camera/cameraClock.ts deleted file mode 100644 index ac7f20e7ef..0000000000 --- a/src/services/engine/camera/cameraClock.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * cameraClock — the engine Resource that turns descriptor-identity changes - * into elapsed values for camera drivers. - * - * The clock sits between the Redux store (timeless intent) and the frame-loop - * drivers. Each frame the caller passes the current tween descriptor, the - * auto-rotate active bit, and the active clip; the clock detects when any - * changed by reference identity / value, resets the relevant start time, and - * returns elapsed time from that start. - * - * The elapsed functions share one resource: `tweenElapsed`, `autoRotateElapsed`, - * and `followElapsed` all return milliseconds (for easing drivers); `clipElapsed` - * returns SECONDS (for `evaluateClip`). All take `nowMs` as a parameter — they - * never read `performance.now()` or `Date.now()` themselves. The caller owns the - * wall clock; this keeps the clock deterministic and testable. `accumulateFollowPan` - * is the odd one out — it returns void, folding a follow-while-panning strafe into - * the clock's `followPanOffset` — but it lives here because that offset is - * follow-runtime state the same resource owns and `followElapsed` zeroes on focus. - * - * One module for all four functions because they share the `CameraClock` - * Resource: the halves are inseparable parts of one stateful computation. - * Same reasoning as `cameraDrivers.ts` holding both `runCameraDrivers` and - * `buildCameraDrivers`. - */ - -import type { CameraClock } from '../../../@types/engine/camera/CameraClock'; -import type { CameraTweenDescriptor } from '../../../@types/camera/CameraTweenDescriptor'; -import type { FrameTween } from '../../../@types/camera/FrameTween'; -import type { CameraPose } from '../../../@types/camera/CameraPose'; -import type { CameraState } from '../../../@types/camera/CameraState'; -import type { SelectionRow } from '../../../@types/engine/SelectionRow'; -import type { Vec3 } from '../../../@types/math/Vec3'; - -/** - * Create a fresh CameraClock with no recorded starts. - * Construct once per engine session; pass by reference to the elapsed fns - * on every frame. - */ -export function createCameraClock(): CameraClock { - return { - tweenStartMs: null, - autoRotateStartMs: null, - lastTweenRef: null, - lastAutoRotateActive: false, - frameTweenStartMs: null, - lastFrameTweenRef: null, - lastBaseRef: null, - clipStartMs: null, - lastClipRef: null, - followStartMs: null, - lastFollowRef: null, - followFrom: null, - followDistanceTarget: null, - followPanOffset: [0, 0, 0], - lastPanTarget: null, - }; -} - -/** - * Detect whether the tween descriptor reference changed; if so, reset the - * tween start to `nowMs` (or null when the new descriptor is null). Then - * return ms elapsed since the current tween started. - * - * A freshly-installed descriptor returns 0 on the arrival frame and grows on - * subsequent frames that carry the same reference. A null tween always - * returns 0. - * - * Reference identity is the correct signal: a `startCameraTween` dispatch - * installs a new object, so `!==` fires exactly once on the transition frame. - */ -export function tweenElapsed( - clock: CameraClock, - tween: CameraTweenDescriptor | null, - nowMs: number, -): number { - if (tween !== clock.lastTweenRef) { - clock.lastTweenRef = tween; - clock.tweenStartMs = tween === null ? null : nowMs; - } - return clock.tweenStartMs === null ? 0 : nowMs - clock.tweenStartMs; -} - -/** - * Detect whether the frame-tween descriptor reference changed; if so, reset the - * frame-roll start to `nowMs` (or null when the new descriptor is null). Then - * return ms elapsed since the current frame roll started. - * - * A freshly-installed descriptor returns 0 on the arrival frame and grows on - * subsequent frames that carry the same reference. A null frame tween always - * returns 0. - * - * Reference identity is the correct signal: an orientation-frame switch installs - * a new `FrameTween` object, so `!==` fires exactly once on the transition frame - * — the same identity-reset idiom `tweenElapsed` uses for camera tweens. - */ -export function frameTweenElapsed( - clock: CameraClock, - frameTween: FrameTween | null, - nowMs: number, -): number { - if (frameTween !== clock.lastFrameTweenRef) { - clock.lastFrameTweenRef = frameTween; - clock.frameTweenStartMs = frameTween === null ? null : nowMs; - } - return clock.frameTweenStartMs === null ? 0 : nowMs - clock.frameTweenStartMs; -} - -/** - * Reset the auto-rotate start to `nowMs` when the active bit flips OR when the - * `base` reference changes underneath an active spin; then return ms elapsed - * since that start. - * - * `spinAutoRotate` advances yaw from a FROZEN base by cumulative elapsed. A - * commit-on-edge (drag-release, focus settle) installs a NEW base object while - * `active` stays true — without the base-identity reset the accumulated elapsed - * would apply to the fresh base and jump the camera on resume. `base` only - * changes on a commit edge, never mid-continuous-spin, so a steady spin is - * never reset by this. - * - * Active false→true (or a base change) returns 0 on that frame then grows. Any - * transition to false returns 0. - */ -export function autoRotateElapsed( - clock: CameraClock, - active: boolean, - base: CameraPose, - nowMs: number, -): number { - if (active !== clock.lastAutoRotateActive || base !== clock.lastBaseRef) { - clock.lastAutoRotateActive = active; - clock.lastBaseRef = base; - clock.autoRotateStartMs = active ? nowMs : null; - } - return clock.autoRotateStartMs === null ? 0 : nowMs - clock.autoRotateStartMs; -} - -/** - * Detect whether the clip reference changed; if so, reset the clip start to - * `nowMs` (or null when the new clip is null). Then return SECONDS elapsed - * since the current clip started. - * - * Unit boundary: unlike `tweenElapsed` (which returns milliseconds for the - * easing driver), `clipElapsed` returns SECONDS because `evaluateClip` takes - * an `elapsedSec` parameter. The conversion is `(nowMs - clipStartMs) / 1000`. - * - * A freshly-installed clip reference returns 0 on the arrival frame and grows - * in seconds on subsequent frames that carry the same reference. A null clip - * always returns 0. - * - * Reference identity is the correct signal: a `startClip` dispatch installs a - * new `{ data, frame }` object, so `!==` fires exactly once on the - * transition frame — same pattern as `tweenElapsed`. - */ -export function clipElapsed(clock: CameraClock, clip: CameraState['clip'], nowMs: number): number { - if (clip !== clock.lastClipRef) { - clock.lastClipRef = clip; - clock.clipStartMs = clip === null ? null : nowMs; - } - return clock.clipStartMs === null ? 0 : (nowMs - clock.clipStartMs) / 1000; -} - -/** - * Reset the follow-approach start to `nowMs` when the focus ROW reference - * changes (a new / re-selected body, or focus leaving a body → null); then - * return ms elapsed since that start. - * - * Keys on the row REFERENCE, not the body id, so a re-select of the same body - * (a fresh `selectionRows.focus` object) restarts the ease exactly once — the - * same identity-reset pattern `tweenElapsed` uses. A drag mid-follow does not - * change the ref, so the ease is not restarted on drag-release: the camera - * resumes its saturated follow instead of re-approaching. - * - * On the reset edge it also NULLS `followFrom` AND `followDistanceTarget`, - * signalling the driver's `pose` to re-capture the live on-screen pose as the - * `from` the approach eases out of, and to re-seed the distance target to the - * framing distance (the INITIAL-APPROACH source — see `CameraClock`). The capture - * is split from this timer because only the driver (closing over `EngineState`) - * can see the live rendered pose (`lastPose`) and the body radius / FOV; this - * function sees only the store's focus ref. - * - * A freshly-selected body returns 0 on the arrival frame and grows on later - * frames carrying the same ref. A null focus always returns 0. - */ -export function followElapsed( - clock: CameraClock, - focusRow: SelectionRow | null, - nowMs: number, -): number { - if (focusRow !== clock.lastFollowRef) { - clock.lastFollowRef = focusRow; - clock.followStartMs = focusRow === null ? null : nowMs; - // Force the driver to re-capture the `from` pose and re-seed the framing - // distance target on the next produce (fresh-approach signal). - clock.followFrom = null; - clock.followDistanceTarget = null; - // A NEW focus starts centred: zero the strafe offset and drop the drag-delta - // chain. (Any focus ROW ref change is a new target, incl. re-selecting the - // same body — same identity-reset semantics the fields above use.) - clock.followPanOffset = [0, 0, 0]; - clock.lastPanTarget = null; - } - return clock.followStartMs === null ? 0 : nowMs - clock.followStartMs; -} - -/** - * Fold a follow-while-panning strafe into `clock.followPanOffset`. - * - * `isFollowDragFrame` is true only when a body is followed AND a drag gesture is - * the frame's winner (orbitDrag). On such frames the frame-to-frame delta of - * `cam.target` is PURE PAN: an orbit drag changes yaw/pitch (not target), and the - * body's own motion never touches `cam.target`, so the delta isolates the strafe - * with no body-motion contamination. Accumulating the delta (rather than reading - * `cam.target - bodyPosition`) is what keeps the offset clean across a drag while - * the body moves under it. - * - * Off a follow-drag frame the delta chain is dropped (`lastPanTarget = null`) so - * the next grab continues the existing offset instead of re-basing it — and the - * accumulated offset persists until `followElapsed` zeroes it on a fresh focus, - * which happens the next time followBody wins the frame (see `CameraClock`). - */ -export function accumulateFollowPan( - clock: CameraClock, - isFollowDragFrame: boolean, - camTarget: Vec3, -): void { - if (!isFollowDragFrame) { - clock.lastPanTarget = null; - return; - } - const last = clock.lastPanTarget; - if (last !== null) { - clock.followPanOffset = [ - clock.followPanOffset[0] + (camTarget[0] - last[0]), - clock.followPanOffset[1] + (camTarget[1] - last[1]), - clock.followPanOffset[2] + (camTarget[2] - last[2]), - ]; - } - clock.lastPanTarget = [camTarget[0], camTarget[1], camTarget[2]]; -} diff --git a/src/services/engine/camera/cameraDrivers.ts b/src/services/engine/camera/cameraDrivers.ts index 24a3b243fd..5a683f7c07 100644 --- a/src/services/engine/camera/cameraDrivers.ts +++ b/src/services/engine/camera/cameraDrivers.ts @@ -1,69 +1,32 @@ /** - * cameraDrivers — the camera-driver table and its resolver. - * - * The engine has several movers that each want to author the camera pose on a - * given frame: an in-flight focus tween, idle auto-rotate, an orbit-controls - * gesture, and a resting floor. They are resolved as a priority-ranked table - * rather than by call order in the frame body, so inserting a mover or changing - * who-beats-whom is a data edit, not surgery on the frame loop. - * - * `runCameraDrivers` collapses that into one rule: among the drivers that declare - * themselves active this frame, the highest `priority` wins, and ONLY that - * winner's `pose` is returned. Two properties fall out of this: - * - * - Single-writer arbitration. There is exactly one pose returned per frame — - * the chosen winner's. There is NO cooperative blending of multiple drivers - * into one frame. A frame is authored by one driver. Blending would - * reintroduce the 'who contributed what, in what order' entanglement this - * seam exists to remove. - * - * - Precedence is data. The ordering between movers is a `priority` number on - * each driver, not a position in a sequence of statements. Re-ranking or - * slotting in a new mover is a one-line declaration. - * - * `pickWinner` is exported so `activeDriverId` can call it and get the SAME - * winner, guaranteeing that the driver's `pose` and the commit-on-edge guard - * never disagree (invariant 1 of the frame-ordering contract). - * - * `buildCameraDrivers` produces the six-row table that reads directly from the - * Redux store. Most drivers' `isActive` and `pose` read only `s.camera.*`; `cam` - * is forwarded to the `orbitDrag` driver, which reads `state.cam` (the gesture - * register) for its live yaw/pitch/distance. The `followBody` driver is the one - * that needs the engine snapshot, so `buildCameraDrivers(state)` closes over - * `EngineState`: follow reads the per-frame body-state snapshot (primed by - * `runFrame` before produce), the live lens FOV, and the follow ease clock. - * - * Priorities: clip 95 > orbitDrag 80 > tween 60 > autoRotate 20 > followBody 10 - * > resting 0. The gap between each step is deliberate headroom so a future - * driver can slot in without renumbering. - * - * Body focus is UN-BRAIDED into two concerns: the focused body owns the PIVOT - * (the pose target), and whichever driver wins owns the ORBIT terms (yaw / pitch - * / distance). The pivot is applied uniformly by the frame-loop pivot-pin - * (`applyFocusedBodyPivot`) to every driver that declares `pivotsOnFocusedBody` - * — so a drag orbits around the moving body, and the autoRotate button spins - * around it, without the follow driver having to win the whole pose. `followBody` - * therefore sits LOW (10, below autoRotate): its only remaining job is the - * initial approach ease + the idle steady hold, which it authors when nothing - * higher is active. A held drag or an active spin takes the orbit terms; the pin - * keeps the body centred throughout. The clip@95 and tween@60 rows share ONE - * evaluator: both produce their pose through `evaluateClip` (the tween via - * `tweenToClip`), differing only in priority and which `camera.*` descriptor - * they read; neither pins (they keyframe a full path including the target). + * cameraDrivers — the camera-driver table and its resolver. Among the drivers + * active this frame the highest `priority` wins and ONLY its `pose` is used: + * one author per frame, no blending; precedence is data, not call order. + * Priorities: clip 95 > orbitDrag 80 > tween 60 > followApproach 55 > + * autoRotate 20 > followHold 10 > resting 0 (gaps are headroom). Body focus is + * un-braided: the focused body owns the PIVOT (applied by the frame-loop pin to + * every driver flagged `pivotsOnFocusedBody`), the winning driver owns the + * orbit terms — which is why the follow HOLD sits below autoRotate and the drag. */ import type { CameraDriver } from '../../../@types/engine/camera/CameraDriver'; -import type { CameraPose } from '../../../@types/camera/CameraPose'; -import type { OrbitCamera } from '../../../@types/camera/OrbitCamera'; -import type { EngineState } from '../../../@types/engine/state/EngineState'; +import type { DriverCtx } from '../../../@types/engine/camera/DriverCtx'; +import type { FramedCameraPose } from '../../../@types/camera/FramedCameraPose'; +import type { FramedClipPose } from '../../../@types/animation/FramedClipPose'; +import type { Mat3 } from '../../../@types/math/Mat3'; import type { RootState } from '../../../store/types'; -import type { CameraClock } from '../../../@types/engine/camera/CameraClock'; -import { poseOf } from './poseOf'; +import type { CameraEpochs } from '../../../@types/engine/camera/CameraEpochs'; +import type { FollowMemory } from '../../../@types/engine/camera/FollowMemory'; +import type { Vec3 } from '../../../@types/math/Vec3'; +import { absoluteArm } from '../../../utils/camera/absoluteArm'; +import { eyeMpcOf } from '../../../utils/camera/eyeMpcOf'; +import { orbitAnglesLookingAlong } from '../../../utils/camera/orbitAnglesLookingAlong'; import { tweenToClip } from './tweenToClip'; import { spinAutoRotate } from './spinAutoRotate'; -import { tweenElapsed, autoRotateElapsed, clipElapsed, followElapsed } from './cameraClock'; -import { evaluateClip } from './evaluateClip'; +import { elapsedMs } from './cameraEpochs'; +import { evaluateFramedClip } from './evaluateClip'; import { reencodePose } from '../../../utils/camera/reencodePose'; +import { decodeBodyFixedChannels } from '../../../utils/camera/decodeBodyFixedChannels'; import { bodyFocusDistance } from './bodyFocusDistance'; import { ORIENTATION_FRAMES } from '../../../data/orientation/orientationFrames'; import { SCALE_UNITS } from '../../../data/scaleUnits'; @@ -71,352 +34,279 @@ import { FOCUS_TWEEN_MS } from './focusTweenDuration'; import { liveBodyPosition } from './liveBodyPosition'; import { bodyMovesThisFrame } from '../../../utils/scene/bodyMovesThisFrame'; import { easeOutCubic } from '../../../utils/math/easeOutCubic'; +import { isFollowDriverId } from '../../../utils/camera/isFollowDriverId'; import { lerp } from '../../../utils/math/lerp'; -/** - * Pick the highest-priority active driver. A pure max-scan over `drivers`: - * never mutates the array, never allocates an intermediate sort copy. The - * always-active `resting` floor (priority 0) ensures the result is never null - * in normal operation; the defensive fallback (`drivers[0]!`) only fires for an - * empty list. - * - * Exported so `activeDriverId` can call the exact same function and guarantee - * that 'who produced the pose' and 'what was the winning id' are decided by one - * code path, not two independent scans that could disagree. - */ -export function pickWinner(drivers: readonly CameraDriver[], s: RootState): CameraDriver { +/** The frame's single author: highest `priority` among the active rows. */ +export function pickWinner( + drivers: readonly CameraDriver[], + s: RootState, + approachDone = false, +): CameraDriver { let winner: CameraDriver | null = null; for (const d of drivers) { - if (!d.isActive(s)) continue; + if (!d.isActive(s, approachDone)) continue; if (winner === null || d.priority > winner.priority) winner = d; } - // Defensive fallback: the resting floor makes this unreachable in normal - // operation; it fires only for an empty list. + // Only an empty table reaches the fallback; `resting` is always active. return winner ?? drivers[0]!; } -/** - * Compute the elapsed value for whichever driver won this frame. - * - * `orbitDrag` and `resting` are stateless — they do not use elapsed time, - * so 0 is the correct and only sensible value. `tween` and `autoRotate` - * both need cumulative elapsed time from their respective clocks. `clip` - * needs cumulative elapsed in SECONDS (not ms) — `evaluateClip` takes - * `elapsedSec`. Dispatching on `winner.id` is a table lookup keyed on a - * stable string — cleaner than a chain of `if (driver === tweenDriver)` - * identity checks. - * - * UNIT NOTE: the returned value is passed straight to the winner's `pose`. - * Each driver interprets it in its own unit — `tween` and `autoRotate` expect - * ms; `clip` expects SECONDS. This is intentional: the generic `elapsedMs` - * name on the CameraDriver type is approximate; do not 'fix' the clip arm to - * multiply by 1000. - */ -function elapsedForWinner( +/** `epochs` must already be advanced for this frame (the step does it once, at the winner). */ +export function elapsedForWinner( winner: CameraDriver, - s: RootState, - clock: CameraClock, + epochs: CameraEpochs, nowMs: number, ): number { - if (winner.id === 'clip') return clipElapsed(clock, s.camera.clip, nowMs); // returns SECONDS - if (winner.id === 'tween') return tweenElapsed(clock, s.camera.tween, nowMs); // returns ms - if (winner.id === 'autoRotate') - return autoRotateElapsed(clock, s.camera.autoRotate.active, s.camera.base, nowMs); // returns ms - if (winner.id === 'followBody') - // Keys on the focus ROW reference so a new / re-selected body restarts the - // approach ease; a drag mid-follow leaves it untouched. returns ms. - return followElapsed(clock, s.selectionRows.focus, nowMs); - // orbitDrag and resting are stateless; elapsed is irrelevant to their pose. - return 0; + return winner.epoch === undefined ? 0 : elapsedMs(epochs[winner.epoch], nowMs); } +/** The no-memory default; exported so a fixture pinning the memory never branches on null. */ +export const NO_FOLLOW_MEMORY: FollowMemory = { + from: null, + distanceTarget: null, + panOffset: [0, 0, 0], + saturated: false, +}; + /** - * Resolve the per-frame camera pose. - * - * Calls `pickWinner` once, computes the winner's elapsed time via - * `elapsedForWinner`, and delegates to the winner's `pose`. The caller - * receives a single `CameraPose` — the frame is authored by one driver. - * - * The `clock` parameter is mutated as a side effect: `tweenElapsed` and - * `autoRotateElapsed` detect descriptor-identity changes and reset start times. - * The clock must therefore be called exactly once per frame — this function - * enforces that contract by being the sole caller of the elapsed helpers in the - * hot path. + * The follow conditions both follow rows share. Active only for a body the sim + * clock MOVES: a static focus (a famous star, the Sun) carries a position but is + * not followed. Gated on the absolute arm (spec §7): the ease has no meaning + * once the state co-rotates with the body. */ -export function runCameraDrivers( - drivers: readonly CameraDriver[], - s: RootState, - cam: OrbitCamera, - clock: CameraClock, - nowMs: number, -): CameraPose { - const winner = pickWinner(drivers, s); - const elapsed = elapsedForWinner(winner, s, clock, nowMs); - return winner.pose(s, cam, elapsed); +function followActive(s: RootState): boolean { + return s.camera.base.frame === 'absolute' && bodyMovesThisFrame(s.selectionRows.focus); } /** - * Build the engine's camera-driver table — six rows, store-reading, returned - * in priority order for readability (the resolver uses a max-scan, so order - * does not affect correctness). - * - * The `state` parameter is closed over for the `followBody` driver alone (see - * below); the other five read only `RootState` and mutate neither `state.cam` - * nor `EngineState`. - * - * Drivers, highest priority first: - * - * - `clip` (95) — an in-flight animation clip. Active while `s.camera.clip` - * is non-null. Owns the camera above orbitDrag so a playing clip cannot - * be interrupted by a drag gesture. `commitsOnEdge: true` bakes the clip's - * final pose into `base` on deactivation — the camera holds the last frame - * of the clip rather than snapping back to the pre-clip base. Elapsed is - * in SECONDS (evaluateClip's unit). - * - * - `orbitDrag` (80) — the live gesture register (`state.cam`). Active while - * `s.camera.dragging` is true. Returns `poseOf(cam)` so the controls keep - * directly manipulating the register without re-composing through the store. - * `pivotsOnFocusedBody`: while a body is focused the pivot-pin overwrites the - * dragged target with the live body, so a drag orbits around the moving body - * (no drift) rather than a frozen point. - * - * - `followBody` (10) — a focus on a moving scene body. Active while - * `s.selectionRows.focus` is a body the sim clock propagates (the shared - * `bodyMovesThisFrame` predicate). Sits BELOW autoRotate - * so it only wins when the scene is otherwise idle: its remaining job is the - * initial approach ease + the steady hold. On activation it eases the distance - * from the captured on-screen pose into the `bodyFocusDistance` framing - * distance over `FOCUS_TWEEN_MS`. Once a drag has committed a zoom into `base` - * (or a wheel edited `clock.followDistanceTarget` directly), follow eases - * toward that user distance instead (the two distance sources are un-braided - * via `clock.followDistanceTarget` — see CameraClock), so zoom-while-following - * is preserved rather than the framing distance being re-asserted each frame. - * `commitsOnEdge: true` bakes the last follow pose into `base` on - * deactivation, so lower drivers resume from where the camera is (no snap- - * back). The moving-target problem that once forced follow to own the whole - * pose is now solved by the shared pivot-pin, so follow no longer needs to - * outrank autoRotate / the drag. - * - * - `tween` (60) — an in-flight focus tween. Active while `s.camera.tween` - * is non-null. Pure: reads `s.camera.tween` + `elapsedMs` from the clock, - * converts descriptor via `tweenToClip`, calls `evaluateClip(data, elapsed/1000)` - * against `tween.frame` (the pinned start frame), then re-encodes forward - * into the current `settings.orientation` — same pinning contract as `clip`. - * - * - `autoRotate` (20) — the idle drift. Active while - * `s.camera.autoRotate.active` is true. Pure: returns - * `spinAutoRotate(s.camera.base, rate, elapsedMs)` — base is frozen while - * active, giving a rate-accurate spin regardless of frame rate. - * `pivotsOnFocusedBody`: with a body focused the spin orbits around the live - * body (the pivot-pin re-centres it), which is why it outranks followBody. - * - * - `resting` (0) — always active; returns `s.camera.base` as-is. The - * permanent floor that guarantees the resolver always has a winner. Also - * pivots on a focused body (belt-and-braces; followBody normally wins the - * idle-follow case). + * One produce for both follow rows: same pose, same returned memory, and the + * ease reads the same `follow` epoch. The approach yields only AFTER a frame it + * saturated, so the hand-off pose is `lerp(_, _, 1)` on both sides — identical + * bit for bit, whatever the frame phase. */ -export function buildCameraDrivers(state: EngineState): readonly CameraDriver[] { - return [ - { - id: 'clip', - priority: 95, - // When the clip ends, bake its final pose into `base` so the camera - // holds the saturated pose (not snap back to whatever base was before - // the clip started). Task 10's frame loop reads this flag; it does not - // hardcode 'clip' as a string. - commitsOnEdge: true, - isActive: (s) => s.camera.clip !== null, - // elapsed here is SECONDS from clipElapsed (not ms) — evaluateClip - // takes elapsedSec. See the UNIT NOTE in elapsedForWinner. `clip.frame` - // (pinned at dispatch time, never the live setting) is the STEADY basis - // a flyPath's world tangents encode through — a fixed reference is what - // makes `evaluateClip`'s compile cache stable across an in-flight - // orientation switch (see evaluateClip's Cached type). The evaluated pose - // is then re-encoded forward into the CURRENT frame — reencodePose returns - // it by reference when the two bases match, the overwhelmingly common case. - pose: (s, _cam, elapsed) => { - const clip = s.camera.clip!; - const evaluated = evaluateClip(clip.data, elapsed, ORIENTATION_FRAMES[clip.frame]); - return reencodePose( - evaluated, - ORIENTATION_FRAMES[clip.frame], - ORIENTATION_FRAMES[s.settings.orientation], - ); - }, - }, - { - id: 'orbitDrag', - priority: 80, - // Orbit driver: while a body is focused the frame loop pins the pose target - // to the live body so a drag orbits AROUND the moving body instead of a - // frozen point (the body would otherwise drift out from under the cursor - // mid-drag). Drag owns yaw/pitch/distance; the body owns the pivot. - pivotsOnFocusedBody: true, - isActive: (s) => s.camera.dragging, - // The live drag register is the source of truth while the gesture is - // held — poseOf reads yaw/pitch/distance/target off the OrbitCamera - // that orbitControls mutates in real time. The target it reads is only - // used when NO body is focused; otherwise the pivot-pin overwrites it. - pose: (_s, cam) => poseOf(cam), - }, - { - id: 'followBody', - priority: 10, - // Leaving focus (null / a non-body row) deactivates the row; the last - // follow pose is baked into `base` so lower drivers resume from where the - // camera actually is rather than snapping back to the pre-focus base. - commitsOnEdge: true, - // followBody is an orbit driver too: the pivot-pin re-centres its steady - // hold on the live body (its own `pose` already targets the body, so this - // is idempotent — but it keeps the pin's rule uniform across every orbit - // driver rather than special-casing followBody out of it). - pivotsOnFocusedBody: true, - // Active when the focus resolves to a scene body the sim clock MOVES - // (`bodyMovesThisFrame` — an `ORBITAL_ELEMENTS` row). Following is a - // response to motion, so a static focus (a famous star, the Sun) does not - // activate it even though the body snapshot carries its position; a star / - // structure / galaxy focus is not a body at all. Priority 10 (below - // autoRotate 20) means followBody only wins when idle: autoRotate or a - // drag takes the orbit terms while the pivot-pin keeps the body centred, - // so the autoRotate button spins AROUND a focused body instead of being - // blocked by follow. - isActive: (s) => bodyMovesThisFrame(s.selectionRows.focus), - // The follow pose. `elapsed` is ms since the approach started (from - // `followElapsed`, keyed on the focus row reference). The target term is - // always the LIVE body position, so the camera tracks the body the sim - // clock is moving. yaw/pitch translate-follow (they ease from the captured - // on-screen pose toward the committed base, so a post-follow drag is - // honoured while an un-dragged follow keeps its heading). - // - // Distance has TWO sources, un-braided via `clock.followDistanceTarget` - // (see CameraClock): an INITIAL APPROACH eases into the framing distance; - // once a drag has committed a zoom into `base`, follow eases toward THAT - // committed `base.distance` instead, so the user can zoom while following - // rather than having the framing distance re-asserted every frame. - pose: (s, _cam, elapsed) => { - const focus = s.selectionRows.focus; - const clock = state.cameraRuntime.clock; - // Defensive: pose only runs for the winner, so isActive already proved a - // moving body focus this same frame, and a moving body is in the snapshot - // by construction — but a null-guard keeps the arm total, falling back to - // the resting pose. The position itself comes from the shared - // `liveBodyPosition` site. - const livePos = liveBodyPosition(focus, state.cameraRuntime.lastRenderedSimDays.current); - if (focus === null || focus.type !== 'body' || livePos === null) return s.camera.base; +function followPose( + ctx: DriverCtx, + mem: FollowMemory | null, +): { readonly pose: FramedCameraPose; readonly memory: FollowMemory | null } { + const s = ctx.state; + const focus = s.selectionRows.focus; + const base = s.camera.base; + const livePos = liveBodyPosition(focus, ctx.bodies); + // Null-guard keeps the arm total; isActive already proved a moving body. + if (focus === null || focus.type !== 'body' || livePos === null) { + return { pose: base, memory: mem }; + } + if (base.frame !== 'absolute') return { pose: base, memory: mem }; - // Capture the `from` pose ONCE per activation. `followElapsed` nulls it - // on the edge; the first produce after fills it from the LIVE rendered - // pose (the previous frame's, not yet overwritten this frame) so the ease - // starts where the camera visibly is — switching focus A→B eases from - // framing-A, never jumping back to the committed base first. - if (clock.followFrom === null) { - const cur = state.cameraRuntime.lastPose.current; - clock.followFrom = { - target: [cur.target[0], cur.target[1], cur.target[2]], - yaw: cur.yaw, - pitch: cur.pitch, - distance: cur.distance, - roll: cur.roll, - }; - } - const from = clock.followFrom; - const base = s.camera.base; + // Captured ONCE per activation (`runFrame` nulls the memory on the focus + // edge) through the EYE, not the angles: `approachTiltedPose` is eye-preserving + // by construction, so authored and displayed registers now yield an + // identical capture (why eye, not angle, is carried across — R12b-1). + // Eye-preserving against the NEW target: `from` is read against + // `livePos` below, so a capture relative to the OLD target silently + // changes meaning on a body switch — an Earth-orbit distance read from + // Saturn's centre is INSIDE Saturn, where the fold engages and the + // absolute-arm gate strands the camera. + const memory = mem ?? NO_FOLLOW_MEMORY; + let from = memory.from; + if (from === null) { + const cur = ctx.authoredWorld; + const pb = ctx.poseBasis; + const eye = eyeMpcOf(cur, pb); + const rel: Vec3 = [livePos[0] - eye[0], livePos[1] - eye[1], livePos[2] - eye[2]]; + const ang = orbitAnglesLookingAlong(rel, pb); + from = { + target: [livePos[0], livePos[1], livePos[2]], + yaw: ang.yaw, + pitch: ang.pitch, + distance: Math.hypot(rel[0], rel[1], rel[2]), + roll: cur.roll, + }; + } - // Resolve the distance target for this frame (the two-source un-braid). - if (clock.followDistanceTarget === null) { - // Fresh focus (followElapsed nulled it): seed the INITIAL-APPROACH - // target to the framing distance. Call `bodyFocusDistance` directly — - // allocation-free, unlike `bodyLikeFraming`, which builds a FocusFraming - // object + target array of which only `.distance` is read here. Computed - // only in this branch, so the per-frame steady path skips the tan(). - const radiusMpc = focus.radiusM * SCALE_UNITS.M_TO_MPC; - clock.followDistanceTarget = bodyFocusDistance( - radiusMpc, - state.cameraRuntime.projection.fovYRad, - ); - } else if (state.cameraRuntime.prevActiveId.current !== 'followBody') { - // Follow re-won this frame but was not last frame's winner, and the - // focus ref is unchanged (else followDistanceTarget would be null): a - // drag (or clip) interrupted the follow and committed a new pose into - // `base`. Re-capture `base.distance` as the STEADY-STATE target so the - // user's zoom sticks instead of snapping back to the framing distance. - clock.followDistanceTarget = base.distance; - } - const distanceTarget = clock.followDistanceTarget; + // Distance target, two sources (see FollowMemory): a fresh focus seeds + // the framing distance — `bodyFocusDistance` directly, allocation-free, + // only on this branch; follow re-winning after a drag committed a zoom + // (last frame's winner was some OTHER row, same focus ref) re-captures + // `base.distance` so the zoom sticks. + // A third source is the wheel: `base` is invisible while a follow row wins + // (it re-asserts its own target every frame), so the notch a following + // camera swallows arrives already resolved to a distance and is simply + // adopted. The drain only routes one with a target already captured and + // a follow row winning last frame, so the three sources never compete. + let distanceTarget = memory.distanceTarget; + if (distanceTarget === null) { + const radiusMpc = focus.radiusM * SCALE_UNITS.M_TO_MPC; + distanceTarget = bodyFocusDistance(radiusMpc, ctx.projection.fovYRad); + } else if (!isFollowDriverId(ctx.winnerLastFrame)) { + distanceTarget = base.pose.distance; + } else if (ctx.followDistanceTarget !== null) { + distanceTarget = ctx.followDistanceTarget; + } - const t = easeOutCubic(elapsed / FOCUS_TWEEN_MS); - return { - // Alias the live snapshot position (a fresh, immutable per-frame array - // downstream reads read-only) — the target is the body, always. (The - // frame-loop pivot-pin sets the same value; keeping it here means the - // driver's pose is correct in isolation too.) - target: livePos, - yaw: lerp(from.yaw, base.yaw, t), - pitch: lerp(from.pitch, base.pitch, t), - distance: lerp(from.distance, distanceTarget, t), - }; - }, - }, - { - id: 'tween', - priority: 60, - // Bake the tween's final pose into `base` on deactivation so that a - // tween-to-focus lands cleanly rather than snapping to the pre-tween base. - // The baked pose is already re-encoded into the CURRENT frame below, so - // commit-on-edge never bakes a stale pinned-frame reading. - commitsOnEdge: true, - isActive: (s) => s.camera.tween !== null, - // `tween.frame` (pinned at dispatch time, same contract as `clip.frame`) - // is the STEADY basis `from`/`to` were captured through. `tweenToClip` - // converts the descriptor to a ClipData (memoised by reference) so - // `evaluateClip`'s compile cache reuses tracks across frames; the result - // is then re-encoded forward into the CURRENT frame — reencodePose - // returns it by reference when the two bases match, the common case. - pose: (s, _cam, elapsedMs) => { - const tween = s.camera.tween!; - const evaluated = evaluateClip( - tweenToClip(tween), - elapsedMs / 1000, - ORIENTATION_FRAMES[tween.frame], - ); - return reencodePose( - evaluated, - ORIENTATION_FRAMES[tween.frame], - ORIENTATION_FRAMES[s.settings.orientation], - ); - }, + const t = easeOutCubic(ctx.elapsedMs / FOCUS_TWEEN_MS); + return { + pose: absoluteArm({ + target: livePos, + // Eases toward the committed `base`: honours a post-follow drag, keeps heading when un-dragged. + yaw: lerp(from.yaw, base.pose.yaw, t), + pitch: lerp(from.pitch, base.pose.pitch, t), + distance: lerp(from.distance, distanceTarget, t), + // Roll rides like yaw/pitch: the approach frame-alignment (ruling 8) + // lands per wheel notch, and dropping it pinned a followed approach + // to scene-frame up until the engage edge. + roll: lerp(from.roll ?? 0, base.pose.roll ?? 0, t), + }), + memory: { from, distanceTarget, panOffset: memory.panOffset, saturated: t >= 1 }, + }; +} + +/** + * The exit both keyframe rows share: an absolute evaluation is re-encoded from + * the clip's pinned basis into the CURRENT one (by reference when they match), + * a body-framed one is DECODED (spec §8) — its angles are about the body's own + * axes, which no orientation frame touches, so the re-encode must not run. + */ +function framedClipArm( + evaluated: FramedClipPose, + from: Readonly, + to: Readonly, +): FramedCameraPose { + const { frame, channels } = evaluated; + if (frame === 'absolute') return absoluteArm(reencodePose(channels, from, to)); + return { frame, pose: decodeBodyFixedChannels(channels, frame.body) }; +} + +/** The seven rows. Constant data: a driver sees the frame only through its `ctx`. */ +export const CAMERA_DRIVERS: readonly CameraDriver[] = [ + { + id: 'clip', + priority: 95, + epoch: 'clip', + // Holds the camera above orbitDrag in EITHER arm: a gesture handed back + // to a clip whose commit-on-edge bakes its own final pose would be + // discarded at pointerup (`replayInput` swallows the steps too). + commitsOnEdge: true, + isActive: (s) => s.camera.clip !== null, + // `clip.frame` (pinned at dispatch) is the STEADY basis the path's + // tangents encode through — a fixed reference keeps `evaluateClip`'s + // compile cache stable across an orientation switch; the result is + // re-encoded into the CURRENT frame (by reference when the bases match). + pose: (ctx, mem) => { + const s = ctx.state; + const clip = s.camera.clip!; + const pinned = ORIENTATION_FRAMES[clip.frame]; + // `clip` IS this playback: a frame leg's start converts once against the + // bodies of the frame the leg opens on, and a replay re-converts. + const evaluated = evaluateFramedClip(clip.data, ctx.elapsedMs / 1000, { + frameBasis: pinned, + bodies: ctx.bodies, + playback: clip, + }); + return { + pose: framedClipArm(evaluated, pinned, ctx.poseBasis), + memory: mem, + }; }, - { - id: 'autoRotate', - priority: 20, - // Bake the spin's accumulated yaw into `base` when auto-rotate stops, - // so resume picks up from the final heading rather than jumping back. - commitsOnEdge: true, - // Orbit driver: while a body is focused the pivot-pin re-centres the spin - // on the live body, so auto-rotate orbits AROUND the focused body. This is - // why followBody sits below autoRotate — the spin wins the orbit terms, the - // body owns the pivot. - pivotsOnFocusedBody: true, - isActive: (s) => s.camera.autoRotate.active, - // Spins from the FROZEN base: base does not update while autoRotate wins - // (commit-on-edge only fires on driver deactivation), so the yaw advances - // at the correct cumulative rate rather than a per-frame delta off a - // moving base. - pose: (s, _cam, elapsedMs) => - spinAutoRotate(s.camera.base, s.camera.autoRotate.rate, elapsedMs), + }, + { + id: 'orbitDrag', + priority: 80, + // The pin overwrites the dragged target with the live body, so a drag + // orbits AROUND a moving body; a no-op on a body arm, so one row serves + // both arms. + pivotsOnFocusedBody: true, + isActive: (s) => s.camera.dragging, + // The AUTHORED register, pre-projection (R12b-1): producing the displayed + // pose here re-pins its tilted angles and walks the eye every held frame. + pose: (ctx, mem) => ({ pose: ctx.register, memory: mem }), + }, + { + id: 'followApproach', + // 55 keeps the two relationships that matter and nothing else: ABOVE + // autoRotate (20), or the spin outranks a body switch and the camera never + // approaches — it holds the old body's distance, which over Saturn is + // inside the planet with the arm engaged (R14-3); BELOW tween (60), where + // follow already yields to an explicitly authored move. Not 60: a tie is + // broken by table order, which is order-dependence, not policy. + priority: 55, + epoch: 'follow', + // Bakes the last follow pose into `base` on focus loss, so lower drivers + // resume from where the camera is. + commitsOnEdge: true, + // Idempotent (the pose already targets the body); keeps the pin's rule uniform. + pivotsOnFocusedBody: true, + isActive: (s, approachDone = false) => followActive(s) && !approachDone, + pose: followPose, + }, + { + id: 'followHold', + // The steady follow, back under autoRotate and the drag: once the approach + // is saturated the row only re-asserts the body's own target, which the + // pivot pin gives those drivers anyway. + priority: 10, + epoch: 'follow', + commitsOnEdge: true, + pivotsOnFocusedBody: true, + isActive: followActive, + pose: followPose, + }, + { + id: 'tween', + priority: 60, + epoch: 'tween', + // Bakes the final pose on deactivation; it is already in the CURRENT + // frame, so the commit never bakes a stale pinned-frame reading. + commitsOnEdge: true, + isActive: (s) => s.camera.tween !== null, + // `tween.frame` (pinned at dispatch) is the STEADY basis `from`/`to` were + // captured through; `tweenToClip` memoises by reference so + // `evaluateClip`'s compile cache reuses tracks across frames. + pose: (ctx, mem) => { + const s = ctx.state; + const tween = s.camera.tween!; + const pinned = ORIENTATION_FRAMES[tween.frame]; + const evaluated = evaluateFramedClip(tweenToClip(tween), ctx.elapsedMs / 1000, { + frameBasis: pinned, + bodies: ctx.bodies, + playback: tween, + }); + return { + pose: framedClipArm(evaluated, pinned, ctx.poseBasis), + memory: mem, + }; }, - { - id: 'resting', - priority: 0, - // Orbit driver: the pivot-pin re-centres the resting pose on a focused - // body. In practice followBody (10) outranks resting whenever a body focus - // is pin-eligible, so this flag is belt-and-braces — it keeps the rule - // 'every orbit driver pivots on the focused body' complete. - pivotsOnFocusedBody: true, - isActive: () => true, - // At rest, the committed base IS the pose. No clock, no elapsed — pure - // identity read from the store. - pose: (s) => s.camera.base, + }, + { + id: 'autoRotate', + priority: 20, + epoch: 'autoRotate', + commitsOnEdge: true, + pivotsOnFocusedBody: true, + // Absolute arm only (spec §7): a yaw spin about the frame pole is not a + // thing a body-fixed arm expresses. + isActive: (s) => s.camera.autoRotate.active && s.camera.base.frame === 'absolute', + // Spins from the FROZEN base (it only changes on a commit edge), so yaw + // advances at the cumulative rate, not a per-frame delta off a moving base. + pose: (ctx, mem) => { + const base = ctx.state.camera.base; + // The isActive gate restated as the narrowing TS needs. + if (base.frame !== 'absolute') return { pose: base, memory: mem }; + return { + pose: absoluteArm( + spinAutoRotate(base.pose, ctx.state.camera.autoRotate.rate, ctx.elapsedMs), + ), + memory: mem, + }; }, - ]; -} + }, + { + id: 'resting', + priority: 0, + // Pivots too, so 'every orbit driver pivots on the focused body' holds without exception. + pivotsOnFocusedBody: true, + isActive: () => true, + pose: (ctx, mem) => ({ pose: ctx.state.camera.base, memory: mem }), + }, +]; diff --git a/src/services/engine/camera/cameraEpochs.ts b/src/services/engine/camera/cameraEpochs.ts new file mode 100644 index 0000000000..c029b3369e --- /dev/null +++ b/src/services/engine/camera/cameraEpochs.ts @@ -0,0 +1,85 @@ +/** + * cameraEpochs — pure Epoch primitives: an epoch resets when its reference + * changes and measures since; nothing here mutates. `advanceEpoch` is + * idempotent for an unchanged ref, so a second advance in one frame (the wheel + * fold reads autoRotate before the frame's advance) can never be a second reset. + */ + +import type { Epoch } from '../../../@types/engine/camera/Epoch'; +import type { CameraEpochs } from '../../../@types/engine/camera/CameraEpochs'; +import type { CameraState } from '../../../@types/camera/CameraState'; +import type { EpochRow } from '../../../@types/engine/camera/EpochRow'; +import type { SelectionRow } from '../../../@types/engine/SelectionRow'; + +/** Every row unstarted — the engine's boot value; immutable, so one shared object is fine. */ +export const UNSTARTED_EPOCHS: CameraEpochs = { + tween: { ref: null, startMs: null }, + frameTween: { ref: null, startMs: null }, + autoRotate: { ref: null, startMs: null }, + follow: { ref: null, startMs: null }, + clip: { ref: null, startMs: null }, +}; + +/** Same `ref` ⇒ `prev` back BY IDENTITY — the no-op guard callers key on. */ +export function advanceEpoch(prev: Epoch, ref: Ref | null, nowMs: number): Epoch { + if (ref === prev.ref) return prev; + return { ref, startMs: ref === null ? null : nowMs }; +} + +export function elapsedMs(epoch: Epoch, nowMs: number): number { + return epoch.startMs === null ? 0 : nowMs - epoch.startMs; +} + +/** + * Advances the five rows in one call. `tween`/`autoRotate`/`follow` read their + * live ref only when the winner names that epoch (else the ease would burn while + * some other driver, e.g. a drag, holds) — replaying `prev.ref` when ineligible + * makes `advanceEpoch` a guaranteed no-op for that row. `frameTween` has no gate: + * `resolveFrameBasis` reads it regardless of winner. `clip` is not advanced here + * at all — the clip player advances it before the frame step runs, so it is + * passed through by reference. + */ +export function advanceEpochs( + prev: CameraEpochs, + inputs: { + readonly intent: CameraState; + readonly focus: SelectionRow | null; + readonly clip: Epoch>; + readonly winnerEpoch: EpochRow | undefined; + readonly nowMs: number; + }, +): CameraEpochs { + const { intent, focus, clip, winnerEpoch, nowMs } = inputs; + + const tween = advanceEpoch( + prev.tween, + winnerEpoch === 'tween' ? intent.tween : prev.tween.ref, + nowMs, + ); + const autoRotate = advanceEpoch( + prev.autoRotate, + winnerEpoch === 'autoRotate' + ? intent.autoRotate.active + ? intent.base + : null + : prev.autoRotate.ref, + nowMs, + ); + const follow = advanceEpoch( + prev.follow, + winnerEpoch === 'follow' ? focus : prev.follow.ref, + nowMs, + ); + const frameTween = advanceEpoch(prev.frameTween, intent.frameTween, nowMs); + + if ( + tween === prev.tween && + autoRotate === prev.autoRotate && + follow === prev.follow && + frameTween === prev.frameTween && + clip === prev.clip + ) { + return prev; + } + return { tween, autoRotate, follow, frameTween, clip }; +} diff --git a/src/services/engine/camera/cameraFraming.ts b/src/services/engine/camera/cameraFraming.ts index 3f0b1751d4..7d402e57ff 100644 --- a/src/services/engine/camera/cameraFraming.ts +++ b/src/services/engine/camera/cameraFraming.ts @@ -24,7 +24,7 @@ * with no depth test, so depth precision is not a concern; the * pick pass uses depth32float, which handles the 0.01 : 50 000 * ratio fine. - * - `near = 0.01` Mpc (10 kpc) — well inside the focus-on tween's + * - `NEAR_CLIP_MPC = 0.01` (10 kpc) — well inside the focus-on tween's * end distance (0.12 Mpc, see `galaxyFocusDistance.ts`). * - `INITIAL_DISTANCE_MPC` — a Local-Group-scale distance the wheel-zoom * envelope + the grand tour still reference; no longer the boot distance. @@ -43,6 +43,9 @@ import { DEFAULT_FOV_DEG } from '../../../data/defaults'; /** Initial camera distance in Mpc — sits the viewer inside the Local Group. */ export const INITIAL_DISTANCE_MPC = 0.14; +/** Near-clip plane in Mpc; the one home for the literal every projection seed shares. */ +export const NEAR_CLIP_MPC = 0.01; + /** Far-clip plane in Mpc — keeps the horizon shell in-frustum at max camera distance. */ export const FAR_CLIP_MPC = 50000; @@ -98,5 +101,5 @@ export function computeInitialCamera({ ...orbitAnglesLookingAlong(GALACTIC_DISC_FORWARD, frameBasis), } : bodyHomePose(bodyId, simDays, fovYRad, frameBasis); - return { ...pose, fovYRad, near: 0.01, far: FAR_CLIP_MPC }; + return { ...pose, fovYRad, near: NEAR_CLIP_MPC, far: FAR_CLIP_MPC }; } diff --git a/src/services/engine/camera/clipFrameChannels.ts b/src/services/engine/camera/clipFrameChannels.ts new file mode 100644 index 0000000000..625531f50a --- /dev/null +++ b/src/services/engine/camera/clipFrameChannels.ts @@ -0,0 +1,68 @@ +/** + * clipFrameChannels — move a keyframe leg's START between frames, through the + * §5.1 pair in `poseFrameConversion`. Called ONCE per leg, never per frame: + * re-converting each frame would walk the start along with the body. The pair is + * lossless in the EYE and the basis but carries no orbit pivot, so the target + * crosses as a POINT and `toWorldArm`'s graze rule re-derives the pivot coming + * back: a round trip keeps eye and aim exactly, and may slide the target along + * the unchanged sightline. Nothing here converts Mpc↔metres — that stays the + * seam's alone (spec §10, `oneMpcSeam`). + */ + +import type { BodyId } from '../../../@types/data/body/BodyId'; +import type { BodyState } from '../../../@types/scene/BodyState'; +import type { CameraPose } from '../../../@types/camera/CameraPose'; +import type { Mat3 } from '../../../@types/math/Mat3'; +import type { Vec3 } from '../../../@types/math/Vec3'; +import { IDENTITY_MAT3 } from '../../../utils/math/identityMat3'; +import { bodyFixedEyeM } from '../../../utils/camera/bodyFixedEyeM'; +import { decodeBodyFixedChannels } from '../../../utils/camera/decodeBodyFixedChannels'; +import { orbitAnglesLookingAlong } from '../../../utils/camera/orbitAnglesLookingAlong'; +import { bodyRelativePose } from './bodyRelativePose'; +import { toBodyArm, resolveWorldArm } from './poseFrameConversion'; + +/** Absolute Mpc channels → the same camera in `bodyId`'s fixed axes, metres. */ +export function toBodyFixedChannels( + pose: CameraPose, + bodyId: BodyId, + bodies: ReadonlyMap, + basis: Readonly, +): CameraPose { + const bodyState = bodies.get(bodyId); + if (bodyState === undefined) { + throw new Error(`toBodyFixedChannels: body '${bodyId}' is unresolved this instant`); + } + const eyeArm = toBodyArm(pose, basis, basis, bodyId, bodyState); + const eyeM = bodyFixedEyeM(eyeArm); + // The target is a POINT in the same world frame as the eye, so it crosses to + // metres through provider A directly; only `eyeRelBodyM` is meaningful for a + // point, so the basis argument is inert. + const target = bodyRelativePose({ + camPosMpc: pose.target, + camBasisWorld: IDENTITY_MAT3, + bodyState, + }).eyeRelBodyM; + const forward: Vec3 = [eyeArm.basisLocal[6], eyeArm.basisLocal[7], eyeArm.basisLocal[8]]; + const { yaw, pitch } = orbitAnglesLookingAlong(forward); + return { + target, + yaw, + pitch, + distance: Math.hypot(eyeM[0] - target[0], eyeM[1] - target[1], eyeM[2] - target[2]), + }; +} + +/** Body-fixed metre channels → absolute Mpc channels. */ +export function fromBodyFixedChannels( + channels: CameraPose, + bodyId: BodyId, + bodies: ReadonlyMap, + basis: Readonly, +): CameraPose { + return resolveWorldArm( + { frame: { body: bodyId }, pose: decodeBodyFixedChannels(channels, bodyId) }, + bodies, + basis, + basis, + ); +} diff --git a/src/services/engine/camera/commitOnEdge.ts b/src/services/engine/camera/commitOnEdge.ts new file mode 100644 index 0000000000..445f900b10 --- /dev/null +++ b/src/services/engine/camera/commitOnEdge.ts @@ -0,0 +1,50 @@ +/** + * commitOnEdge — on the frame the winner changes, a DEPARTING driver that + * declared `commitsOnEdge` bakes its saturated register into `base` verbatim + * (R12b-1: the authored register, never the displayed pose). Produce already ran + * the INCOMING driver against the pre-commit `base`, so which pose covers the + * edge frame is that driver's (R12c-1): a pivoting one re-derives its image + * downstream and renders the AUTHORED register (displaying it would re-pin the + * tilt — one frame of eye walk); a non-pivoting one (clip, tween) would flash + * the untilted register ~0.4 rad to nadir, so it renders the DISPLAYED pose. + */ +import type { UnknownAction } from '@reduxjs/toolkit'; + +import type { CameraDriver } from '../../../@types/engine/camera/CameraDriver'; +import type { DriverId } from '../../../@types/engine/camera/DriverId'; +import type { FramedCameraPose } from '../../../@types/camera/FramedCameraPose'; +import { commitCameraPose } from '../../../state/camera/cameraSlice'; +import { isFollowDriverId } from '../../../utils/camera/isFollowDriverId'; + +const NO_ACTIONS: readonly UnknownAction[] = []; + +export function commitOnEdge(args: { + readonly register: FramedCameraPose; + readonly displayed: FramedCameraPose; + readonly produced: FramedCameraPose; + readonly prevWinner: DriverId; + readonly winner: CameraDriver; + readonly drivers: readonly CameraDriver[]; +}): { + readonly render: FramedCameraPose; + /** Non-null only on a non-pivoting edge: the register value when `render` had + * to be the displayed pose. */ + readonly authoredOverride: FramedCameraPose | null; + readonly actions: readonly UnknownAction[]; +} { + const { register, displayed, produced, prevWinner, winner, drivers } = args; + const departing = drivers.find((d) => d.id === prevWinner); + // The follow pair is ONE author: committing between them baked the OLD body's + // distance into `base`, which the pin then read around the NEW body. + const sameAuthor = + prevWinner === winner.id || (isFollowDriverId(prevWinner) && isFollowDriverId(winner.id)); + if (sameAuthor || !departing?.commitsOnEdge) { + return { render: produced, authoredOverride: null, actions: NO_ACTIONS }; + } + const pivots = winner.pivotsOnFocusedBody ?? false; + return { + render: pivots ? register : displayed, + authoredOverride: pivots ? null : register, + actions: [commitCameraPose(register)], + }; +} diff --git a/src/services/engine/camera/evaluateClip.ts b/src/services/engine/camera/evaluateClip.ts index 0491ff7cae..411c530e2a 100644 --- a/src/services/engine/camera/evaluateClip.ts +++ b/src/services/engine/camera/evaluateClip.ts @@ -73,21 +73,37 @@ * stable `ORIENTATION_FRAMES[frame]` object). Steady frames — the common case — * hit the cache and avoid re-flattening the effect tree every frame. * + * ### Frames + * + * A `set`/`setVec` endpoint may name the frame its `to` is read in (spec §8); + * untagged ⇒ absolute. Interpolation runs in the endpoint's own frame, so clip + * time splits into "legs", one per frame, and a leg's start is converted ONCE, + * at leg start. `spin`/`rate`/`osc` are relative writers: they act in whatever + * arm is current, never open a leg, and are otherwise untouched. Body-framed + * channels are body-FIXED metres — a LookAt the driver decodes at its exit. + * * ### Purity * * - `data` and the cached `CompiledClip` are never mutated. * - The returned `CameraPose` allocates a fresh `target` triple each call. - * - Same `(data, elapsedSec, frameBasis)` triple ⇒ deep-equal output. + * - Same `(data, elapsedSec, frameBasis)` triple ⇒ deep-equal output — EXCEPT + * across a frame change, where the leg-start conversion is captured against + * the bodies of the first call that reached the leg and held for the playback. */ import type { ClipData } from '../../../@types/animation/ClipData'; +import type { ClipFrameOptions } from '../../../@types/animation/ClipFrameOptions'; +import type { FramedClipPose } from '../../../@types/animation/FramedClipPose'; import type { CompiledClip, BaseSegment, VelRamp, PathTrack, } from '../../../@types/animation/CompiledClip'; +import type { BodyId } from '../../../@types/data/body/BodyId'; +import type { BodyState } from '../../../@types/scene/BodyState'; import type { CameraPose } from '../../../@types/camera/CameraPose'; +import type { PoseFrame } from '../../../@types/camera/PoseFrame'; import type { Channel } from '../../../@types/animation/Channel'; import type { Ease } from '../../../@types/animation/Ease'; import type { Vec3 } from '../../../@types/math/Vec3'; @@ -97,6 +113,7 @@ import { lerpInSpace } from '../animation/channelSpace'; import { EASE } from '../animation/ease'; import { lerpAngleShortest } from '../../../utils/math/lerpAngleShortest'; import { lerp } from '../../../utils/math/lerp'; +import { toBodyFixedChannels, fromBodyFixedChannels } from './clipFrameChannels'; // --------------------------------------------------------------------------- // Module-level compile cache — keyed on ClipData reference identity. @@ -294,6 +311,21 @@ function oscOffset(compiled: CompiledClip, ch: Channel, t: number): number { // Base layer — fold prior (ended) segments then apply the active/last one. // --------------------------------------------------------------------------- +/** + * The clip time a base walk answers for: `[fromSec, beforeSec)` on a segment's + * START. Earlier segments are already folded into the seed value, in this leg's + * frame. `beforeSec` tightens only when seeding the NEXT leg — a segment + * starting exactly where that leg opens states its `to` in the NEW leg's units, + * so folding it in leaks metres into an Mpc pose (or the reverse). + */ +type LegWindow = { readonly fromSec: number; readonly beforeSec: number }; + +const WHOLE_CLIP: LegWindow = { fromSec: -Infinity, beforeSec: Infinity }; + +function outsideLeg(seg: BaseSegment, window: LegWindow): boolean { + return seg.startSec < window.fromSec || seg.startSec >= window.beforeSec; +} + /** * evaluateBaseScalar — evaluate the base layer for a scalar channel at time t. * @@ -309,6 +341,7 @@ function evaluateBaseScalar( startVal: number, channel: Channel, t: number, + window: LegWindow, ): number { if (segments.length === 0) return startVal; @@ -316,6 +349,7 @@ function evaluateBaseScalar( for (let i = 0; i < segments.length; i++) { const seg = segments[i]!; + if (outsideLeg(seg, window)) continue; if (t < seg.startSec) { // Before this segment starts — running value from prior segments is the result. @@ -377,7 +411,12 @@ function evaluateBaseScalar( * component-wise in linear space (log-space and additive-angle semantics are * undefined for signed 3D positions). */ -function evaluateBaseVec3(segments: BaseSegment[], startVal: Vec3, t: number): Vec3 { +function evaluateBaseVec3( + segments: BaseSegment[], + startVal: Vec3, + t: number, + window: LegWindow, +): Vec3 { if (segments.length === 0) return [startVal[0], startVal[1], startVal[2]]; // Compute component-wise running Vec3. @@ -387,6 +426,7 @@ function evaluateBaseVec3(segments: BaseSegment[], startVal: Vec3, t: number): V for (let i = 0; i < segments.length; i++) { const seg = segments[i]!; + if (outsideLeg(seg, window)) continue; if (t < seg.startSec) { return [rx, ry, rz]; @@ -433,53 +473,178 @@ function activePathAt(paths: PathTrack[], t: number): PathTrack | null { return best; } +/** The pose a frame leg starts from, and the second it starts at. */ +type LegOrigin = { readonly atSec: number; readonly pose: CameraPose }; + +type FrameLeg = { + readonly frame: PoseFrame; + readonly startSec: number; + /** The endpoint that opened the leg — the memo key for its converted start. */ + readonly anchor: BaseSegment | null; +}; + +function frameKeyOf(frame: PoseFrame): string { + return frame === 'absolute' ? 'absolute' : frame.body; +} + +const legsCache = new WeakMap(); + +// Keyed on the PLAYBACK, not the compiled clip: replaying the same `ClipData` +// must re-convert against the bodies where they are now. +const legStartCache = new WeakMap>(); + +function frameLegsOf(compiled: CompiledClip): readonly FrameLeg[] { + const cached = legsCache.get(compiled); + if (cached !== undefined) return cached; + + // `'tween'` only: reading a relative `spin`'s absent tag as `'absolute'` sent + // an earth-framed pose back through the Mpc seam mid-orbit. + const segments = Object.values(compiled.baseTracks) + .flat() + .filter((seg) => seg.segKind === 'tween') + .sort((a, b) => a.startSec - b.startSec); + const legs: FrameLeg[] = [{ frame: 'absolute', startSec: -Infinity, anchor: null }]; + for (const seg of segments) { + const frame = seg.frame ?? 'absolute'; + if (frameKeyOf(frame) !== frameKeyOf(legs[legs.length - 1]!.frame)) { + legs.push({ frame, startSec: seg.startSec, anchor: seg }); + } + } + legsCache.set(compiled, legs); + return legs; +} + +function legIndexAt(legs: readonly FrameLeg[], t: number): number { + let index = 0; + for (let i = 1; i < legs.length; i++) { + if (legs[i]!.startSec <= t) index = i; + } + return index; +} + +type FrameDeps = { + readonly bodies: ReadonlyMap; + /** The clip's own steady basis — its absolute angles were authored through it. */ + readonly basis: Mat3; + /** Memo owner: the leg-start conversion is captured once per playback. */ + readonly owner: object; +}; + +function frameDepsOf(opts: ClipFrameOptions): FrameDeps { + const { bodies, frameBasis, playback } = opts; + if (bodies === undefined || frameBasis === undefined || playback === undefined) { + throw new Error( + 'evaluateFramedClip: a frame-tagged keyframe needs `bodies`, `frameBasis` and `playback`', + ); + } + return { bodies, basis: frameBasis, owner: playback }; +} + +function convertChannels( + channels: CameraPose, + from: PoseFrame, + to: PoseFrame, + deps: FrameDeps, +): CameraPose { + if (frameKeyOf(from) === frameKeyOf(to)) return channels; + const world = + from === 'absolute' + ? channels + : fromBodyFixedChannels(channels, from.body, deps.bodies, deps.basis); + return to === 'absolute' ? world : toBodyFixedChannels(world, to.body, deps.bodies, deps.basis); +} + +function originOfLeg( + compiled: CompiledClip, + legs: readonly FrameLeg[], + index: number, + opts: ClipFrameOptions, +): LegOrigin { + const leg = legs[index]!; + if (leg.anchor === null) return { atSec: -Infinity, pose: compiled.start }; + + const deps = frameDepsOf(opts); + let byAnchor = legStartCache.get(deps.owner); + if (byAnchor === undefined) { + byAnchor = new Map(); + legStartCache.set(deps.owner, byAnchor); + } + const hit = byAnchor.get(leg.anchor); + if (hit !== undefined) return { atSec: leg.startSec, pose: hit }; + + const previous = originOfLeg(compiled, legs, index - 1, opts); + const converted = convertChannels( + evaluateBaseAt(compiled, leg.startSec, previous, leg.startSec), + legs[index - 1]!.frame, + leg.frame, + deps, + ); + byAnchor.set(leg.anchor, converted); + return { atSec: leg.startSec, pose: converted }; +} + // --------------------------------------------------------------------------- -// Public entry point +// Layer composition // --------------------------------------------------------------------------- /** - * evaluateClip — evaluate the clip at `elapsedSec` seconds after its start. - * - * Compiles `data` on first call (memoised on reference identity); subsequent - * calls with the same `data` reference reuse the cached `CompiledClip`. - * - * Returns a fresh `CameraPose` with a fresh `target` array — no aliasing with - * any internal structure. - * - * @param data The authored clip description. - * @param elapsedSec Seconds since the clip started (≥ 0). - * @param frameBasis The STEADY orientation-frame basis a `flyPath` encodes its - * aim through (see `buildPathTrack`). Absent ⇒ identity - * (world-frame aim) — the pre-feature behaviour, so a clip - * with no `flyPath` is unaffected. - * @returns The camera pose at that instant. + * evaluateBaseAt — the base layer (or the path that supersedes it) at `t`, + * seeded from the current leg's origin. `origin.atSec` is `-Infinity` on the + * opening leg, so every segment counts and the walk is the pre-frames one. + * `beforeSec` is only passed when seeding the NEXT leg (see `LegWindow`). */ -export function evaluateClip(data: ClipData, elapsedSec: number, frameBasis?: Mat3): CameraPose { - const compiled = getCompiled(data, frameBasis); - const { start, baseTracks } = compiled; - const t = elapsedSec; - - // --- Base layer (a flyPath supersedes it for all four channels) --- +function evaluateBaseAt( + compiled: CompiledClip, + t: number, + origin: LegOrigin, + beforeSec = Infinity, +): CameraPose { + const { baseTracks } = compiled; const path = activePathAt(compiled.pathTracks, t); - let baseDistance: number; - let baseYaw: number; - let basePitch: number; - let baseTarget: Vec3; if (path !== null) { // Clamp into the path's own window: before it starts we never get here // (activePathAt requires startSec ≤ t); after it ends, hold the final pose. const localSec = Math.min(Math.max(t - path.startSec, 0), path.endSec - path.startSec); const pose = path.sample(localSec); - baseDistance = pose.distance; - baseYaw = pose.yaw; - basePitch = pose.pitch; - baseTarget = pose.target; - } else { - baseDistance = evaluateBaseScalar(baseTracks['distance'], start.distance, 'distance', t); - baseYaw = evaluateBaseScalar(baseTracks['yaw'], start.yaw, 'yaw', t); - basePitch = evaluateBaseScalar(baseTracks['pitch'], start.pitch, 'pitch', t); - baseTarget = evaluateBaseVec3(baseTracks['target'], start.target, t); + return { target: pose.target, yaw: pose.yaw, pitch: pose.pitch, distance: pose.distance }; } + const { atSec, pose: start } = origin; + const window: LegWindow = + atSec === -Infinity && beforeSec === Infinity ? WHOLE_CLIP : { fromSec: atSec, beforeSec }; + return { + target: evaluateBaseVec3(baseTracks['target'], start.target, t, window), + yaw: evaluateBaseScalar(baseTracks['yaw'], start.yaw, 'yaw', t, window), + pitch: evaluateBaseScalar(baseTracks['pitch'], start.pitch, 'pitch', t, window), + distance: evaluateBaseScalar(baseTracks['distance'], start.distance, 'distance', t, window), + }; +} + +// --------------------------------------------------------------------------- +// Public entry points +// --------------------------------------------------------------------------- + +/** + * evaluateFramedClip — the clip at `elapsedSec`, and the frame its channels are + * expressed in (spec §8). + * + * Compiles `data` on first call (memoised on reference identity); subsequent + * calls with the same `data` reference reuse the cached `CompiledClip`. + * + * `channels` is always fresh, with a fresh `target` array — no aliasing with + * any internal structure. + */ +export function evaluateFramedClip( + data: ClipData, + elapsedSec: number, + opts: ClipFrameOptions = {}, +): FramedClipPose { + const compiled = getCompiled(data, opts.frameBasis); + const legs = frameLegsOf(compiled); + const index = legIndexAt(legs, elapsedSec); + const origin = originOfLeg(compiled, legs, index, opts); + const t = elapsedSec; + + const base = evaluateBaseAt(compiled, t, origin); // --- Velocity layer (displacement, additive) --- const velDist = velDisplacement(compiled, 'distance', t); @@ -498,14 +663,34 @@ export function evaluateClip(data: ClipData, elapsedSec: number, frameBasis?: Ma const oscTarget = oscOffset(compiled, 'target', t); return { - // Fresh target triple — never alias any input. - target: [ - baseTarget[0] + velTarget + oscTarget, - baseTarget[1] + velTarget + oscTarget, - baseTarget[2] + velTarget + oscTarget, - ], - yaw: baseYaw + velYaw + oscYaw, - pitch: basePitch + velPitch + oscPitch, - distance: baseDistance + velDist + oscDist, + frame: legs[index]!.frame, + channels: { + // Fresh target triple — never alias any input. + target: [ + base.target[0] + velTarget + oscTarget, + base.target[1] + velTarget + oscTarget, + base.target[2] + velTarget + oscTarget, + ], + yaw: base.yaw + velYaw + oscYaw, + pitch: base.pitch + velPitch + oscPitch, + distance: base.distance + velDist + oscDist, + }, }; } + +/** + * evaluateClip — the channel values at `elapsedSec`, WITHOUT their frame: Mpc + * and orientation-frame angles for every untagged clip, which is every clip in + * the registry. A caller that must survive a body-framed endpoint — where the + * same four numbers are body-fixed metres — reads `evaluateFramedClip` instead. + * + * @param data The authored clip description. + * @param elapsedSec Seconds since the clip started (≥ 0). + * @param frameBasis The STEADY orientation-frame basis a `flyPath` encodes its + * aim through (see `buildPathTrack`). Absent ⇒ identity + * (world-frame aim) — the pre-feature behaviour, so a clip + * with no `flyPath` is unaffected. + */ +export function evaluateClip(data: ClipData, elapsedSec: number, frameBasis?: Mat3): CameraPose { + return evaluateFramedClip(data, elapsedSec, { frameBasis }).channels; +} diff --git a/src/services/engine/camera/frameAlignedRoll.ts b/src/services/engine/camera/frameAlignedRoll.ts new file mode 100644 index 0000000000..e054bf41e3 --- /dev/null +++ b/src/services/engine/camera/frameAlignedRoll.ts @@ -0,0 +1,91 @@ +/** + * frameAlignedRoll — the world-arm frame transition (rulings 8 + 10): the roll TARGET is + * the ONE reference field (`blendedUpDir` on `bodyUpWeight`'s band, read in the image plane), + * and each driven notch applies the ONE settle discipline (`riddenOrientStepRad`). At the + * engage flip both arms' targets are the same function of altitude, making the zoom pop + * unrepresentable; above the band the target is the scene up and the formula reduces to the + * singular-locus drain, at the ruled cost of arrival roll on at-rest notches. `logZoom` is the + * zoom the CALLER's pivot spent, except where the envelope pins (`zoomedDistance` scales + * ALTITUDE, so no pose ratio recovers it), and with `rideBoundRad` bounds one notch's turn. + */ + +import type { BodyId } from '../../../@types/data/body/BodyId'; +import type { BodyState } from '../../../@types/scene/BodyState'; +import type { CameraPose } from '../../../@types/camera/CameraPose'; +import type { CameraTuning } from '../../../@types/camera/CameraTuning'; +import type { Mat3 } from '../../../@types/math/Mat3'; +import type { Vec3 } from '../../../@types/math/Vec3'; +import { ORIENT_DECAY } from '../../../data/camera/orientDecay'; +import { blendedUpDir } from '../../../utils/camera/blendedUpDir'; +import { bodyUpWeight } from '../../../utils/camera/bodyUpWeight'; +import { eyeMpcOf } from '../../../utils/camera/eyeMpcOf'; +import { frameUp } from '../../../utils/camera/frameUp'; +import { imagePlaneBasis } from '../../../utils/camera/imagePlaneBasis'; +import { riddenOrientStepRad } from '../../../utils/camera/riddenOrientStepRad'; +import { rollFromScreenUp } from '../../../utils/camera/rollFromScreenUp'; +import { normalize3 } from '../../../utils/math/normalize3'; +import { rotateVec3ByTightMat3 } from '../../../utils/math/rotateVec3ByTightMat3'; +import { wrapRad } from '../../../utils/math/wrapRad'; +import { nearestBodyHR } from './nearestBodyHR'; + +/** + * The field-defined roll target at this pose; `null` when no target exists + * (empty roster, forward down the frame pole — roll itself is undefined + * there — or the field degenerate with nothing to carry): callers hold the + * roll. The pose's own screen-up is the hold-and-transport carry, so inside + * the singular neighbourhood the target IS the current roll and the settle + * is inert. Exported for the camera debug readout, the one other consumer. + */ +export function bandRollTarget( + pose: CameraPose, + bodyStates: ReadonlyMap, + poseBasis: Readonly, + upBasis: Readonly, + tuning: CameraTuning, +): number | null { + const eyeMpc = eyeMpcOf(pose, poseBasis); + const nearest = nearestBodyHR(eyeMpc, bodyStates); + if (nearest === null) return null; + + const forward = normalize3([ + pose.target[0] - eyeMpc[0], + pose.target[1] - eyeMpc[1], + pose.target[2] - eyeMpc[2], + ]); + const upRef = frameUp(upBasis); + const upVert = upRef[0] * forward[0] + upRef[1] * forward[1] + upRef[2] * forward[2]; + const upPlaneSq = + upRef[0] * upRef[0] + upRef[1] * upRef[1] + upRef[2] * upRef[2] - upVert * upVert; + if (upPlaneSq < 1e-18) return null; // forward ∥ frame pole: roll is undefined + const pole = rotateVec3ByTightMat3([0, 0, 1], nearest.bodyState.orientation); + const carry = imagePlaneBasis(forward, pose.roll ?? 0, upRef).up; + const dir = blendedUpDir(forward, pole, bodyUpWeight(nearest.hr, tuning), upRef, carry); + if (dir === null) return null; + return rollFromScreenUp(forward, dir, upRef); +} + +export function frameAlignedRoll( + prePose: CameraPose, + postPose: CameraPose, + bodyStates: ReadonlyMap, + poseBasis: Readonly, + upBasis: Readonly, + logZoom: number, + tuning: CameraTuning, +): number { + const currentRoll = postPose.roll ?? 0; + // Ruling 11: north-up off switches the roll authority off whole — + // same gate the engaged heading/level settles read (one home). + if (!tuning.northUp) return currentRoll; + const tPre = bandRollTarget(prePose, bodyStates, poseBasis, upBasis, tuning); + const tNew = bandRollTarget(postPose, bodyStates, poseBasis, upBasis, tuning); + if (tPre === null || tNew === null) return currentRoll; + // Deviation vs the PRE-notch target decays; the deviation's own movement + // (the notch's authored target swing) rides — one shared discipline. + const dPre = wrapRad(currentRoll - tPre); + const dNewRaw = wrapRad(currentRoll - tNew); + return ( + currentRoll - + riddenOrientStepRad(dPre, wrapRad(dNewRaw - dPre), ORIENT_DECAY.rideBoundRad, logZoom) + ); +} diff --git a/src/services/engine/camera/hOverR.ts b/src/services/engine/camera/hOverR.ts new file mode 100644 index 0000000000..ff0432da8a --- /dev/null +++ b/src/services/engine/camera/hOverR.ts @@ -0,0 +1,21 @@ +/** + * hOverR — a world eye's altitude over a body, in radius units. Eye-based (FW-A), + * never pivot-derived, and reuses `bodyRelativePose` — spec §10's one permitted + * Mpc↔metre seam for the engaged camera path. The basis argument is discarded, so + * any orthonormal matrix works. + */ + +import type { Vec3 } from '../../../@types/math/Vec3'; +import type { BodyState } from '../../../@types/scene/BodyState'; +import { IDENTITY_MAT3 } from '../../../utils/math/identityMat3'; +import { bodyRelativePose } from './bodyRelativePose'; + +export function hOverR(eyeMpc: Readonly, bodyState: BodyState, radiusM: number): number { + const { eyeRelBodyM } = bodyRelativePose({ + camPosMpc: eyeMpc, + camBasisWorld: IDENTITY_MAT3, + bodyState, + }); + const distanceM = Math.hypot(eyeRelBodyM[0], eyeRelBodyM[1], eyeRelBodyM[2]); + return (distanceM - radiusM) / radiusM; +} diff --git a/src/services/engine/camera/liveBodyPosition.ts b/src/services/engine/camera/liveBodyPosition.ts index 52bd079b6c..a897c8e0b3 100644 --- a/src/services/engine/camera/liveBodyPosition.ts +++ b/src/services/engine/camera/liveBodyPosition.ts @@ -1,34 +1,22 @@ /** - * liveBodyPosition — the live world position of the currently-focused scene - * body this frame. + * liveBodyPosition — the live world position of a selection row's body in a + * body snapshot. The SINGLE resolution of that lookup; four callers share it + * (the follow rows' target term, the frame-loop pivot pin, the NEAR0 selection + * ring, and `liveFocusRow`'s off-frame debug read) rather than copying it. * - * This is the SINGLE resolution of 'the live world position of a selection row's - * body this frame'. Three callers share it, so that lookup is defined in exactly - * one place rather than copied per site: - * - * - The `followBody` driver — its `pose` target term. - * - The frame-loop pivot-pin (`applyFocusedBodyPivot`) — re-centres whichever - * OTHER orbit driver wins (orbitDrag while dragging, autoRotate while - * spinning, resting while idle) on the same live body position. - * - The NEAR0 selection-ring layer — centres the halo on the SELECT row's live - * body position so the ring tracks the animated body, not its pick-time pose. - * - * This answers WHERE, never WHETHER: a null return means only that the snapshot - * holds no position for the row, and callers that need 'does this body move' - * ask `bodyMovesThisFrame` — the snapshot carries static anchors too, so - * presence in it is not motion. - * - * The body keeps moving at the sim rate; resolving the position from the live - * snapshot every frame is what lets the camera track it and the ring follow it. - * `deriveBodyStates` is memoised one-deep on `simDays`, so a same-instant call - * returns the cached Map for free — no extra Kepler solve. + * Answers WHERE, never WHETHER: null means only that the snapshot holds no + * position for the row. "Does this body move" is `bodyMovesThisFrame` — the + * snapshot carries static anchors too, so presence in it is not motion. */ -import { deriveBodyStates } from '../frame/deriveBodyStates'; +import type { BodyState } from '../../../@types/scene/BodyState'; import type { SelectionRow } from '../../../@types/engine/SelectionRow'; import type { Vec3 } from '../../../@types/math/Vec3'; -export function liveBodyPosition(focusRow: SelectionRow | null, simDays: number): Vec3 | null { +export function liveBodyPosition( + focusRow: SelectionRow | null, + bodies: ReadonlyMap, +): Vec3 | null { if (focusRow === null || focusRow.type !== 'body') return null; - return deriveBodyStates(simDays).get(focusRow.id)?.positionMpc ?? null; + return bodies.get(focusRow.id)?.positionMpc ?? null; } diff --git a/src/services/engine/camera/liveUpBasisQuat.ts b/src/services/engine/camera/liveUpBasisQuat.ts index 279eb42e96..3d88d7131c 100644 --- a/src/services/engine/camera/liveUpBasisQuat.ts +++ b/src/services/engine/camera/liveUpBasisQuat.ts @@ -2,7 +2,7 @@ * liveUpBasisQuat — the live up-basis B(t) as a unit quaternion * (x, y, z, w). * - * `upBasis.current` is the tight 9-float column-major Mat3 `runFrame` + * `outputs.upBasis` is the tight 9-float column-major Mat3 `runFrame` * resolves once per frame — exactly `matrixToQuaternion`'s input — so both * switch surfaces (the orientation saga's runtime accessor in `engine.ts` and * the `frameTo` clip cue in `applySceneEffect`) seed a roll from the live pole @@ -14,5 +14,5 @@ import type { Vec4 } from '../../../@types/math/Vec4'; import { matrixToQuaternion } from '../../../utils/math/matrixToQuaternion'; export function liveUpBasisQuat(cameraRuntime: CameraRuntime): Vec4 { - return matrixToQuaternion(cameraRuntime.upBasis.current); + return matrixToQuaternion(cameraRuntime.outputs.upBasis); } diff --git a/src/services/engine/camera/nearestBodyHR.ts b/src/services/engine/camera/nearestBodyHR.ts new file mode 100644 index 0000000000..81fc0f9e6c --- /dev/null +++ b/src/services/engine/camera/nearestBodyHR.ts @@ -0,0 +1,33 @@ +/** + * nearestBodyHR — which body owns the eye's approach, body-blind: the + * `SCENE_BODIES` roster row nearest in band units (h/R), focus never + * consulted. ONE home for the rule — the regime predicate's engage test and + * the world-arm frame alignment must never disagree about the owning body. + */ + +import type { Vec3 } from '../../../@types/math/Vec3'; +import type { BodyId } from '../../../@types/data/body/BodyId'; +import type { BodyState } from '../../../@types/scene/BodyState'; +import { SCENE_BODIES } from '../../../data/bodies/sceneBodies'; +import { hOverR } from './hOverR'; + +type Nearest = { + readonly bodyId: BodyId; + readonly bodyState: BodyState; + readonly hr: number; +}; + +export function nearestBodyHR( + eyeMpc: Readonly, + bodyStates: ReadonlyMap, +): Nearest | null { + let nearest: Nearest | null = null; + for (const body of SCENE_BODIES) { + const bodyId = body.id as BodyId; + const bodyState = bodyStates.get(bodyId); + if (bodyState === undefined) continue; + const hr = hOverR(eyeMpc, bodyState, body.radiusM); + if (nearest === null || hr < nearest.hr) nearest = { bodyId, bodyState, hr }; + } + return nearest; +} diff --git a/src/services/engine/camera/orientDeltas.ts b/src/services/engine/camera/orientDeltas.ts new file mode 100644 index 0000000000..cdca46ba3e --- /dev/null +++ b/src/services/engine/camera/orientDeltas.ts @@ -0,0 +1,87 @@ +/** + * orientDeltas — how far each orientation DOF moved between the last two + * FRAMES, plus the largest such step since the last clear. Fed by `runFrame` + * once the displayed pose is final, at frame rate: the panel's 4 Hz poll + * averages ~15 frames into one reading and hides exactly the defect this + * exists for — a decay keyed to the wrong clock (per frame instead of per zoom + * notch). Module state, not `cameraRuntime`: that has one writer by gate + * (`cameraRuntimeSingleWriter.test.ts`) and this is not camera mechanism. + * Δ is the pose's OWN motion, never a residual — but heading and tilt are + * measured body-fixed, so a rotating body moves them under a still camera. + */ + +import type { CameraDofAngles } from '../../../@types/camera/CameraDofAngles'; +import type { OrientDeltas } from '../../../@types/camera/OrientDeltas'; +import type { OrientDofDelta } from '../../../@types/camera/OrientDofDelta'; +import { wrapRad } from '../../../utils/math/wrapRad'; + +type DofRecord = { + prevRad: number | null; + deltaRad: number; + peakAbsRad: number; +}; + +function emptyDof(): DofRecord { + return { prevRad: null, deltaRad: 0, peakAbsRad: 0 }; +} + +const RECORD: Record = { + heading: emptyDof(), + tilt: emptyDof(), + roll: emptyDof(), +}; + +let watchers = 0; + +/** + * The panel's mount IS the gate. `prevRad` resets on subscribe: without it the + * first frame back reports every radian flown while the panel was closed as one + * frame's Δ, and that phantom then owns the peak column. + */ +export function watchOrientDeltas(): () => void { + watchers += 1; + for (const dof of Object.values(RECORD)) { + dof.prevRad = null; + dof.deltaRad = 0; + } + return () => { + watchers -= 1; + }; +} + +export function orientDeltasWatched(): boolean { + return watchers > 0; +} + +function step(dof: DofRecord, currentRad: number | null): void { + const prevRad = dof.prevRad; + dof.prevRad = currentRad; + // A gap (off-roster, degenerate forward) breaks the chain rather than + // reporting the whole gap as one frame's motion. + if (currentRad === null || prevRad === null) { + dof.deltaRad = 0; + return; + } + dof.deltaRad = wrapRad(currentRad - prevRad); + const absRad = Math.abs(dof.deltaRad); + if (absRad > dof.peakAbsRad) dof.peakAbsRad = absRad; +} + +export function recordOrientDeltas(angles: CameraDofAngles): void { + step(RECORD.heading, angles.heading.currentRad); + step(RECORD.tilt, angles.tilt.currentRad); + step(RECORD.roll, angles.roll.currentRad); +} + +export function clearOrientPeaks(): void { + for (const dof of Object.values(RECORD)) dof.peakAbsRad = 0; +} + +function frozen(dof: DofRecord): OrientDofDelta { + return { deltaRad: dof.deltaRad, peakAbsRad: dof.peakAbsRad }; +} + +/** A value copy — the record keeps mutating under a panel that renders from it. */ +export function readOrientDeltas(): OrientDeltas { + return { heading: frozen(RECORD.heading), tilt: frozen(RECORD.tilt), roll: frozen(RECORD.roll) }; +} diff --git a/src/services/engine/camera/poseFrameConversion.ts b/src/services/engine/camera/poseFrameConversion.ts new file mode 100644 index 0000000000..bb7d95cbea --- /dev/null +++ b/src/services/engine/camera/poseFrameConversion.ts @@ -0,0 +1,164 @@ +/** + * poseFrameConversion — the lossless world arm ↔ body arm pair (spec §5.1). + * + * Entering captures NOTHING time-dependent — no epoch, no orientation snapshot + * — so co-rotation is a property of the storage and a fast clock cannot move + * the engaged pose. Leaving bakes the rotation back out and re-derives the + * orbit parameterization; `roll` carries the screen-up residual the eye alone + * cannot express (spec §12-R1), which is what makes the pair exact for ANY + * pose. Second and last user of the Mpc↔metre constants here (spec §10). + */ + +import type { Vec3 } from '../../../@types/math/Vec3'; +import type { Mat3 } from '../../../@types/math/Mat3'; +import type { BodyId } from '../../../@types/data/body/BodyId'; +import type { BodyState } from '../../../@types/scene/BodyState'; +import type { CameraPose } from '../../../@types/camera/CameraPose'; +import type { BodyFixedPose } from '../../../@types/camera/BodyFixedPose'; +import type { FramedCameraPose } from '../../../@types/camera/FramedCameraPose'; +import { SCENE_BODIES } from '../../../data/bodies/sceneBodies'; +import { SCALE_UNITS } from '../../../data/scaleUnits'; +import { yawPitchToDir } from '../../../utils/camera/yawPitchToDir'; +import { imagePlaneBasis } from '../../../utils/camera/imagePlaneBasis'; +import { frameUp } from '../../../utils/camera/frameUp'; +import { orbitAnglesLookingAlong } from '../../../utils/camera/orbitAnglesLookingAlong'; +import { rollFromScreenUp } from '../../../utils/camera/rollFromScreenUp'; +import { rotateVec3ByTightMat3 } from '../../../utils/math/rotateVec3ByTightMat3'; +import { mat3FromColumns } from '../../../utils/math/mat3FromColumns'; +import { normalize3 } from '../../../utils/math/normalize3'; +import { raySphereRoots } from '../../../utils/math/raySphereRoots'; +import { surfaceFloorM } from '../../../utils/camera/surfaceFloorM'; +import { bodyFixedEyeM } from '../../../utils/camera/bodyFixedEyeM'; +import { dot3 } from '../../../utils/math/dot3'; +import { bodyRelativePose } from './bodyRelativePose'; +import { BODY_LOCAL_FRAME } from '../../../data/camera/bodyLocalFrame'; + +export function toBodyArm( + pose: CameraPose, + poseBasis: Readonly, + upBasis: Readonly, + bodyId: BodyId, + bodyState: BodyState, +): BodyFixedPose { + // The world eye and camera basis exactly as `frameContext` derives them: + // `updatePosition`'s two steps (frame-local decode, then rotate by the + // STEADY `poseBasis`), then the image-plane basis over the possibly + // mid-slerp `upBasis`. Re-deriving either convention here would let a body + // row's screen orientation drift from NEAR0's. + const dirWorld = rotateVec3ByTightMat3(yawPitchToDir(pose.yaw, pose.pitch), poseBasis); + const camPosMpc: Vec3 = [ + pose.target[0] + dirWorld[0] * pose.distance, + pose.target[1] + dirWorld[1] * pose.distance, + pose.target[2] + dirWorld[2] * pose.distance, + ]; + const forward = normalize3([ + pose.target[0] - camPosMpc[0], + pose.target[1] - camPosMpc[1], + pose.target[2] - camPosMpc[2], + ]); + const { right, up } = imagePlaneBasis(forward, pose.roll ?? 0, frameUp(upBasis)); + + // Provider A IS the Mpc→metre seam, so the body arm reuses it rather than + // repeating the subtract-then-scale ordering the precision depends on + // (spec §5.2: both providers agree at the flip because it is one derivation). + const { eyeRelBodyM, basisM } = bodyRelativePose({ + camPosMpc, + camBasisWorld: mat3FromColumns(right, up, forward), + bodyState, + }); + + // Anchor at the body centre (spec §5.3, ruled S2): re-anchoring is a separate + // operation, so the first landing stores the eye whole. + return { bodyId, anchorLocalM: [0, 0, 0], eyeRelAnchorM: eyeRelBodyM, basisLocal: basisM }; +} + +export function toWorldArm( + pose: BodyFixedPose, + bodyState: BodyState, + poseBasis: Readonly, + upBasis: Readonly, + bodyRadiusM: number, +): CameraPose { + const { basisLocal } = pose; + const eyeLocalM = bodyFixedEyeM(pose); + const forwardLocal: Vec3 = [basisLocal[6], basisLocal[7], basisLocal[8]]; + const eyeMagM = Math.hypot(eyeLocalM[0], eyeLocalM[1], eyeLocalM[2]); + + // The target stays ON the forward ray whatever the sightline does, so the + // view axis IS `forwardLocal` and crossing the limb cannot turn the pose. + // Range is the near root on a hit and the closest approach `t*` on a miss — + // continuously the same number, since `t* − √disc → t*` at tangency (Cesium's + // `grazingAltitudeLocation`, prior-art Q1). Re-aiming at the body centre on a + // miss is what popped the scene by `asin(R/d)`. + const roots = raySphereRoots(eyeLocalM, forwardLocal, BODY_LOCAL_FRAME.centreM, bodyRadiusM); + const grazingM = -dot3(eyeLocalM, forwardLocal); + // Floored at the eye's altitude — the scale `cam.distance` consumers want + // when the ray points at sky, and positive, which the encoding needs to carry + // a direction at all. It cannot reopen the crossing: `t* = √(d²−R²) ≥ d−R` + // there. `eyeMagM`'s own floor guards only an eye at or below the surface, + // which the surface arm's descent floor already stands off from. + const rangeM = Math.max( + roots === null ? grazingM : roots[0], + Math.max(eyeMagM, surfaceFloorM(bodyRadiusM)) - bodyRadiusM, + ); + const armLocalM: Vec3 = [ + forwardLocal[0] * rangeM, + forwardLocal[1] * rangeM, + forwardLocal[2] * rangeM, + ]; + + const { orientation, positionMpc } = bodyState; + const targetWorldM = rotateVec3ByTightMat3( + [eyeLocalM[0] + armLocalM[0], eyeLocalM[1] + armLocalM[1], eyeLocalM[2] + armLocalM[2]], + orientation, + ); + const target: Vec3 = [ + positionMpc[0] + targetWorldM[0] * SCALE_UNITS.M_TO_MPC, + positionMpc[1] + targetWorldM[1] * SCALE_UNITS.M_TO_MPC, + positionMpc[2] + targetWorldM[2] * SCALE_UNITS.M_TO_MPC, + ]; + // Measured in body-fixed metres, never as a difference of two heliocentric + // Mpc positions: the range keeps full f64 relative precision that way. + const distance = Math.hypot(armLocalM[0], armLocalM[1], armLocalM[2]) * SCALE_UNITS.M_TO_MPC; + + // The orbit convention aims the camera AT its target, so the reconstructed + // view axis is the arm — `forwardLocal` in world, exactly, at every range. + const viewDirWorld = normalize3(rotateVec3ByTightMat3(armLocalM, orientation)); + // `orbitAnglesLookingAlong` predates the readonly-basis convention and takes + // a mutable `Mat3`; it only reads the nine cells. + const { yaw, pitch } = orbitAnglesLookingAlong(viewDirWorld, poseBasis as Mat3); + const upWorld = rotateVec3ByTightMat3([basisLocal[3], basisLocal[4], basisLocal[5]], orientation); + + return { + target, + yaw, + pitch, + distance, + roll: rollFromScreenUp(viewDirWorld, upWorld, frameUp(upBasis)), + }; +} + +/** + * The world arm of a framed pose. The absolute arm returns its own pose BY + * REFERENCE — the fold is free on every world-arm frame, which is what lets + * `runFrame` resolve unconditionally instead of branching (spec §7 step 6). + * + * Throws when the engaged body has no state or no registry row this instant: + * a body arm is only ever created for a body the roster resolved, so this is + * unreachable by construction and a silent fallback would teleport the camera. + */ +export function resolveWorldArm( + framed: FramedCameraPose, + bodyStates: ReadonlyMap, + poseBasis: Readonly, + upBasis: Readonly, +): CameraPose { + if (framed.frame === 'absolute') return framed.pose; + const bodyId = framed.frame.body; + const bodyState = bodyStates.get(bodyId); + const body = SCENE_BODIES.find((row) => row.id === bodyId); + if (bodyState === undefined || body === undefined) { + throw new Error(`resolveWorldArm: engaged body '${bodyId}' is unresolved this instant`); + } + return toWorldArm(framed.pose, bodyState, poseBasis, upBasis, body.radiusM); +} diff --git a/src/services/engine/camera/poseOf.ts b/src/services/engine/camera/poseOf.ts deleted file mode 100644 index 3d866df531..0000000000 --- a/src/services/engine/camera/poseOf.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * poseOf — extract the orbit params from a live OrbitCamera as a CameraPose. - * - * An OrbitCamera carries projection geometry (fovYRad, aspect, near, far) and - * a derived world-space position alongside the four orbit parameters that - * camera drivers actually author (target, yaw, pitch, distance). This helper - * drops projection + position and returns only what a CameraPose carries. - * - * The target is copied into a fresh array so the returned pose never aliases - * the camera's mutable target field — callers can hold the pose across a frame - * boundary without risk of the camera advancing under them. - */ - -import type { OrbitCamera } from '../../../@types/camera/OrbitCamera'; -import type { CameraPose } from '../../../@types/camera/CameraPose'; - -export function poseOf(cam: OrbitCamera): CameraPose { - return { - target: [cam.target[0], cam.target[1], cam.target[2]], - yaw: cam.yaw, - pitch: cam.pitch, - distance: cam.distance, - roll: cam.roll, - }; -} diff --git a/src/services/engine/camera/projectionOf.ts b/src/services/engine/camera/projectionOf.ts deleted file mode 100644 index ce2f3d6a0b..0000000000 --- a/src/services/engine/camera/projectionOf.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * projectionOf — extract the lens/frustum config from a live OrbitCamera as a - * CameraProjection. The mirror of `poseOf`: where `poseOf` drops projection + - * position and keeps the four orbit params, `projectionOf` keeps the four - * projection numbers (fovYRad, aspect, near, far) and drops the rest. - * - * Completes the extract/merge set around the projection/pose split: `poseOf` - * and `projectionOf` pull the two halves out of an OrbitCamera, and - * `assembleOrbitCamera` merges them back. With both extractors present, the - * bootstrap seed reads its projection straight off the camera instead of - * re-spelling the four numbers from their source pieces (and re-deriving - * `aspect` a second time). - */ - -import type { OrbitCamera } from '../../../@types/camera/OrbitCamera'; -import type { CameraProjection } from '../../../@types/camera/CameraProjection'; - -export function projectionOf(cam: OrbitCamera): CameraProjection { - return { - fovYRad: cam.fovYRad, - aspect: cam.aspect, - near: cam.near, - far: cam.far, - }; -} diff --git a/src/services/engine/camera/regimeArmFor.ts b/src/services/engine/camera/regimeArmFor.ts new file mode 100644 index 0000000000..e81dd59fec --- /dev/null +++ b/src/services/engine/camera/regimeArmFor.ts @@ -0,0 +1,46 @@ +/** + * regimeArmFor — the regime predicate (spec §4, §12-R2, round 10): a pure read of + * geometry AND focus, never a stored flag. `camera.base.frame` IS the regime, so + * hysteresis falls out of `current`: from `'absolute'` the test is + * `min(h/R) < engageHR`, from a body arm `h/R > disengageHR` for THAT body only. + * A differing BODY focus both releases the arm and blocks engage — an arm the fold + * would release next frame must never be entered. + */ + +import type { CameraTuning } from '../../../@types/camera/CameraTuning'; +import type { PoseFrame } from '../../../@types/camera/PoseFrame'; +import type { Vec3 } from '../../../@types/math/Vec3'; +import type { BodyId } from '../../../@types/data/body/BodyId'; +import type { BodyState } from '../../../@types/scene/BodyState'; +import { SCENE_BODIES } from '../../../data/bodies/sceneBodies'; +import { hOverR } from './hOverR'; +import { nearestBodyHR } from './nearestBodyHR'; + +export function regimeArmFor( + current: PoseFrame, + eyeMpc: Readonly, + bodyStates: ReadonlyMap, + focusedBodyId: string | null, + tuning: CameraTuning, +): PoseFrame { + if (current === 'absolute') { + const nearest = nearestBodyHR(eyeMpc, bodyStates); + // Clip/tour reachability (R10-1): a hand-authored `flyToClip` CAN park at one + // body's surface with a stale focus on another. No engage happens there, so + // the first at-rest frame's pivot pin re-targets the FOCUSED body. + return nearest !== null && + nearest.hr < tuning.engageHR && + (focusedBodyId === null || focusedBodyId === nearest.bodyId) + ? { body: nearest.bodyId } + : 'absolute'; + } + + // A differing body focus releases the arm so follow can take over next frame. + if (focusedBodyId !== null && focusedBodyId !== current.body) return 'absolute'; + + // Unresolved this frame: hold rather than guess — the caller's next frame retries. + const row = SCENE_BODIES.find((body) => body.id === current.body); + const bodyState = bodyStates.get(current.body); + if (row === undefined || bodyState === undefined) return current; + return hOverR(eyeMpc, bodyState, row.radiusM) > tuning.disengageHR ? 'absolute' : current; +} diff --git a/src/services/engine/camera/replayInput.ts b/src/services/engine/camera/replayInput.ts new file mode 100644 index 0000000000..fba68d256b --- /dev/null +++ b/src/services/engine/camera/replayInput.ts @@ -0,0 +1,295 @@ +/** + * replayInput — one frame's input steps folded over the camera runtime, pure: + * the new register/surface/follow memories plus the actions the drain would + * have dispatched, in order. Each step reads the EFFECTIVE intent — the frame's + * store snapshot with the actions emitted so far folded through the real camera + * reducer — so a step sees the commit the step before it made, exactly as the + * incumbent's fresh `getState()` per step did. The store commits only at + * gesture end and per at-rest notch; a following camera's notch rides the + * return as `followDistanceTarget` instead. + */ + +import type { UnknownAction } from '@reduxjs/toolkit'; + +import { applyInputToCamera } from '../../camera/applyInputToCamera'; +import { surfaceStep } from '../../camera/surfaceStep'; +import { surfaceGestureEdge } from '../../../utils/camera/surfaceGestureEdge'; +import { applyWheelZoom } from './applyWheelZoom'; +import { advanceEpoch, elapsedMs } from './cameraEpochs'; +import { frameAlignedRoll } from './frameAlignedRoll'; +import { pivotFraming } from './pivotRadiusMpc'; +import { resolveWorldArm } from './poseFrameConversion'; +import { zoomedDistance } from '../../../utils/camera/zoomedDistance'; +import { absoluteArm } from '../../../utils/camera/absoluteArm'; +import { bodyMovesThisFrame } from '../../../utils/scene/bodyMovesThisFrame'; +import { frameUp } from '../../../utils/camera/frameUp'; +import { isFollowDriverId } from '../../../utils/camera/isFollowDriverId'; +import { rotateVec3ByTightMat3T } from '../../../utils/math/rotateVec3ByTightMat3T'; +import { selectFocusRow } from '../../../state/selection/selectors'; +import cameraReducer, { endDrag, commitCameraPose } from '../../../state/camera/cameraSlice'; +import { SCENE_BODIES } from '../../../data/bodies/sceneBodies'; + +import type { BodyId } from '../../../@types/data/body/BodyId'; +import type { BodyState } from '../../../@types/scene/BodyState'; +import type { CameraProjection } from '../../../@types/camera/CameraProjection'; +import type { CameraTuning } from '../../../@types/camera/CameraTuning'; +import type { DriverId } from '../../../@types/engine/camera/DriverId'; +import type { Epoch } from '../../../@types/engine/camera/Epoch'; +import type { FollowMemory } from '../../../@types/engine/camera/FollowMemory'; +import type { FramedCameraPose } from '../../../@types/camera/FramedCameraPose'; +import type { InputStep } from '../../../@types/camera/InputStep'; +import type { Mat3 } from '../../../@types/math/Mat3'; +import type { SurfaceMemory } from '../../../@types/camera/SurfaceMemory'; +import type { Vec2 } from '../../../@types/math/Vec2'; +import type { Vec3 } from '../../../@types/math/Vec3'; +import type { RootState } from '../../../store/types'; + +export function replayInput( + prev: { + readonly register: FramedCameraPose; + readonly surface: SurfaceMemory; + readonly follow: FollowMemory | null; + }, + steps: readonly InputStep[], + ctx: { + readonly rootState: RootState; + readonly nowMs: number; + readonly canvasPx: Readonly; + readonly projection: CameraProjection; + readonly upBasis: Readonly; + readonly poseBasis: Readonly; + readonly bodies: ReadonlyMap; + readonly winnerLastFrame: DriverId; + readonly autoRotateEpoch: Epoch; + readonly tuning: CameraTuning; + }, +): { + readonly register: FramedCameraPose; + readonly surface: SurfaceMemory; + readonly follow: FollowMemory | null; + readonly followDistanceTarget: number | null; + readonly actions: readonly UnknownAction[]; +} { + const { + rootState, + nowMs, + canvasPx, + projection, + upBasis, + poseBasis, + bodies, + winnerLastFrame, + tuning, + } = ctx; + const cssHeight = canvasPx[1]; + // Only camera actions are emitted mid-drain, so every other slice is the + // snapshot's; the focus row is read once. + const focus = selectFocusRow(rootState); + const pivot = pivotFraming(focus); + + // The accumulator. EVERY step writes the register so a later step in the same + // drain chains from it — an at-rest notch left out of it would be folded over + // and silently discarded by a drag arriving in the same frame window. + let register = prev.register; + let surface = prev.surface; + let follow = prev.follow; + let followDistanceTarget: number | null = null; + // Advanced locally for this frame's elapsed read and DISCARDED: handing it to + // `advanceEpochs` would keep a fold-time reset when another driver wins. + let autoRotateEpoch = ctx.autoRotateEpoch; + let camera = rootState.camera; + const actions: UnknownAction[] = []; + const emit = (action: UnknownAction): void => { + actions.push(action); + camera = cameraReducer(camera, action); + }; + + /** + * The engaged arm's input owner (spec §6). The GATE is the stored regime + * (`base.frame`); the POSE is the live register, because the fold commits on + * a regime EDGE only — mid-tween `base` holds the last crossing pose while the + * register tracks the animation (FW-G). + */ + const routeToSurface = (step: InputStep): boolean => { + const base = camera.base; + if (base.frame === 'absolute') return false; + // A playing clip owns the camera in both arms (the driver table's rule). + if (camera.clip !== null) return true; + const body = SCENE_BODIES.find((row) => row.id === base.frame.body); + if (body === undefined) return true; + const from = + register.frame !== 'absolute' && register.frame.body === base.frame.body + ? register.pose + : base.pose; + // Scene up in the body's fixed axes for the settle's band blend; a missing + // body degrades to the pole (the blend collapses to the body ENU). + const bodyState = bodies.get(base.frame.body); + const sceneUpLocal: Vec3 = bodyState + ? rotateVec3ByTightMat3T(frameUp(upBasis), bodyState.orientation) + : [0, 0, 1]; + const { pose: next, next: memory } = surfaceStep(surface, from, step, { + viewportPx: canvasPx, + fovYRad: projection.fovYRad, + bodyRadiusM: body.radiusM, + sceneUpLocal, + tuning, + }); + surface = memory; + register = { frame: base.frame, pose: next }; + // An at-rest notch is its own atomic gesture, so its commit is its gesture + // end (the resting driver renders `base`, not the register). Identity, not + // equality: a declined step returns its input by reference. + if (step.kind === 'zoom' && !step.duringGesture && next !== from) { + emit(commitCameraPose(register)); + } + return true; + }; + + const applyWorldStep = (step: Extract): void => { + // A playing clip is not gesture-interruptible: swallowed, not folded under it. + if (camera.clip !== null) return; + // AUTHORED, not displayed (R12b-1): the drag composes below the tilt. + const world = resolveWorldArm(register, bodies, poseBasis, upBasis); + let next = applyInputToCamera( + world, + step, + cssHeight, + pivot, + projection.fovYRad, + poseBasis, + upBasis, + ); + if (step.kind === 'zoom') { + // The roll ride runs on every driven zoom path, gesture-held included. + const roll = frameAlignedRoll( + world, + next, + bodies, + poseBasis, + upBasis, + Math.abs(Math.log(step.factor)), + tuning, + ); + next = { ...next, roll }; + } + if (step.kind === 'drag' && step.mode === 'pan' && bodyMovesThisFrame(focus)) { + // Followed-body strafe: the pivot-pin owns the target (`bodyPosition + + // panOffset`), so the pan's own delta goes to the offset the pin reads. + const off = follow?.panOffset ?? [0, 0, 0]; + follow = { + from: follow?.from ?? null, + distanceTarget: follow?.distanceTarget ?? null, + panOffset: [ + off[0] + next.target[0] - world.target[0], + off[1] + next.target[1] - world.target[1], + off[2] + next.target[2] - world.target[2], + ], + saturated: follow?.saturated ?? false, + }; + } + register = absoluteArm(next); + }; + + for (const step of steps) { + switch (step.kind) { + case 'gestureStart': + // The gesture boundaries are the memory's only 'down' writes; the latch + // is taken by the first drag step, which carries the press pixel. + surface = surfaceGestureEdge(surface, true); + break; + + case 'gestureEnd': { + // ONE commit site for both arms: bake the register into `base` before + // `endDrag`. Skipped while a clip owns the camera and across an arm + // mismatch — the fold owns regime edges; a commit here must never flip one. + const sameArm = + register.frame === 'absolute' + ? camera.base.frame === 'absolute' + : camera.base.frame !== 'absolute' && register.frame.body === camera.base.frame.body; + if (camera.clip === null && sameArm) emit(commitCameraPose(register)); + surface = surfaceGestureEdge(surface, false); + emit(endDrag()); + break; + } + + case 'drag': + if (!routeToSurface(step)) applyWorldStep(step); + break; + + case 'zoom': { + // The settles are priced per unit of zoom, not per step (user ruling + // 2026-09-10): a trackpad twitch must not spend a mouse notch's decay. + const logZoom = Math.abs(Math.log(step.factor)); + // In a body arm both zoom owners route to the anchored step (§7). + if (routeToSurface(step)) break; + if (step.duringGesture) { + applyWorldStep(step); + break; + } + // A follow row re-asserts its own target every frame and would swallow + // a committed base, so its notch is resolved to a distance the driver + // adopts; a second notch in the same drain resolves off the first. + const followTargetBefore = followDistanceTarget ?? follow?.distanceTarget ?? null; + if ( + camera.base.frame === 'absolute' && + isFollowDriverId(winnerLastFrame) && + followTargetBefore !== null + ) { + followDistanceTarget = zoomedDistance(followTargetBefore, step.factor, pivot); + // Ruling 8: the ride's authored altitude move IS that target change — + // the live pose twice gave a zero delta and froze the band roll. It + // lands on `base.roll`, the term the follow pose lerps toward. + const basePose = camera.base.pose; + const live = resolveWorldArm(register, bodies, poseBasis, upBasis); + const roll = frameAlignedRoll( + { ...live, distance: followTargetBefore }, + { ...live, distance: followDistanceTarget }, + bodies, + poseBasis, + upBasis, + logZoom, + tuning, + ); + if (roll !== (basePose.roll ?? 0)) + emit(commitCameraPose(absoluteArm({ ...basePose, roll }))); + break; + } + // The spin epoch as THIS frame's advance will see it (idempotent on an + // unchanged ref), not the stale row a switch-off between frames left. + const { active, rate } = camera.autoRotate; + autoRotateEpoch = advanceEpoch(autoRotateEpoch, active ? camera.base : null, nowMs); + const zoomed = applyWheelZoom({ + base: camera.base, + factor: step.factor, + spin: { owns: winnerLastFrame === 'autoRotate', rate }, + spinElapsedMs: elapsedMs(autoRotateEpoch, nowMs), + pivot, + }); + if (zoomed !== null && camera.base.frame === 'absolute') { + // `base` is centre-looking by wiring (R12-1), so the pre/post pair is + // self-consistent under an autoRotate-owned notch too. + const roll = frameAlignedRoll( + camera.base.pose, + zoomed, + bodies, + poseBasis, + upBasis, + logZoom, + tuning, + ); + register = absoluteArm({ ...zoomed, roll }); + emit(commitCameraPose(register)); + } + break; + } + } + } + + return { + register, + surface, + follow, + followDistanceTarget, + actions, + }; +} diff --git a/src/services/engine/camera/resolveFrameBasis.ts b/src/services/engine/camera/resolveFrameBasis.ts index 4297f17b81..ecac043d07 100644 --- a/src/services/engine/camera/resolveFrameBasis.ts +++ b/src/services/engine/camera/resolveFrameBasis.ts @@ -1,57 +1,22 @@ /** - * resolveFrameBasis — the single authority for the camera's resolved - * orientation basis B(t), evaluated once per frame. - * - * (orientation, frameTween, clock, nowMs) → (this module) → Mat3 basis - * Mat3 basis → camera up → view matrix - * - * There is exactly one place that answers 'which way is up this frame', so no - * two call sites can drift on how a frame roll is interpolated. When no roll is - * in flight the answer is the steady registry basis for the current orientation; - * during a roll it is the slerp between the basis captured at switch start - * (`frameTween.fromQuat`) and the destination frame's basis, reshaped by the - * tween's easing and clamped so an over-elapsed frame settles on the endpoint - * rather than overshooting. - * - * ### Why slerp on quaternions rather than lerp on matrices - * - * Both endpoints are proper rotations. Linearly blending their matrices leaves - * the intermediate non-orthonormal (columns neither unit-length nor mutually - * perpendicular), which shears the rendered sky mid-transition. Spherical - * interpolation of the unit quaternions stays on the rotation manifold, so every - * sampled midpoint is itself a proper rotation. `quat.slerp` / - * `mat3.fromQuat` come from wgpu-matrix (same library the view-projection path - * uses) — no hand-rolled slerp. - * - * ### Layout bridge: 12-float mat3 → 9-float registry Mat3 - * - * wgpu-matrix stores a `mat3` as three vec4-padded columns (12 floats, padding - * at indices 3, 7, 11) to match WGSL's `mat3x3` memory layout. The registry's - * `Mat3` is a tight 9-float column-major tuple. This module strips the padding - * so callers see the registry shape, keeping the padded representation an - * implementation detail of the interpolation step. - * - * ### Steady branch returns a copy, not the registry object - * - * On the null-tween path we clone `ORIENTATION_FRAMES[orientation]` rather than - * hand the registry entry out directly. The registry is shared, module-level, - * frozen-by-convention truth; returning a fresh array means a caller that (say) - * writes the basis into a scratch buffer can never mutate the source. The - * interpolated branch already allocates a fresh array, so this keeps the return - * contract uniform: the caller always owns the array it receives. + * resolveFrameBasis — the single authority for the camera's orientation + * basis B(t): the steady registry basis when no frame roll is in flight, + * else the slerp from the basis captured at switch start (`fromQuat`) to the + * destination frame's, eased and clamped to the endpoint. Quaternion slerp, + * not matrix lerp: a blended matrix is non-orthonormal mid-roll and shears + * the sky; slerp stays on the rotation manifold (`quat.slerp` / + * `mat3.fromQuat` from wgpu-matrix, the view-projection path's library). */ import { quat, mat3 } from 'wgpu-matrix'; import type { Mat3 } from '../../../@types/math/Mat3'; import type { OrientationFrameId } from '../../../@types/camera/OrientationFrameId'; import type { FrameTween } from '../../../@types/camera/FrameTween'; -import type { CameraClock } from '../../../@types/engine/camera/CameraClock'; import { ORIENTATION_FRAMES, ORIENTATION_FRAME_QUATERNIONS, } from '../../../data/orientation/orientationFrames'; import { EASE } from '../animation/ease'; -import { frameTweenElapsed } from './cameraClock'; /** Strip wgpu-matrix's vec4 column padding (indices 3, 7, 11) to a tight Mat3. */ function toRegistryMat3(padded: Float32Array | number[]): Mat3 { @@ -68,27 +33,19 @@ function toRegistryMat3(padded: Float32Array | number[]): Mat3 { ]; } -/** - * Resolve the orientation basis for this frame. - * - * Total by construction: a null `frameTween` yields the steady registry basis; - * otherwise the eased slerp parameter is clamped to [0, 1] by the `EASE` - * functions, so an elapsed value at or beyond `durationMs` returns the - * destination frame's basis exactly. - */ +/** Total: a null tween is the steady basis; `EASE` clamps to [0, 1], so an + * over-elapsed roll settles on the destination exactly. */ export function resolveFrameBasis( orientation: OrientationFrameId, frameTween: FrameTween | null, - clock: CameraClock, - nowMs: number, + frameTweenElapsedMs: number, ): Mat3 { if (frameTween === null) { - // Copy so callers never mutate the shared registry entry (see module header). + // A copy: the registry entry is shared, callers own what they get. return [...ORIENTATION_FRAMES[orientation]]; } - const elapsed = frameTweenElapsed(clock, frameTween, nowMs); - const t = EASE[frameTween.easing](elapsed / frameTween.durationMs); + const t = EASE[frameTween.easing](frameTweenElapsedMs / frameTween.durationMs); const q = quat.slerp(frameTween.fromQuat, ORIENTATION_FRAME_QUATERNIONS[frameTween.to], t); return toRegistryMat3(mat3.fromQuat(q)); } diff --git a/src/services/engine/camera/seedCameraRuntime.ts b/src/services/engine/camera/seedCameraRuntime.ts new file mode 100644 index 0000000000..567ebb242f --- /dev/null +++ b/src/services/engine/camera/seedCameraRuntime.ts @@ -0,0 +1,37 @@ +/** + * seedCameraRuntime — the ONE constructor of `CameraRuntime`: the engine's + * boot placeholder and `wireInput`'s first real pose both come through here, so + * no seed site can leave a half-built bag. Displayed = authored at the seed: + * nothing has been projected yet (`projectFramePose` splits them thereafter). + */ + +import type { CameraProjection } from '../../../@types/camera/CameraProjection'; +import type { FramedCameraPose } from '../../../@types/camera/FramedCameraPose'; +import type { CameraRuntime } from '../../../@types/engine/state/CameraRuntime'; +import { UNSTARTED_EPOCHS } from './cameraEpochs'; +import { EMPTY_SURFACE_MEMORY } from '../../camera/surfaceStep'; +import { ORIENTATION_FRAMES } from '../../../data/orientation/orientationFrames'; +import { DEFAULT_ORIENTATION } from '../../../data/defaults'; +import { CONST_J2000 } from '../../../data/time/constJ2000'; + +export function seedCameraRuntime(args: { + readonly committed: FramedCameraPose; + readonly projection: CameraProjection; +}): CameraRuntime { + // Copied: the boot seed is the camera slice's `base`, and the register must + // never alias the store's object. + const pose: FramedCameraPose = { ...args.committed }; + return { + register: { pose, winner: 'resting' }, + epochs: UNSTARTED_EPOCHS, + follow: null, + surface: EMPTY_SURFACE_MEMORY, + outputs: { + displayed: pose, + simDays: CONST_J2000, + // Copied, so the seed never aliases the shared registry entry. + upBasis: [...ORIENTATION_FRAMES[DEFAULT_ORIENTATION]], + projection: args.projection, + }, + }; +} diff --git a/src/services/engine/camera/stepCameraRuntime.ts b/src/services/engine/camera/stepCameraRuntime.ts new file mode 100644 index 0000000000..a282775d8b --- /dev/null +++ b/src/services/engine/camera/stepCameraRuntime.ts @@ -0,0 +1,210 @@ +/** + * stepCameraRuntime — one frame of the camera as a pure step: `prev` in, the + * next runtime and the actions the frame dispatches out, in the order the store + * must see them. Stage order is the contract: `replayInput` → `advanceEpochs` + * (once per frame, at the winner) → arbitrate → `commitOnEdge` → + * `projectFramePose` (the fold, last). Every stage after the replay reads the + * EFFECTIVE intent — the snapshot with the replay's actions folded through the + * camera reducer — never the store, which is written only after `runFrame` + * installs `next`. Unchanged groups come back by identity. + */ + +import type { UnknownAction } from '@reduxjs/toolkit'; + +import type { CameraPose } from '../../../@types/camera/CameraPose'; +import type { CameraProjection } from '../../../@types/camera/CameraProjection'; +import type { CameraRuntime } from '../../../@types/engine/state/CameraRuntime'; +import type { StepInputs } from '../../../@types/engine/camera/StepInputs'; +import type { RootState } from '../../../store/types'; + +import { replayInput } from './replayInput'; +import { pickWinner, elapsedForWinner } from './cameraDrivers'; +import { advanceEpochs, elapsedMs } from './cameraEpochs'; +import { commitOnEdge } from './commitOnEdge'; +import { resolveWorldArm } from './poseFrameConversion'; +import { resolveFrameBasis } from './resolveFrameBasis'; +import { NEAR_CLIP_MPC, FAR_CLIP_MPC } from './cameraFraming'; +import { projectFramePose } from '../frame/projectFramePose'; +import { ORIENTATION_FRAMES } from '../../../data/orientation/orientationFrames'; +import cameraReducer, { + cancelCameraTween, + clearFrameTween, +} from '../../../state/camera/cameraSlice'; + +export function stepCameraRuntime( + prev: CameraRuntime, + inputs: StepInputs, +): { + readonly next: CameraRuntime; + readonly actions: readonly UnknownAction[]; + readonly requestRender: boolean; + /** + * The world arm the frame draws — pre-flip on a crossing frame, so the draw + * and the scale bar see the pose the fold judged. + */ + readonly world: CameraPose; + /** The effective snapshot the stages read; the keep-ticking vote must be off the same reading. */ + readonly rootState: RootState; +} { + const { + nowMs, + simDays, + rootState: stored, + canvasPx, + aspect, + steps, + bodies, + clipEpoch, + drivers, + } = inputs; + const focus = stored.selectionRows.focus; + // Re-derived every frame: the FOV slider can change with no resize event. + const projection: CameraProjection = { + fovYRad: stored.settings.camera.fovDeg * (Math.PI / 180), + aspect, + near: NEAR_CLIP_MPC, + far: FAR_CLIP_MPC, + }; + // `poseBasis` is the COMMITTED frame — the saga writes the destination into + // `settings.orientation` when a switch starts, so the eye holds still through + // a roll and only up rotates (`upBasis`, the live B(t)). + const poseBasis = ORIENTATION_FRAMES[stored.settings.orientation]; + // `stored`, not the post-replay snapshot: the replay runs before that exists, + // and no action it emits writes `tuning`, so the two readings are identical. + const tuning = stored.camera.tuning; + + const drained = replayInput( + { register: prev.register.pose, surface: prev.surface, follow: prev.follow }, + steps, + { + rootState: stored, + nowMs, + canvasPx, + projection, + upBasis: prev.outputs.upBasis, + poseBasis, + bodies, + winnerLastFrame: prev.register.winner, + autoRotateEpoch: prev.epochs.autoRotate, + tuning, + }, + ); + const actions: UnknownAction[] = [...drained.actions]; + // The drivers must see this frame's commits (`endDrag` above all, or + // `orbitDrag` wins one frame too long). Identity on a steady frame, so a + // memoised selector keyed on the root object keeps its cache. + const rootState = + drained.actions.length === 0 + ? stored + : { ...stored, camera: drained.actions.reduce(cameraReducer, stored.camera) }; + + // The approach hands off on a frame it SATURATED, never on a clock the pick + // reads independently: a fresh focus row starts a new approach whatever the + // old memory said, so the two are one fact and cannot disagree on phase. + const approachDone = + focus === prev.epochs.follow.ref ? (drained.follow?.saturated ?? false) : false; + // ONE pick per frame: the epoch advance, the commit gate and the produced pose + // read the same driver object, so they cannot disagree on who won. + const winner = pickWinner(drivers, rootState, approachDone); + const winnerId = winner.id; + const epochs = advanceEpochs(prev.epochs, { + intent: rootState.camera, + focus, + clip: clipEpoch, + winnerEpoch: winner.epoch, + nowMs, + }); + // The follow memory belongs to one focus row: a fresh row (a same-body + // re-select included) drops it, and the driver re-captures against the new + // target on its next produce. + const followIn = epochs.follow.ref !== prev.epochs.follow.ref ? null : drained.follow; + + const { pose, memory } = winner.pose( + { + state: rootState, + elapsedMs: elapsedForWinner(winner, epochs, nowMs), + register: drained.register, + // Against the PREVIOUS frame's up-basis: produce precedes the basis resolve. + authoredWorld: resolveWorldArm(drained.register, bodies, poseBasis, prev.outputs.upBasis), + winnerLastFrame: prev.register.winner, + poseBasis, + simDays, + projection, + bodies, + followDistanceTarget: drained.followDistanceTarget, + }, + followIn, + ); + + // The runtime keeps `upBasis`, NOT `poseBasis`: it seeds the next switch's + // `fromQuat`, and a re-switch mid-roll must compose from the live pole. + const rollElapsed = elapsedMs(epochs.frameTween, nowMs); + const upBasis = resolveFrameBasis( + rootState.settings.orientation, + rootState.camera.frameTween, + rollElapsed, + ); + // `EASE` clamps, so this frame's basis is already the destination; the clear + // only affects the next frame's snapshot. + if ( + rootState.camera.frameTween !== null && + rollElapsed >= rootState.camera.frameTween.durationMs + ) { + actions.push(clearFrameTween()); + } + // After produce (the pose is already saturated at `to`) and before + // commit-on-edge: the cancel lands next frame, when the tween deactivates and + // the edge commits the register — exactly one commit, exactly at `to`. + if ( + winnerId === 'tween' && + rootState.camera.tween !== null && + elapsedMs(epochs.tween, nowMs) >= rootState.camera.tween.durationMs + ) { + actions.push(cancelCameraTween()); + } + + const edge = commitOnEdge({ + register: drained.register, + displayed: prev.outputs.displayed, + produced: pose, + prevWinner: prev.register.winner, + winner, + drivers, + }); + actions.push(...edge.actions); + // The fold reads the SAME intent the drivers resolved against: the edge + // commit above is not visible to this frame's regime read. + const projected = projectFramePose({ + render: edge.render, + authoredOverride: edge.authoredOverride, + pivotsOnFocusedBody: winner.pivotsOnFocusedBody ?? false, + focus, + follow: memory, + surface: drained.surface, + intent: rootState.camera, + bodies, + poseBasis, + upBasis, + tuning, + }); + actions.push(...projected.actions); + + return { + next: { + register: { pose: projected.register, winner: winnerId }, + epochs, + follow: memory, + surface: projected.surface, + outputs: { + displayed: projected.displayed, + simDays, + upBasis, + projection, + }, + }, + actions, + requestRender: projected.requestRender, + world: projected.world, + rootState, + }; +} diff --git a/src/services/engine/engine.ts b/src/services/engine/engine.ts index 1341d2b86f..407763633a 100644 --- a/src/services/engine/engine.ts +++ b/src/services/engine/engine.ts @@ -18,13 +18,11 @@ import type { EngineHandle } from '../../@types/engine/EngineHandle'; import type { EngineHomeConfig } from '../../@types/engine/EngineHomeConfig'; import type { EngineState } from '../../@types/engine/state/EngineState'; -import { createCameraClock } from './camera/cameraClock'; +import { seedCameraRuntime } from './camera/seedCameraRuntime'; +import { NEAR_CLIP_MPC, FAR_CLIP_MPC } from './camera/cameraFraming'; import { liveUpBasisQuat } from './camera/liveUpBasisQuat'; -import type { CameraRuntime } from '../../@types/engine/state/CameraRuntime'; import type { SkyCubemapCaptureRuntime } from '../../@types/engine/state/SkyCubemapCaptureRuntime'; -import { CONST_J2000 } from '../../data/time/constJ2000'; import { ORIENTATION_FRAMES } from '../../data/orientation/orientationFrames'; -import { DEFAULT_ORIENTATION } from '../../data/defaults'; import { createEngineData } from './data/createEngineData'; import { SCENE_STARS } from '../../data/bodies/sceneStars'; import { Source } from '../../data/source'; @@ -46,7 +44,16 @@ import { createInputAggregator } from './subsystems/inputAggregator'; import { CONTENT_PASSES } from './frame/passes'; import { logCameraState } from './helpers/logCameraState'; import { liveRenderCamera } from './helpers/liveRenderCamera'; +import { liveWorldPose } from './helpers/liveWorldPose'; import { liveFocusRow } from './helpers/liveFocusRow'; +import { deriveBodyStates } from './frame/deriveBodyStates'; +import { eyeMpcOf } from '../../utils/camera/eyeMpcOf'; +import { cameraDebugSnapshotOf } from '../../utils/camera/cameraDebugSnapshotOf'; +import { readOrientDeltas } from './camera/orientDeltas'; +import { deriveSimDays } from '../../utils/time/deriveSimDays'; +import { selectTimeState } from '../../state/time/selectors'; +import type { BodyId } from '../../@types/data/body/BodyId'; +import type { BodyState } from '../../@types/scene/BodyState'; import { engineStatusChanged, engineSourceCountReported } from '../../state/engine/engineSlice'; import { selectFamousGalaxiesMeta } from '../../state/engine/selectors'; import type { AssetSlot } from '../../@types/loading/AssetSlot'; @@ -76,23 +83,9 @@ import { createClipPathInspectSeam } from './animation/computeClipPath'; import type { ResolveDeps } from '../../@types/engine/ResolveDeps'; /** - * Start the WebGPU engine on `canvas`. - * - * Returns a handle synchronously; async setup (GPU init, data loading) - * progresses in the background and is dispatched to the store via - * `engineStatusChanged`. - * - * ### Lifecycle - * - * 1. `engineStatusChanged({ kind: 'initializing' })` dispatches immediately. - * 2. `initGpu()` + `loadCloud()` run asynchronously. - * 3. `engineStatusChanged({ kind: 'loading' })` dispatches before the fetch. - * 4. `engineStatusChanged({ kind: 'ready', ... })` dispatches when the render - * loop starts, or `{ kind: 'error' }` if GPU init fails. - * 5. `engineScaleChanged` dispatches per frame during steady-state rendering - * as the camera moves (deduped by the slice). - * - * @throws Never — errors are dispatched via `engineStatusChanged({ kind: 'error' })`. + * Start the WebGPU engine on `canvas`. Returns a handle synchronously; async setup + * (GPU init, data loading) progresses in the background and reaches the UI through + * `engineStatusChanged` dispatches — including failures, so this never throws. */ export function createEngine( @@ -100,94 +93,29 @@ export function createEngine( cb: EngineCallbacks, home: EngineHomeConfig, ): EngineHandle { - // ── Mutable engine state ───────────────────────────────────────────────── - // - // Everything lives as closure variables rather than a class because the - // engine is a singleton: one canvas → one engine → one set of state. - // Closure variables are slightly simpler to reason about than `this.*` and - // they keep the internal state completely inaccessible from outside. - - // The whole engine state — see `@types/EngineState.d.ts` (and the - // per-sub-bag siblings) for the type-level map. Sub-bag groupings: - // - // - `settings` → SettingsPanel knobs, seeded from `data/defaults.ts` - // (the single source of truth shared with App.tsx). - // - `sources` → loaded `GalaxyCatalog`s + visibility bitmasks + tier - // + optional famous-galaxy sidecars. - // - `picking` → hover / click / drag mutables. - // - `gpu` → renderers / offscreen render-target table / - // compositor — null until `initGpu` finishes. - // - `subsystems` → long-lived helpers; some construct up-front, the rest - // land later. - // - `cam` → orbit camera, null until the first cloud loads. - // - // The outer `state` binding is `const` — only inner fields mutate. - // Mutation in place matches the subsystem facades and avoids per-frame - // allocations on the hot path. - - // ── Frame-function forward declaration ──────────────────────────────────── - // - // The render loop's `frame()` body lives in `runFrame.ts` (called from - // the `startLoop` phase) because it reads GPU resources initGpu returns - // asynchronously. But the `RenderScheduler` in `state.subsystems.scheduler` - // needs an `onFrame` callback at construction time — here, in the - // synchronous state literal below. - // - // We resolve the chicken-and-egg with a `{ current }` ref holding a no-op - // stub. The scheduler captures `frameRef` via `() => frameRef.current()`, - // so once `startLoop` assigns the real body, every rAF runs it. A ref - // (not a `let`) because the bootstrap phases are sibling modules where a - // `let` would be invisible (see `BootstrapDeps` for the full ref inventory). - // - // The stub is a silent no-op: its only invocation window is "rAF fires - // before startLoop wires `frameRef.current`", vanishingly rare and - // harmless. + // The scheduler needs an `onFrame` at construction time — here — but the real + // frame body lives in `runFrame.ts` and only lands once `startLoop` runs. The + // scheduler captures `frameRef` and calls through it, so assigning + // `frameRef.current` later is enough. A ref, not a `let`, because the bootstrap + // phases are sibling modules where a `let` would be invisible. const frameRef: { current: () => void } = { current: () => { /* stub until startLoop assigns the real body */ }, }; - // ── Always-on CPU-side frame stats ──────────────────────────────────────── - // - // A mutable tracker the render scheduler's `onFrame` chokepoint folds into - // every frame, exposed read-only through `handle.debug.frameStats()`. Unlike - // the GPU timing service this needs no `?gpuTimings` gate and no device — it - // times the JS frame body with `performance.now()`, so the DebugPanel can show - // an fps + CPU-frame-time line at all times. `lastStartMs === 0` doubles as - // the "no frame has run yet" sentinel that seeds the first interval to 0 (the - // idle-gap guard in `updateFrameStats` skips that fold — see its header). + // Unlike the GPU timing service this needs no `?gpuTimings` gate and no device, + // so the DebugPanel always has an fps line. `lastStartMs === 0` doubles as the + // "no frame has run yet" sentinel that seeds the first interval to 0. const frameStats = { fps: 0, cpuMs: 0, lastStartMs: 0 }; - // ── Live camera Resources (cameraRuntime) ──────────────────────────────── - // - // The animation clock, live projection config, and the commit-on-edge - // bookkeeping refs. Constructed here alongside `frameRef` so wireInput - // (gesture seed + focus `from`), startLoop (RunFrameDeps), and runFrame - // (produce + commit-on-edge) all read from one source. Seeded with - // placeholders; wireInput's bootstrap seed fills real values once the - // initial OrbitCamera exists. - // - // `lastPose` seeds from the camera slice's initial `base` — the single home - // for the pre-bootstrap placeholder pose — so the first resting frame has a - // stable pose to read before wireInput's commitCameraPose fires. Copied so a - // later per-frame `lastPose.current = …` never aliases the store's state. - const cameraRuntime: CameraRuntime = { - clock: createCameraClock(), - projection: { fovYRad: 0, aspect: 1, near: 0.01, far: 50000 }, - lastPose: { - current: { ...cb.store.getState().camera.base }, - }, - prevActiveId: { current: 'resting' }, - // Seeded at J2000, a plausible epoch before the first frame runs. No frame - // has run pre-bootstrap, so no pick can fire against it; `runFrame` overwrites - // it with the real frame instant before the first pick is possible. - lastRenderedSimDays: { current: CONST_J2000 }, - // Seeded with the default frame's steady basis so a pre-first-frame read is - // valid; `runFrame` overwrites it with the resolved B(t) each frame. Copied - // so the seed never aliases the shared registry entry. - upBasis: { current: [...ORIENTATION_FRAMES[DEFAULT_ORIENTATION]] }, - }; + // Seeded with placeholders; wireInput re-seeds once the initial OrbitCamera + // exists. The register seeds from the camera slice's initial `base` — the + // single home for the pre-bootstrap placeholder pose, arm tag included. + const cameraRuntime = seedCameraRuntime({ + committed: cb.store.getState().camera.base, + projection: { fovYRad: 0, aspect: 1, near: NEAR_CLIP_MPC, far: FAR_CLIP_MPC }, + }); // Sky-cubemap bake bookkeeping — false/infinity/null until the first frame // the lensing band goes active; `renderFrame` is the sole writer thereafter. @@ -199,21 +127,10 @@ export function createEngine( bakedSettings: null, }; - // ── Settings — the injected Redux store ────────────────────────── - // - // The settings store is created once at the app root (main.tsx) and - // injected here, so the engine and React share one instance: React reads - // it through + useAppSelector, the engine reads it each frame - // via the `state.settings` getter below and writes it through the - // sub-handle setters' dispatches. The dozens of `state.settings.X` read - // sites stay byte-identical — the getter hands back `getState().settings` - // directly, with no parallel mirror to drift. const store = cb.store; - // Famous stars are seeded at construction (createEngineData → - // SCENE_STARS), not fetched, so there is no async slot commit to carry the - // usual `engineSourceCountReported` pulse. Report it here instead — the - // same action the survey star/galaxy catalogs' slots dispatch on load — so + // Famous stars are seeded at construction, not fetched, so there is no async slot + // commit to carry the usual `engineSourceCountReported` pulse — report it here so // the Stars panel's count chip lights up for the curated row too. const engineData = createEngineData(); store.dispatch( @@ -221,60 +138,42 @@ export function createEngine( ); const state: EngineState = { - // `state.settings` delegates to the injected store. Reads hand back - // `store.getState().settings`; the write path dispatches through the - // sub-handle setters, so per-frame reads see the authoritative object - // with no parallel mirror to keep in sync. + // These five getters delegate straight to the store: sagas and sub-handle + // setters own the writes, per-frame readers reach the authoritative object + // here, and there is no engine-side mirror to drift. get settings() { return store.getState().settings; }, - // `state.tier` delegates to the root `tier` slice the same way `settings` - // delegates above. The tier saga owns the write; the engine reads here. get tier() { return store.getState().tier; }, - // `state.selection` delegates to the root `selection` slice — the same - // single-seam pattern as `settings`/`tier`. The pick path dispatches writes; - // per-frame readers reach the store here, with no engine-side mirror to drift. get selection() { return store.getState().selection; }, - // `state.selectionRows` delegates to the saga-owned `selectionRows` slice. - // The selection-resolution saga is the sole writer; per-frame readers - // (selection-ring, structure focus) use this getter. get selectionRows() { return store.getState().selectionRows; }, - // Same single-seam pattern as `settings`/`tier`/`selectionRows` above — no - // engine-side mirror of the store slice to drift. get famousGalaxiesMeta() { return selectFamousGalaxiesMeta(store.getState()); }, - // Per-type data stores. Galaxies/structures are empty at construction and - // fill via slot commits; bodies (incl. the famous-star seed) are filled - // synchronously inside `createEngineData` itself — see its header. data: engineData, picking: { - // Per-frame pick-throttle state. Hover / select live on the - // Redux `selection` slice; see `EnginePickingState.d.ts`. - // `latestMouseCss`/`lastPickedMouseCss` are gone — hover picking is - // now fully pointer-driven via `hoverPickDriver` in wireInput.ts, which - // tracks its own `latest`/`picked` locals. + // Pick-throttle state only; hover/select live on the Redux `selection` slice. pickInFlight: false, pointerDown: false, }, gpu: { - // All GPU handles populate during the async IIFE below and - // release in `destroy()`. See `@types/EngineGpuHandles.d.ts` - // for the null-until-init lifecycle rationale. + // Every handle here is null until the async bootstrap constructs it and is + // released in `destroy()`. Only the `isEngineReady` members are relied on + // downstream; the rest are optional and null-checked at their use site. See + // `@types/EngineGpuHandles.d.ts` for the lifecycle. galaxyPointRenderer: null, galaxyPickRenderer: null, pickProgram: null, milkyWayPickRenderer: null, - // Canonical fade + source + focus bind-group layouts. Built once in - // initGpu and threaded into every renderer's createPipelineLayout so - // consumers share one layout identity. See - // services/gpu/bindGroupLayouts/fadeUniforms.ts (layout:'auto' trap). + // Canonical bind-group layouts, threaded into every renderer's + // createPipelineLayout so consumers share one layout identity — see + // services/gpu/bindGroupLayouts/fadeUniforms.ts (the layout:'auto' trap). fadeBgl: null, sourceBgl: null, focusBgl: null, @@ -283,133 +182,60 @@ export function createEngine( compositor: null, filamentRenderer: null, constellationRenderer: null, - // fontAtlases + uiCtx: null until initGpu resolves the font-atlas fetch; - // read by buildSwapRenderers to rebuild the swap-format renderers below - // on a later format change without re-threading bootstrap deps. + // Read by buildSwapRenderers to rebuild the swap-format renderers on a later + // format change without re-threading bootstrap deps. fontAtlases: null, uiCtx: null, - // labelRenderer + markerLineRenderer: null until initGpu finishes the - // font-atlas fetch. Excluded from isEngineReady (optional async - // resources, null-checked at use by labelsPass / markerLinesPass). labelRenderer: null, markerLineRenderer: null, - // Second MSDF label renderer for the foreground Sun/Earth captions - // (Plan 01 — zoom-to-Earth). null until initGpu; excluded from - // isEngineReady, null-checked at use like labelRenderer. foregroundLabelRenderer: null, - // Leader-line sibling of foregroundLabelRenderer — the NEAR0-slab - // connectors under the scene-body captions. null until initGpu; - // excluded from isEngineReady, null-checked at use. foregroundMarkerLineRenderer: null, - // The two label pick providers, one per slab. null until initGpu; - // excluded from isEngineReady, null-checked at use by the label layers' - // drawPick. labelPickRenderer: null, foregroundLabelPickRenderer: null, - // null until initGpu; excluded from isEngineReady, null-checked at use by - // clipPathDebugPass. debugLineRenderer: null, - // null until initGpu; excluded from isEngineReady, null-checked at use. selectionRingRenderer: null, structureMarkerRenderer: null, - // texturedDiskRenderer / proceduralDiskRenderer: null until initGpu - // constructs them. The frame body reads them straight off - // `state.gpu.*` (see `passes/index.ts`); they live here so `destroy()` - // can reach them and so later phases consume the same identities. texturedDiskRenderer: null, proceduralDiskRenderer: null, - // Milky-Way point cloud + its two-pass renderer. null until initGpu. - // Excluded from isEngineReady; released in destroy(). milkyWayCloud: null, milkyWayCloudRenderer: null, horizonShellRenderer: null, - // Galactic-plane dust-band guide. null until initGpu; excluded from - // isEngineReady, null-checked at use by zoneOfAvoidancePass. zoneOfAvoidanceRenderer: null, - // Shared world-geometry text renderer. null until initGpu; its first - // consumer is the zone-of-avoidance lettering path, null-checked at use. label3DRenderer: null, - // null until initGpu; excluded from isEngineReady — volumeUpsamplePass - // null-checks both before hasActiveFields(), so a null state no-ops. volumeFieldRenderer: null, flowFieldRenderer: null, volumeUpsample: null, - // null until initGpu; excluded from isEngineReady — - // milkyWayUpsamplePass null-checks it in draw, so a null no-ops. milkyWayAggregateUpsample: null, - // null until initGpu; excluded from isEngineReady — - // zoneOfAvoidanceUpsamplePass null-checks it in draw, so a null no-ops. zoneOfAvoidanceUpsample: null, - // null until initGpu; excluded from isEngineReady — - // starAggregateUpsamplePass null-checks it in draw, so a null no-ops. starAggregateUpsample: null, - // null until initGpu; excluded from isEngineReady — every bloom content - // layer's enable gate is exactly `bloomPyramid !== null`, so a null handle - // silently drops the whole bloom sub-program. + // Every bloom content layer's enable gate is exactly `bloomPyramid !== null`, + // so a null handle silently drops the whole bloom sub-program. bloomPyramid: null, - // Debug overlays. null until initGpu; the per-frame consumer - // null-checks each together with its `settings.debug.*` toggle. pickDebugOverlay: null, diskRadiusRing: null, - // True-scale textured Earth (Plan 02 — zoom-to-Earth). null until initGpu - // constructs it; its 'earth' texture slot in the bodyTextures family - // (proximity-demanded, commits via setMap) is minted later, in wireSlots. - // Excluded from isEngineReady, null-checked at use by earthPass. earthRenderer: null, - // Instanced surface-tile detail draw over the base globe. null until - // initGpu; excluded from isEngineReady, null-checked at use by earthPass. earthSurfaceTileRenderer: null, - // Anchor renderers (Plan 02 — zoom-to-Earth): the resolved near star - // (the Sun), one instanced planet renderer drawing every seeded - // planet, and the far-star additive points. null until initGpu; - // excluded from isEngineReady, null-checked at use by their layers. starRenderer: null, planetRenderer: null, - // Shared textured-sphere renderer for every non-Earth textured body; the - // bodyTextures family's commit/onRelease call its setMap/clearMap. texturedBodyRenderer: null, - // Saturn's rings — the translucent overlay half of the ring system, drawn - // last in the (foreground:0, NEAR0) group. null until initGpu; excluded - // from isEngineReady, null-checked at use by ringsPass. ringRenderer: null, - // Earth's translucent cloud shell — the thin deck drawn just above the - // opaque surface, immediately after earthPass in the (foreground:0, NEAR0) - // group. null until initGpu; excluded from isEngineReady, null-checked at - // use by cloudShellPass. cloudShellRenderer: null, - // The in-scatter atmosphere — the outermost translucent shell, drawn LAST in - // the (foreground:0, NEAR0) group and also read by the atmosphereSkyView step. atmosphereShellRenderer: null, starPointRenderer: null, - // Sub-pixel bodies (the glints branch of the body partition) as - // brightness-scaled additive points on the (hdr, NEAR0) step — the far - // half of the body LOD, sibling of starPointRenderer. null until initGpu; - // excluded from isEngineReady, null-checked at use by bodyGlintsPass. bodyGlintRenderer: null, - // The Sgr A* lens pass — a single billboard draw on Sgr A*'s own - // body-m slab row. null until initGpu; excluded from isEngineReady, - // null-checked at use by sgrAStarLensingPass. sgrAStarLensingRenderer: null, starCatalogRenderer: null, starCatalogPickRenderer: null, - // r32uint pick provider for the NEAR0 foreground bodies (Earth / planets / - // scene-star spheres + the sub-pixel scene-star points). null until - // initGpu; excluded from isEngineReady, driven by the body layers' drawPick. bodyPickRenderer: null, - // Keplerian orbit trails (Earth / Jupiter / Moon) — additive screen-space - // conics on the (hdr, NEAR0) step. null until initGpu; excluded from - // isEngineReady, null-checked at use by orbitTrailsPass. orbitTrailRenderer: null, - // Per-pass GPU timing service. Always non-null — a no-op stub until - // initGpu swaps in the device-aware service. Consumers gate on - // `.enabled`. + // The one exception to the null rule: always non-null, a no-op stub until + // initGpu swaps in the device-aware service. Consumers gate on `.enabled`. timingService: createDisabledGpuTimingService(), }, subsystems: { - // ── LOD impostor planners + atlas ───────── - // Null until `wireSlots` constructs them post-GPU init. The hi-res - // pair (LOD-3) is rebuilt per-tier so its `texture_2d_array` layerSide - // matches the active tier; the others persist across tier changes. + // The impostor planners are null until `wireSlots` constructs them post-GPU + // init. The hi-res pair (LOD-3) is rebuilt per-tier so its `texture_2d_array` + // layerSide matches the active tier; the others persist across tier changes. galaxyAtlas: null, proceduralDisks: null, texturedDisks: null, @@ -417,81 +243,41 @@ export function createEngine( hiResFamous: null, hiResFamousTexture: null, - // ── Earth surface virtual texture ───────────────────────────── - // Null until `wireSlots` constructs it post-GPU init, and holding no - // GPU memory even then — the atlas is allocated by the first frame the - // tile planner engages on. + // Holds no GPU memory even once constructed — the atlas is allocated by the + // first frame the tile planner engages on. earthTiles: null, - // ── Bias-correction subsystem ───────────────────────────────── - // Owns Malmquist-bias mode flags, cached per-source ratios/weights, - // and the async bake state machine. Eager (no GPU dep); the renderer - // is wired during initGpu via `attachRenderer`. The reconcile saga - // drives bake state via the bias.mode reconcile row. Production uses - // the module-level Vite `?worker` runners; tests inject synchronous stubs. biasCorrection: createBiasCorrectionSubsystem({ getMode: () => state.settings.bias.mode, getLoadedClouds: () => state.data.galaxies.catalogs, requestRender: () => state.subsystems.scheduler.requestRender(), }), - // ── Label director ─────────────────────────────────────────── - // The director owns the `labelRenderer.setLabels` / - // `markerLineRenderer.setLines` calls and declutters across all its - // `Label2DProducer`s (the milkyWay + structure/famous label producers, - // registered just after this literal). Renderers are wired in during - // initGpu so the director sees everything before the first frame. + // The directors own the label/marker-line uploads and declutter across every + // registered producer; the layers only issue draws against what was flushed. cosmoLabelDirector: createLabel2DDirector(COSMO_LABEL_DIRECTOR), - - // ── Foreground label director ────────────────────────────────── - // The NEAR0 sibling of `cosmoLabelDirector` — same factory, `screenSeparation` - // + `exponentialApproach` + lift arms instead. Owns the caption + leader- - // line upload for `produceSceneBodyCaptions` + `produceConstellationCaptions` - // (registered just after this literal); `foregroundLabelsPass` only - // issues the draw calls against what this director already flushed. foregroundLabelDirector: createLabel2DDirector(FOREGROUND_LABEL_DIRECTOR), - // ── Cluster focus-mode subsystem ───────────────────────────── - // Selection-driven: `runFrame` calls `update(selectedStructure, nowMs)` to - // drive the 400 ms member-isolation fade and threads - // `produceFocusUniforms` into the points draw. Eager, no GPU dep. structureFocus: createStructureFocusSubsystem({ requestRender: () => state.subsystems.scheduler.requestRender(), }), - // ── Clip player ─────────────────────────────────────────────── - // Owns the active clip's scene cues, the clipOpacity channel, and - // clip-completion lifecycle. Eager (no GPU dep), non-null from t=0. - // tick() is called as the first step of runFrame (Task 12). clipPlayer: createClipPlayer({ store: cb.store, requestRender: () => state.subsystems.scheduler.requestRender(), - clock: cameraRuntime.clock, getEngineState: () => state, }), - // ── Input aggregator ────────────────────────────────────────── - // Collects the gesture recognizer's events; `drainInput` applies them - // once per frame. Eager (no GPU dep) — the first rAF can beat the - // async `wireInput` phase that attaches the recognizer. + // Eager, because the first rAF can beat the async `wireInput` phase that + // attaches the recognizer. inputAggregator: createInputAggregator(), - // ── Clip-path inspector (debug) ─────────────────────────────── - // Holds the precomputed ClipPathSnapshot the debug panel's "Calculate" - // button produces; the clip-path debug pass reads it each frame. Eager - // (no GPU dep), non-null from t=0; snapshot null until the first Calculate. clipPathInspector: createClipPathInspector(), - // ── Render scheduler — eager, capture-safe ──────────────────── - // Created here (not a deferred shim): its `onFrame` closes over the - // forward-declared `frame` binding, and the IIFE assigns the real - // body before any rAF fires. Anyone capturing the scheduler gets the - // live one — a deferred shim by reference would break hover-pick on - // first frames. - // `onFrame` is the single per-frame chokepoint — every rAF the scheduler - // fires runs the real body here. Timing wraps this one site (loop entry → - // after the body returns, which is post-submit) so the frame stats stay a - // pure measurement: the body is invoked exactly as before, unmodified. + // Constructed eagerly, not as a deferred shim: anyone capturing the scheduler + // gets the live one, and a shim by reference would break hover-pick on the + // first frames. `onFrame` is the single per-frame chokepoint, which is why + // the frame-stats timing wraps this one site. scheduler: createRenderScheduler({ onFrame: () => { const start = performance.now(); @@ -507,48 +293,28 @@ export function createEngine( }, }), - // ── Fade registry ────────────────────────────────────────── - // Eager so initGpu can register handles without a null-check. Pure - // CPU — no GPU device at construction. + // Eager so initGpu can register handles without a null-check. fades: createFadeRegistry({ requestRender: () => state.subsystems.scheduler.requestRender(), }), - // ── Boot asset queue ────────────────────────────────────────── - // Bounds how many boot fetches (catalog `.bin` files, body textures) - // run at once — see `ASSET_QUEUE_CONCURRENCY` for why 2, not the - // thumbnail queue's `MAX_CONCURRENT_FETCHES`. Eager, no GPU dep: - // `evaluateRows` (the per-frame demand walk) can enqueue before the - // GPU init IIFE below finishes. + // Bounds concurrent boot fetches — see `ASSET_QUEUE_CONCURRENCY` for why 2, + // not the thumbnail queue's `MAX_CONCURRENT_FETCHES`. assetQueue: new PriorityQueue(ASSET_QUEUE_CONCURRENCY), - // The rest land later in the IIFE once their deps (GPU device, - // galaxyPickRenderer, scheduler) exist. clickResolver: null, inputBindings: null, - // Download-progress aggregator — built inside the IIFE so the - // `engineLoadProgressChanged` dispatch is the closure target. loadProgress: null, }, - cam: null, - // The live camera Resources — clock, projection, lastPose, prevActiveId. - // Seeded with placeholders; wireInput fills real values at bootstrap. + booted: false, cameraRuntime, skyCubemapCapture, - // ── Asset-loading slot bag ─────────────────────────────────────────── - // - // Each slot is a race-checked fetch→commit pipeline (see - // `services/loading/AssetSlot.ts`). The Map is declared up-front so - // consumers can `state.assetSlots.points.get(source)?.load(...)` without - // a null check, but the slots are minted in `wireSlots`: their commit - // closures re-read GPU handles (renderer, filamentRenderer, - // volumeFieldRenderer) at call time and null-guard, rather than assuming - // `initGpu` already assigned them — the same destroy-race posture the - // keyed `bodyTextures` family below uses. + // The Maps are declared up-front so consumers can reach a slot without a null + // check, but the slots themselves are minted in `wireSlots`: their commit + // closures re-read GPU handles at call time and null-guard, rather than assuming + // `initGpu` already assigned them. assetSlots: { points: new Map(), - // Per-source star catalogs — registry-built (wireSlots), like points; - // the star slot's commit null-guards the renderer the same way. starCatalogs: new Map(), filaments: null, famousGalaxiesMeta: null, @@ -558,44 +324,26 @@ export function createEngine( cf4Density: null, // Tier-aware (unlike cf4Density): setTier reloads on tier change. mcpm: null, - // Default-off velocity flow field; demand-loaded like cf4Density. flow: null, - // Default-off 2MRS Polyphorm density volume; tier-aware like mcpm. + // Tier-aware like mcpm. polyphorm2Mrs: null, - // Hidden until Phase 4 clears; untiered like cf4Density. mcpmWorkbench: null, - // Constellation stick-figure artifact; demand-loaded on its master gate. constellations: null, - // Keyed body-surface texture family (Earth + planets/moons + Saturn ring), - // minted in wireSlots. Empty map at construction — proximity-demanded + - // released per body (mirrors the `points` map). bodyTextures: new Map(), - // The all-bodies low-res atlas: one boot fetch seeding every body's - // placeholder, so no body ever draws untextured while its own map loads. + // One boot fetch seeding every body's placeholder, so no body ever draws + // untextured while its own map loads. bodyTextureAtlas: null, }, - // ── One-shot transient request flags ──────────────────────────────── - // - // Edge-triggered UI events that drive demand predicates (palette opened, - // lazy alias requested) with no persistent home. The wiring layer sets - // a key and leaves it set — the demand loop's idle-guard prevents a - // re-fetch, so no clear is needed. See `@types/loading/RequestKey.d.ts`. + // Edge-triggered UI events driving demand predicates. The wiring layer sets a + // key and leaves it set — the demand loop's idle-guard prevents a re-fetch. requests: new Set(), }; - // ── Register label producers with the director ─────────────────────── - // - // Registration order = merged label order: milkyWayLabel, then the structure - // labels, then the famous-galaxy labels. The director declutters across all - // of them by `prominencePx`, so registration order only sets the tiebreak for - // equal-prominence collisions (rare). All producers are pure functions over - // the state; wrap each as a Label2DProducer with a stable id. All eager, so - // this is synchronous before any frame. - // - // The constellation figure NAMES are deliberately NOT here: their anchors sit - // at parsec distances, inside the COSMO slab's fixed 0.01-Mpc near plane this - // director projects through, so a label here could never draw. They register - // on `foregroundLabelDirector` (NEAR0) instead, just below. + // Registration order only sets the tiebreak for equal-`prominencePx` collisions; + // the director declutters by prominence otherwise. The constellation figure NAMES + // are deliberately NOT here: their anchors sit at parsec distances, inside the + // COSMO slab's fixed 0.01-Mpc near plane, so a label here could never draw — they + // register on `foregroundLabelDirector` (NEAR0) below. state.subsystems.cosmoLabelDirector.registerProducer({ id: 'milkyWayLabel', produceLabels: produceMilkyWayLabel, @@ -609,11 +357,8 @@ export function createEngine( produceLabels: produceFamousGalaxyLabels, }); - // The NEAR0 sibling registration: scene-body captions first, matching the - // COSMO order's "landmark before decoration" shape — body captions are - // navigation aids, the constellation figures a diffuse orientation overlay - // (`captionPriority.ts`'s own ranking) — so an equal-`prominencePx` tiebreak - // (rare) favours the body. + // Scene-body captions first so an equal-prominence tiebreak favours the + // navigation aid over the diffuse constellation overlay. state.subsystems.foregroundLabelDirector.registerProducer({ id: 'sceneBodyCaptions', produceLabels: produceSceneBodyCaptions, @@ -623,35 +368,19 @@ export function createEngine( produceLabels: produceConstellationCaptions, }); - // ── Cleanup function returned by `attachOrbitControls` ───────────────── - // Orbit-controls attachment lives outside `inputBindings` because it - // needs a fully-constructed OrbitCamera, absent at engine() time. A - // transient local (single teardown fn, no other consumers), boxed as - // `{current}` because `attachOrbitControls` runs in the `wireInput` - // sibling phase. `destroy()` reads through the ref to detach. + // Orbit-controls attachment lives outside `inputBindings` because it needs a + // fully-constructed OrbitCamera, absent at engine() time; boxed because + // `attachOrbitControls` runs in the sibling `wireInput` phase. const detachControlsRef: { current: (() => void) | null } = { current: null }; - // ── Async startup ──────────────────────────────────────────────────────── - - // Flat slot registry keyed by `slot.name`, at outer scope so the public - // handle exposes it as `assetSlots` (the `LoadingDevPanel`). The IIFE - // populates it as each slot is minted. The same instance feeds the - // load-progress emitter, so the loading bar and dev panel agree on what's - // in flight. + // One instance feeds both the dev panel and the load-progress emitter, so the + // loading bar and the panel agree on what is in flight. const allSlots = new Map>(); cb.store.dispatch(engineStatusChanged({ kind: 'initializing' })); - // ── Bootstrap dependency bag ───────────────────────────────────────────── - // - // The four bootstrap phases consume a shared `BootstrapDeps` built here: - // the canvas + cb args, `{current}` ref boxes for forward-declared - // bindings (frameRef, detachControlsRef, handleRef), and the `allSlots` - // registry `startLoop` and the loading bar share. - // - // `handleRef.current` is null here — the handle is declared after the - // IIFE below. `wireInput`'s onDoubleClick reads it lazily, so it's - // non-null by the time a user can physically double-click. + // Null here — the handle is declared after the IIFE below. `wireInput` reads it + // lazily, so it is non-null by the time a user can physically double-click. const handleRef: { current: EngineHandle | null } = { current: null }; const bootstrapDeps: BootstrapDeps = { canvas, @@ -663,35 +392,17 @@ export function createEngine( allSlots, }; - // Register all saga runners in one setSagaContext call so the running root - // saga receives the full context bag synchronously, before the async GPU - // bootstrap finishes. - // - // `makeRunTierTransition(state, bootstrapDeps)` closes over `bootstrapDeps` - // (reading `device` lazily off `phaseLocals`) — safe to build here because - // the closure dereferences the device only at call time, after initGpu - // populates it. - // - // `makeReconcileEffects(state)` closes over the live `state.gpu` and - // `state.subsystems`, also dereferenced lazily at call time — the same - // rationale: registering before the async bootstrap is safe because the - // subsystems the closures reach into are populated before any saga dispatches - // them. - // - // `resolveDeps` hands the reconciler saga and the focus-tween saga the LIVE - // engine resources (read lazily each call, because clouds + structures change - // as data loads and the GPU lands only after bootstrap). requestRender is NOT - // added here — selection sagas reach it through the existing `reconcile` bag. + // Every runner below is registered in ONE `setSagaContext` call before the async + // GPU bootstrap finishes, which is safe only because each closure dereferences + // its engine resources lazily, at call time. const resolveDeps = (): ResolveDeps => ({ catalogs: { get: (source: GalaxyCatalogSourceType) => state.data.galaxies.catalogs.get(source), }, famousGalaxiesMeta: state.famousGalaxiesMeta, structures: { byId: (id) => state.data.structures.byId(id) }, - // The sole loaded star catalog — the first (only, in v1) committed Gaia - // catalog off the renderer, or null before the star cloud lands or after - // the GPU tears down. Read lazily like the other getters so a star pick / - // deep-link always sees the current catalog. + // The first (only, in v1) committed Gaia catalog, or null before the star cloud + // lands and after the GPU tears down. stars: { current: () => { const renderer = state.gpu.starCatalogRenderer; @@ -702,39 +413,21 @@ export function createEngine( }, }); - // Bound clip player, hoisted into the saga context as the single clip-run - // seam: resolves the 'live' pose at dispatch time, attaches the [CANCEL] hook, - // and returns a Promise that resolves on both natural end and cancellation. - // The tour saga awaits this for the establishing fly and races it (as - // dwellDrift) against the dwell timer; `watchClipSaga` runs it for a `playClip` - // action (the dev panel's single-clip path). + // The single clip-run seam the saga context exposes. const playClip = createPlayClip({ store, clipPlayer: state.subsystems.clipPlayer, - // No null-guard needed: lastPose.current is seeded from camera.base at - // CameraRuntime construction (synchronous) and playClip is only ever - // invoked from tour/tween sagas or the dev panel, all after construction. - getLivePose: () => state.cameraRuntime.lastPose.current, + getLivePose: () => liveWorldPose(state), }); - // Debug clip-path inspector seam — `watchClipPathInspectSaga` calls `compute` - // to sample a clip's camera route into the `clipPathInspector` subsystem (read - // each frame by `clipPathDebugPass`) and `clear` to drop it. Shares the same - // live-pose accessor as `playClip` so a `start:'live'` clip samples from the - // pose the user sees. - // - // The sample count must cover the WAYPOINT-DENSEST clip, not just the sparse - // demo path: a flyPath threading ~200 waypoints gives 384 samples only ~1.9 - // per leg — the polyline then draws the raw waypoint-to-waypoint CHORDS and - // hides the smooth spline between knots, reading as hard corners everywhere. - // 4000 samples is ~20 per leg on such a route, enough to resolve the actual - // curve so a smooth stretch reads smooth and only genuinely tight turns - // still bend. This must stay within the - // debugLineRenderer's maxLines (2·(n−1) route+target segments + 9 gizmo = - // 8007 here; the renderer is built with 8192 in `initGpu`). + // `sampleCount` must cover the WAYPOINT-DENSEST clip, not the sparse demo path: a + // flyPath threading ~200 waypoints gets only ~1.9 samples per leg at 384, so the + // polyline draws raw waypoint-to-waypoint CHORDS and hides the spline between + // knots — hard corners everywhere. 4000 is ~20 per leg on such a route, and stays + // within debugLineRenderer's maxLines (2·(n−1) + 9 gizmo = 8007 against 8192). const clipPathInspect = createClipPathInspectSeam({ inspector: state.subsystems.clipPathInspector, - getLivePose: () => state.cameraRuntime.lastPose.current, + getLivePose: () => liveWorldPose(state), sampleCount: 4000, }); @@ -742,18 +435,14 @@ export function createEngine( runTierTransition: makeRunTierTransition(state, bootstrapDeps), reconcile: makeReconcileEffects(state, canvas), resolveDeps, - // The live camera Resources the focus and orientation sagas read off the - // frame loop: the visible from-pose (so a re-focus hands off from what the - // user sees), the lens FOV (for structure screen-fill framing), and the - // up-basis quaternion resolved THIS frame via `liveUpBasisQuat`, so a - // mid-slerp re-switch captures the live pole rather than snapping to the - // committed frame. Null when `state.cam` is absent — pre-bootstrap or - // post-destroy — so both sagas no-op rather than seed from a stale pose. + // The up-basis quaternion is resolved THIS frame, so a mid-slerp re-switch + // captures the live pole rather than snapping to the committed frame. Null + // pre-bootstrap and post-destroy, so a saga no-ops rather than seeding stale. cameraRuntime: () => - state.cam + state.booted ? { - from: state.cameraRuntime.lastPose.current, - fovYRad: state.cameraRuntime.projection.fovYRad, + from: liveWorldPose(state), + fovYRad: state.cameraRuntime.outputs.projection.fovYRad, upBasisQuat: liveUpBasisQuat(state.cameraRuntime), } : null, @@ -761,52 +450,37 @@ export function createEngine( clipPathInspect, }); - // The main async IIFE runs the bootstrap phases; all errors are caught - // and dispatched via `engineStatusChanged({ kind: 'error' })`. See `runBootstrapPhases`. - // `void`: nothing awaits engine construction, and the catch below already - // routes failures to the status callback rather than an unhandled rejection. + // `void`: nothing awaits engine construction, and the catch routes failures to + // the status callback rather than an unhandled rejection. void (async () => { try { await runBootstrapPhases(state, bootstrapDeps); } catch (err) { - // Surface initialisation failures via the status callback so the UI - // shows a readable message rather than a blank canvas. const message = err instanceof Error ? err.message : String(err); cb.store.dispatch(engineStatusChanged({ kind: 'error', message })); console.error('Engine startup failed:', err); } })(); - // ── Public handle ───────────────────────────────────────────────────────── - // - // Bespoke local methods handle async bakes, subsystem forwards, multi-field - // mutations, and live-state reads. The handle literal at the end stitches - // them into the public sub-handle clusters. - - // ── Bespoke methods (async bakes, subsystem forwards, multi-field mutations) ── - // - // Each owns work a simple store dispatch can't express: async worker bakes, - // per-source slot reloads, subsystem forwards, multi-field mutations, or - // returning live state. Declared up-front so the sub-handle literal can - // reference each by name — no forward references, no `!` assertions. - + // Declared up-front so the handle literal can reference each by name — no forward + // references, no `!` assertions. function logCameraStateFn(): void { - const simDays = state.cameraRuntime.lastRenderedSimDays.current; + const simDays = state.cameraRuntime.outputs.simDays; logCameraState( liveRenderCamera(state), canvas, liveFocusRow(state.selectionRows.focus, simDays), simDays, state.subsystems.earthTiles?.getDebugSnapshot().subCamera ?? null, + state.cameraRuntime.outputs.displayed, ); } function loadPgcAliasesFn(): Promise { - // Set the edge-triggered flag and wake the loop: the pgcAlias row demands - // on `request('paletteOpened')`, so the next `reevaluateDemand` fires the - // load. The flag stays set (idle-guard prevents a re-fetch), so a second - // open resolves off the ready slot. An errored load isn't retried; - // awaitSlotReady then yields the empty-map fallback. + // The pgcAlias row demands on `request('paletteOpened')`, so setting the flag + // and waking the loop is what fires the load. The flag stays set, so a second + // open resolves off the ready slot; an errored load isn't retried, and + // `awaitSlotReady` then yields the empty-map fallback. state.requests.add('paletteOpened'); state.subsystems.scheduler.requestRender(); return awaitSlotReady(state.assetSlots.pgcAlias, new Map() as PgcAliasMap); @@ -825,31 +499,22 @@ export function createEngine( } function destroy(): void { - // Every subsystem and renderer satisfies `Destroyable`, so this reads as - // a flat list of `.destroy()` calls. Ordering is load-bearing only for - // the first two groups (scheduler before everything; DOM listeners - // before the subsystems they fire into); past that it's free. - - // 1. Cancel the render loop first — every subsequent destroy() must be - // safe after the loop has stopped. + // Ordering is load-bearing only for the first two groups: the render loop stops + // before anything it touches is torn down, and DOM listeners detach before the + // subsystems they fire into. Past that it is free. state.subsystems.scheduler.destroy(); state.subsystems.assetQueue.destroy(); - // 2. Detach DOM listeners before the subsystems they fire into. state.subsystems.inputBindings?.destroy(); state.subsystems.inputBindings = null; detachControlsRef.current?.(); detachControlsRef.current = null; // Drop anything the recognizer queued but no frame ever drained. state.subsystems.inputAggregator.destroy(); - // The HDR-capability matchMedia listener `initGpu` registers via - // `watchHdrCapability` — `phaseLocals` is assigned immediately after - // registration (not at the end of `initGpu`'s many-hundred-line body), - // so this is undefined only if the GPU IIFE errored before that point: - // device/context acquisition or the listener registration itself. + // Undefined only if the GPU IIFE errored before registering the HDR-capability + // matchMedia listener — `phaseLocals` is assigned immediately after it. bootstrapDeps.phaseLocals?.unwatchHdrCapability(); - // 3. Walk every other subsystem (order-independent past here). state.subsystems.biasCorrection.destroy(); state.subsystems.cosmoLabelDirector.destroy(); state.subsystems.foregroundLabelDirector.destroy(); @@ -866,16 +531,12 @@ export function createEngine( state.subsystems.hiResFamousTexture = null; state.subsystems.proceduralDisks?.destroy(); state.subsystems.proceduralDisks = null; - // The shared walk holds no GPU resource — just the stride cursor — so - // its teardown order relative to the atlas is irrelevant; grouped with - // the disk planners it drives. state.subsystems.diskPlannerWalk?.destroy(); state.subsystems.diskPlannerWalk = null; state.subsystems.galaxyAtlas?.destroy(); state.subsystems.galaxyAtlas = null; - // The Earth tile subsystem owns a 67 MB atlas and a page-table texture - // once engaged, neither of which WebGPU releases on GC. Order-independent: - // nothing subscribes to it. + // Owns a 67 MB atlas and a page-table texture once engaged, neither of which + // WebGPU releases on GC. state.subsystems.earthTiles?.destroy(); state.subsystems.earthTiles = null; state.subsystems.clickResolver?.destroy(); @@ -883,34 +544,25 @@ export function createEngine( state.subsystems.loadProgress?.destroy(); state.subsystems.loadProgress = null; - // 4. GPU renderers. WebGPU buffers/textures don't release via JS GC, so - // destroy() is mandatory. One reverse walk over GPU_HANDLE_ROWS covers - // every registry-owned handle: declaration order is construction order - // (`focusUniform` first, `pickProgram`/`galaxyPickRenderer` last — - // see gpuHandleRegistry.ts), so the reversed walk destroys the two - // pick rows first and `focusUniform` last, after the pick renderer - // that captures its bind group at construction. + // WebGPU buffers/textures don't release via JS GC, so destroy() is mandatory. + // The walk is REVERSED because registry declaration order is construction order + // — so `focusUniform` is destroyed last, after the pick renderer that captured + // its bind group at construction. destroyGpuHandles(GPU_HANDLE_ROWS, state); - // The 6 registry exclusions keep their own teardown. fontAtlases/uiCtx - // own no GPU resource (decoded atlas data / raw device+context+canvas - // refs) — re-nulled for lifecycle symmetry, not released. + // fontAtlases/uiCtx own no GPU resource — re-nulled for lifecycle symmetry. state.gpu.fontAtlases = null; state.gpu.uiCtx = null; state.gpu.timingService.destroy(); state.gpu.timingService = createDisabledGpuTimingService(); - // 5. Drop remaining strong references to aid GC. for (const source of [...state.data.galaxies.catalogs.keys()]) { state.data.galaxies.removeCatalog(source); } - state.cam = null; + state.booted = false; } - // ── Handle literal — sub-handle clusters + destroy + slots ── - // - // Each sub-handle delegates to local functions. This literal is the - // engine's only public surface; it holds imperative operations (camera, - // selection, sources, volumes, debug) while store writes go direct to the store. + // The engine's only public surface: imperative operations only — store writes go + // direct to the store. const handle: EngineHandle = { camera: { logState: logCameraStateFn, @@ -929,57 +581,64 @@ export function createEngine( list: () => listVolumeFields(state), getState: () => getVolumeFieldsState(state), }, - // ── Debug sub-handle (observability + dev toggles) ──────── - // - // `timingService`: a getter, not a copied reference, because initGpu - // assigns `state.gpu.timingService` AFTER this literal is built — a copy - // would be null forever. - // - // `passOverrides`: read-only pass-name list for the DebugPanel's renderer - // toggle section. `allNames` is materialised from the hdr- and swap-target - // `CONTENT_PASSES` (the volume-target raymarch has no user toggle, so it is - // excluded) so the React rows track the frame's actual draw order. - // The DebugPanel dispatches `setPassDisabled` directly; `watchWakeSaga` wakes - // the render loop on the store write. debug: { + // A getter, not a copied reference: initGpu assigns `state.gpu.timingService` + // AFTER this literal is built, so a copy would be null forever. get timingService() { return state.gpu.timingService; }, - // Snapshot the rolling CPU-side frame stats. `fps` is rounded for display; - // `idle` is derived here (not stored) from the wall-clock gap since the - // last frame, so a sleeping render-on-demand loop reads "idle" rather than - // a stale fps. `lastStartMs === 0` means no frame has run yet. + // `idle` is derived, not stored, from the wall-clock gap since the last frame, + // so a sleeping render-on-demand loop reads "idle" rather than a stale fps. frameStats: (): FrameStats => ({ fps: Math.round(frameStats.fps), cpuMs: frameStats.cpuMs, idle: frameStats.lastStartMs === 0 || performance.now() - frameStats.lastStartMs > IDLE_GAP_MS, }), + // The volume-target raymarch has no user toggle, so it is excluded. passOverrides: { allNames: CONTENT_PASSES.filter((l) => l.target !== 'volume').map((p) => p.name), }, - // Re-derived per call off the live state rather than snapshotted: the - // slots this joins against are minted by the async IIFE below. + // Re-derived per call, not snapshotted: the slots this joins against are + // minted by the async bootstrap. assetPriorities: () => assetPriorityBySlotName(state), - // `state.subsystems.earthTiles` is null before Earth's slot wires (and - // again after destroy), so the fallback keeps the panel's read total. earthTiles: () => state.subsystems.earthTiles?.getDebugSnapshot() ?? EMPTY_EARTH_TILE_DEBUG_SNAPSHOT, + // An off-frame read that never writes camera state, so it goes through + // `liveWorldPose` + `deriveBodyStates` at `outputs.simDays`. `liveSimDays` + // alone resolves fresh — it is what the epoch-mismatch check compares against. + cameraDebug: () => { + const rootState = store.getState(); + const time = selectTimeState(rootState); + const { register, surface, outputs } = state.cameraRuntime; + const bodyStates = deriveBodyStates(outputs.simDays) as ReadonlyMap; + return cameraDebugSnapshotOf({ + storedFrame: rootState.camera.base.frame, + renderedPose: outputs.displayed, + worldPose: liveWorldPose(state), + poseBasis: ORIENTATION_FRAMES[state.settings.orientation], + upBasis: outputs.upBasis, + orientationFrame: state.settings.orientation, + bodyStates, + lastRenderedSimDays: outputs.simDays, + liveSimDays: deriveSimDays(time, performance.now()), + time, + activeDriverId: register.winner, + gesture: surface.gesture, + rememberedTiltRad: surface.rememberedTiltRad, + tuning: rootState.camera.tuning, + deltas: readOrientDeltas(), + }); + }, }, destroy, - // ── Asset-slot registry (dev-panel surface) ────────────────────────── - // - // The same `allSlots` Map the IIFE populates, so the dev panel observes - // slots as they appear. Read-only at the type level so React-side - // mutation trips the typechecker. + // The same Map the bootstrap populates, so the dev panel observes slots as they + // appear. Read-only at the type level, so React-side mutation trips tsc. assetSlots: allSlots, }; - // Publish the handle so `wireInput` can read it lazily. The IIFE may - // still be in flight, but the handle is non-null well before the user - // can interact. handleRef.current = handle; return handle; diff --git a/src/services/engine/frame/drainInput.ts b/src/services/engine/frame/drainInput.ts deleted file mode 100644 index 50c8fe3616..0000000000 --- a/src/services/engine/frame/drainInput.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * drainInput — the single per-frame input-apply site. Runs at the top of - * `runFrame`, above the store read the driver table resolves against, so a - * gesture that began between frames is visible to this frame's produce step. - * Steps arrive in order, so a wheel tick between two drags still changes the - * rate the second drag is applied at. - * - * `beginDrag` / `cancelCameraTween` are NOT here — the emit sink dispatches them - * at DOM time (`wireInput`) so a cancel cannot outlive the tween a double-click - * starts in the same gap. - */ - -import { seedCameraFromBase } from '../../camera/seedCameraFromBase'; -import { applyInputToCamera } from '../../camera/applyInputToCamera'; -import { applyWheelZoom } from '../camera/applyWheelZoom'; -import { pivotFraming } from '../camera/pivotRadiusMpc'; -import { poseOf } from '../camera/poseOf'; -import { selectFocusRow } from '../../../state/selection/selectors'; -import { endDrag, commitCameraPose } from '../../../state/camera/cameraSlice'; - -import type { EngineState } from '../../../@types/engine/state/EngineState'; -import type { RunFrameDeps } from '../../../@types/engine/frame/RunFrameDeps'; - -export function drainInput(state: EngineState, deps: RunFrameDeps, nowMs: number): void { - const steps = state.subsystems.inputAggregator.drain(); - if (steps.length === 0) return; - - const store = deps.cb.store; - const cssHeight = deps.canvas.clientHeight || 1; - // Pre-bootstrap `state.cam` is null and no recognizer is attached, so only the - // register arms need the guard — the store edges fired unconditionally in the - // callbacks this drain replaced, and stay unconditional. - const cam = state.cam; - - for (const step of steps) { - switch (step.kind) { - case 'gestureStart': - // Seed from the live PRODUCED pose, not `camera.base`: mid-tween those - // differ, and only the produced pose is where the user sees the camera. - if (cam !== null) seedCameraFromBase(cam, state.cameraRuntime.lastPose.current); - break; - - case 'gestureEnd': - // Commit BEFORE `endDrag` so the baked pose is in `base` the moment the - // orbitDrag driver deactivates — otherwise the next frame's resting - // driver returns the pre-gesture base and the camera snaps back. - if (cam !== null) store.dispatch(commitCameraPose(poseOf(cam))); - store.dispatch(endDrag()); - break; - - case 'drag': - if (cam !== null) { - applyInputToCamera(cam, step, cssHeight, pivotFraming(selectFocusRow(store.getState()))); - } - break; - - case 'zoom': { - if (step.duringGesture) { - if (cam !== null) { - applyInputToCamera(cam, step, cssHeight, pivotFraming(selectFocusRow(store.getState()))); - } - break; - } - // At rest the register is invisible — `applyWheelZoom` routes the factor - // to whichever driver actually owns the distance this frame. - const root = store.getState(); - const zoomed = applyWheelZoom( - state.cameraRuntime.clock, - state.cameraRuntime.prevActiveId.current, - root.camera.base, - step.factor, - root.camera.autoRotate, - nowMs, - pivotFraming(selectFocusRow(root)), - ); - if (zoomed !== null) store.dispatch(commitCameraPose(zoomed)); - break; - } - } - } -} diff --git a/src/services/engine/frame/frameContext.ts b/src/services/engine/frame/frameContext.ts index 7698ba92ed..4e70a64313 100644 --- a/src/services/engine/frame/frameContext.ts +++ b/src/services/engine/frame/frameContext.ts @@ -1,86 +1,10 @@ /** * frameContext — a typed snapshot of 'what the world looks like this frame', - * derived once at the top of `runFrame()` and threaded into `renderFrame()` as - * a single struct. + * derived once at the top of `runFrame` and threaded into `renderFrame`. * - * ### Why a per-frame derived context exists - * - * The alternative is free-standing snapshot locals at the top of the frame - * body — camera, view-proj, renderer, post-process handles — followed by an - * n-way null check, with each snapshot (plus derived scalars like `drawCamPos` - * / `drawPxPerRad`) forwarded into `renderFrame()` as separate fields and - * possibly recomputed there. That arrangement has three legibility problems: - * - * 1. The 'is the engine bootstrapped?' question has no single answer site — - * every frame redoes the n-way check, and any call site that wants to ask - * the same question has to copy-paste it. - * 2. Derived scalars get computed twice (runFrame → renderFrame), in different - * files, with no link between the two derivations. Drift is a latent bug. - * 3. Type narrowing doesn't flow. Each consumer needs its own non-null - * assertion or local guard, even though the engine is provably-ready by the - * time the GPU dispatch runs. - * - * Instead: one named struct, derived once at the top of the frame body and - * consumed by every downstream site that asks 'what's the camera doing this - * frame?'. Adding a new derived per-frame quantity (e.g. frustum planes for - * culling) is a one-line addition to `ReadyFrameContext`, not a multi-snapshot - * scatter across two files. - * - * ### Why the discriminated union (isReady: true | false) - * - * The alternative is `FrameContext | null` — a nullable shape where the caller - * writes `if (!ctx) return`. That works structurally, but the named boolean - * reads better at every call site: - * - * if (!ctx.isReady) { // self-describing - * state.subsystems.scheduler.requestRender(); - * return; - * } - * - * if (!ctx) return; // what does 'not ctx' mean? - * - * The discriminated union also lets helper functions take `ReadyFrameContext` - * instead of `FrameContext`, encoding 'this code only runs after the bootstrap - * gate passed' directly in the type system. - * - * ### Why `drawCamPos: Readonly` (a tuple) - * - * `OrbitCamera.position` is a mutable `Vec3` tuple, updated in place by - * `updatePosition`. Forwarding the live array to downstream passes risks - * two failure modes: (a) a consumer accidentally mutating an entry (array - * writes don't fault), and (b) the camera moving between the snapshot point and - * the read point inside one frame. Snapshotting to a plain readonly tuple - * defends against both: the array is small (3 floats — copy is essentially - * free), the shape is pinned, and the `Readonly<...>` modifier makes attempted - * writes a tsc error. - * - * ### Why the GPU handles ride along on the ready context - * - * `state.gpu.galaxyPointRenderer`, `state.gpu.renderTargets`, and `state.subsystems.thumbnails` - * are all part of the 5-way bootstrap gate. Once the gate passes, downstream - * code wants to use those handles without re-checking they're non-null — but if - * we left them on `state.gpu.*` and `state.subsystems.*`, every consumer would - * have to re-narrow them locally (since TS can't track that another function - * asserted them non-null earlier in the call stack). - * - * Forwarding the narrowed handles onto `ReadyFrameContext` carries the narrowing - * across the function boundary. A pass implementation can read - * `ctx.galaxyPointRenderer.draw(...)` directly, no `!` needed. - * - * The trade-off is mild type duplication: `ReadyFrameContext` lists fields that - * also live on `EngineState`. We accept it because the win at the call site (no - * re-narrowing) is greater than the cost (one declaration site duplicated). - * - * ### Why `pose` and `projection` are passed in (the threaded-pose variant) - * - * `runFrame` produces the pose once (for the tween-completion check and the - * commit-on-edge gate), then passes the already-produced `pose` and the live - * `projection` into `deriveFrameContext`. This avoids calling `runCameraDrivers` - * a second time (which would advance the clock twice on the same frame — the - * clock is idempotent for the same descriptor reference, but two calls is still - * conceptually wrong). `deriveFrameContext` is therefore side-effect-free again: - * it only calls `assembleOrbitCamera(pose, projection, poseBasis, upBasis)`, - * `computeViewProj`, and `deriveSlabs` to build the frame's slab table. + * `deriveFrameContext` is side-effect-free: the camera clock is advanced by + * `runFrame`'s produce step, not here, so the pick path can call it + * speculatively between frames. */ import type { EngineState } from '../../../@types/engine/state/EngineState'; @@ -89,6 +13,7 @@ import type { Vec3 } from '../../../@types/math/Vec3'; import type { FrameContext } from '../../../@types/engine/frame/FrameContext'; import type { CameraPose } from '../../../@types/camera/CameraPose'; import type { CameraProjection } from '../../../@types/camera/CameraProjection'; +import type { FramedCameraPose } from '../../../@types/camera/FramedCameraPose'; import type { Mat3 } from '../../../@types/math/Mat3'; import type { BodyPoseProvider } from '../../../@types/engine/camera/BodyPoseProvider'; import type { SceneBody } from '../../../@types/scene/SceneBody'; @@ -101,6 +26,7 @@ import { starSphereRangeM } from '../../../utils/scene/starSphereRangeM'; import { isEngineReady } from '../helpers/engineReady'; import { assembleOrbitCamera } from '../camera/assembleOrbitCamera'; import { bodyRelativePose } from '../camera/bodyRelativePose'; +import { poseFromBodyArm } from '../../../utils/camera/poseFromBodyArm'; import { pivotRadiusMpc } from '../camera/pivotRadiusMpc'; import { ZERO_FOCUS } from '../subsystems/structureFocusSubsystem'; import { deriveSlabs } from './slabs'; @@ -113,43 +39,26 @@ import { partitionStarsByResolution, STAR_RESOLVE_PX } from './partitionStarsByR /** * Derive the per-frame context from an already-produced pose and projection. * - * `pose` is the pose that `runFrame` produced earlier in the same frame (via - * `runCameraDrivers`); `projection` is the live engine Resource that carries - * fovYRad, aspect, near, and far; `poseBasis` and `upBasis` are this frame's - * two orientation bases, forwarded straight into `assembleOrbitCamera`: * `poseBasis` (the committed `ORIENTATION_FRAMES[orientation]`, which does not - * move during a roll) decodes the eye position, `upBasis` (the live, possibly - * mid-slerp `resolveFrameBasis` result) decodes screen-up. Splitting them here - * is what makes an orientation-frame switch roll the horizon instead of - * sweeping the whole view — see `runFrame`'s basis-resolution block for why - * each reader gets the value it gets. - * - * The bootstrap gate still reads `state.cam` for non-null (it is non-null once - * `wireInput` runs); `state.cam` is the drag register, NOT the source of the - * rendered pose. The produced `ctx.cam` is a fresh assembled camera that does - * NOT alias `state.cam`. - * - * Side-effect-free: the clock is advanced by `runFrame`'s produce step, not - * here. Safe to call speculatively; a second call in the same frame is a no-op - * on clock state. - * - * `nowMs` is runFrame's single wall-clock sample, stamped onto the ready - * context so every animated consumer reads the frame clock instead of - * sampling `performance.now()` itself — the seam a frame-by-frame recorder - * needs to step time deterministically. - * - * `simDays` is the frame's sim-clock instant (Julian days), derived by - * `runFrame` from the time-intent slice before the camera produce step and - * stamped here so `sceneBodyStates` evaluates the body snapshot at one agreed - * epoch every reader shares. It is a separate axis from `nowMs`: `nowMs` is - * wall-clock (drives fades and ramps), `simDays` is scene time (drives where - * the planets are), and the two decouple whenever the clock is paused or - * scrubbed. + * move during a roll) decodes the eye position; `upBasis` (the live, possibly + * mid-slerp `resolveFrameBasis` result) decodes screen-up. The split is what + * makes an orientation-frame switch roll the horizon instead of sweeping the + * whole view. + * + * `nowMs` is wall-clock ms (fades, ramps); `simDays` is scene time in Julian + * days (where the planets are). The two decouple whenever the clock is paused + * or scrubbed, and `nowMs` being threaded rather than sampled per consumer is + * the seam a frame-by-frame recorder needs to step time deterministically. + * + * `arm` is the SAME framed pose `pose` was resolved from (`resolveWorldArm`, + * called once by the caller) and serves only the pose-provider seam below + * (spec §5.2). */ export function deriveFrameContext( state: EngineState, canvas: HTMLCanvasElement, pose: CameraPose, + arm: FramedCameraPose, projection: CameraProjection, poseBasis: Mat3, upBasis: Mat3, @@ -157,11 +66,6 @@ export function deriveFrameContext( nowMs: number, simDays: number, ): FrameContext { - // The bootstrap gate. Every site that asks 'is the engine bootstrapped?' — - // per-frame, slot-commit, public-handle — funnels through the one - // `isEngineReady` predicate. When a new bootstrap-only handle lands, only - // `isEngineReady` and `ReadyFrameContext`'s field list need updating; this - // gate stays the same. if (!isEngineReady(state)) { return { isReady: false }; } @@ -169,31 +73,16 @@ export function deriveFrameContext( const renderTargets = state.gpu.renderTargets; const texturedDisks = state.subsystems.texturedDisks; - // Assemble the full OrbitCamera from the already-produced store pose, the - // engine's projection Resource, and this frame's two orientation bases. The - // bases are written onto the camera before its position is derived: position - // decodes through `poseBasis` (committed, roll-invariant), every derived - // quantity below that reads screen-up (vp, slabs) decodes through `upBasis` - // (live, rolls). The returned camera is a fresh object — it does NOT alias - // `state.cam` (the drag register) or any frozen store array. const cam = assembleOrbitCamera(pose, projection, poseBasis, upBasis); - // Snapshot-derive everything the caller would otherwise compute locally. - // `runFrame` and `renderFrame` both read these off `ctx`, so the two - // derivations can't drift. const canvasSize = { width: canvas.width, height: canvas.height }; const vp = computeViewProj(cam); - // This frame's ONE R_body(t) sample (spec §4). Called directly rather than - // via `sceneBodyStates(state, ctx)` because `ctx` does not exist yet at this - // point in its own derivation; `deriveBodyStates` memoizes one deep on - // `simDays`, so every later `sceneBodyStates(state, ctx)` call this frame - // returns this SAME Map by reference — no second cache, no drift. + // This frame's ONE R_body(t) sample (spec §4). `deriveBodyStates` memoizes one + // deep on `simDays`, so every later `sceneBodyStates(state, ctx)` call this + // frame returns this SAME Map by reference — no second cache, no drift. const bodyStates = deriveBodyStates(simDays); - // Computed here (ahead of `visibleSlabBodies`, which needs it for the - // frustum cull) rather than inline in the pose-provider seam below, so - // both consumers share one derivation. const camForward = normalize3([ cam.target[0] - cam.position[0], cam.target[1] - cam.position[1], @@ -216,33 +105,33 @@ export function deriveFrameContext( fovYRad: cam.fovYRad, }); - // The pose provider seam (spec §5): a closure over provider A - // (`bodyRelativePose`), built HERE rather than inside `deriveSlabs` so a - // future provider B (spec 2) swaps in behind the same `BodyPoseProvider` - // type with `deriveSlabs` untouched. `camBasisWorld` reruns the SAME roll - // NEAR0's own vp derivation uses (`imagePlaneBasis` is the shared seam both - // call, not a copy) so a body row's screen orientation matches NEAR0's — - // reading `cam.roll` here rather than hard-coding 0 is what keeps that true - // once something sets a non-zero roll (spec 2 §5.2). Forwarded onto - // `ReadyFrameContext.bodyPose` below (the SAME closure, not a second one) so - // a body-slab layer's own pose read (`prepareBodySurfaceFrame`) can never - // drift from the one `slabs` was built from — see that field's doc. + // `camBasisWorld` reruns the SAME roll NEAR0's own vp derivation uses + // (`imagePlaneBasis` is the shared seam both call, not a copy) so a body row's + // screen orientation matches NEAR0's — reading `cam.roll` rather than + // hard-coding 0 is what keeps that true once something sets a non-zero roll. + // The closure below is forwarded onto `ReadyFrameContext.bodyPose` (the SAME + // closure, not a second one) so a body-slab layer's own pose read can never + // drift from the one `slabs` was built from. const { right: camRight, up: camUp } = imagePlaneBasis( camForward, cam.roll ?? 0, frameUp(cam.upBasis), ); const camBasisWorld = mat3FromColumns(camRight, camUp, camForward); + // Provider B serves ONLY the engaged body, straight from its own stored + // pose — no Mpc round trip. Every other body, and the whole absolute arm, + // stay on provider A (spec §5.2, ruled S1: "B keeps A"). const bodyPose: BodyPoseProvider = (bodyId) => { + if (arm.frame !== 'absolute' && arm.frame.body === bodyId) { + return poseFromBodyArm(arm.pose); + } const bodyState = bodyStates.get(bodyId); if (bodyState === undefined) return null; return bodyRelativePose({ camPosMpc: cam.position, camBasisWorld, bodyState }); }; // NEAR0's distanceRangeM (spec §7.1): the star spheres actually drawn this - // frame, not `foregroundFrustum`'s bracket. `positionedVisibleStars` needs - // `ctx.simDays` only, so its join is inlined by hand here for the same - // reason `bodyStates` is called directly above — `ctx` doesn't exist yet. + // frame, not `foregroundFrustum`'s bracket. const positionedStars = visibleStars(state).map((star) => ({ ...star, positionMpc: bodyStates.get(star.id)!.positionMpc, @@ -256,12 +145,8 @@ export function deriveFrameContext( }); const starRangeM = starSphereRangeM({ spheres, camPosMpc: cam.position }); - // deriveSlabs is called here — alongside vp, not from a separate site — - // so there is exactly one per-frame derivation of the slab table (see the - // module header's point 2 on why derived scalars must not be recomputed - // in two places). The focused pivot's radius (or null) lets the near-field - // row key its near plane off ALTITUDE rather than raw distance — see - // `slabs.ts: deriveSlabs`. + // The focused pivot's radius (or null) lets the near-field row key its near + // plane off ALTITUDE rather than raw distance — see `slabs.ts: deriveSlabs`. const slabs = deriveSlabs({ cam, cosmoVp: vp, @@ -274,23 +159,10 @@ export function deriveFrameContext( const drawCamPos: Readonly = [cam.position[0]!, cam.position[1]!, cam.position[2]!]; const drawPxPerRad = canvasSize.height / (2 * Math.tan(cam.fovYRad / 2)); - // `focusBlend` is seeded to 0 (the at-rest, no-recession value) and then - // overwritten by `runFrame` with this frame's real blend the moment the ready - // gate passes. It can't be derived here: computing it ticks the structureFocus - // fade controller, a side effect that must fire exactly once per frame — - // `deriveFrameContext` is side-effect-free (it may be called speculatively, - // and double-ticking would double-advance the ramp). So the value is a - // placeholder until `runFrame` fills it in, before any consumer (label - // director, marker upload, render settings) reads it. - // - // `focus` is seeded to ZERO_FOCUS (blend=0, the at-rest sentinel) and overwritten - // by `runFrame` with this frame's real FocusUniformsValue the moment the ready - // gate passes. Same reason as `focusBlend`: `produceFocusUniforms` ticks the - // focus fade controller — a once-per-frame side effect — so it can't run here. - // ZERO_FOCUS is a module-private constant in structureFocusSubsystem; importing - // it avoids duplicating the literal and keeps a single source of truth for the - // at-rest defaults (blend=0, apparentRadiusMpc=1 so smoothstep edges are - // never degenerate, everything else a don't-care). + // `focusBlend` and `focus` are at-rest placeholders that `runFrame` overwrites + // the moment the ready gate passes, before any consumer reads them: deriving + // them here would tick the structureFocus fade controller, a once-per-frame + // side effect this (speculatively callable) function must not have. return { isReady: true, cam, @@ -311,11 +183,9 @@ export function deriveFrameContext( focus: ZERO_FOCUS, galaxyPointRenderer, renderTargets, - // A fresh empty Set per frame — the executor populates it as it opens the - // first pass against each target, and a later pass sampling an earlier - // target's texture reads it to know whether that target actually rendered - // this frame. `deriveFrameContext` returns a fresh object each frame, so a - // new Set here can never leak state across frames. + // The executor populates this as it opens the first pass against each target; + // a later pass sampling an earlier target's texture reads it to know whether + // that target actually rendered this frame. renderedTargets: new Set(), texturedDisks, }; diff --git a/src/services/engine/frame/passes/near0SelectionRingPass.ts b/src/services/engine/frame/passes/near0SelectionRingPass.ts index ab2d3f9261..dcc7a98987 100644 --- a/src/services/engine/frame/passes/near0SelectionRingPass.ts +++ b/src/services/engine/frame/passes/near0SelectionRingPass.ts @@ -72,10 +72,9 @@ * selection time, but the sim clock keeps moving planets and moons along their * orbits every frame. Centring on the stale snapshot leaves the ring where the * body WAS when picked while the sphere drifts away. So a body row re-resolves - * its position at THIS frame's `ctx.simDays` through `liveBodyPosition` — the - * single live-body resolution site (it reads the same one-deep-memoized - * `deriveBodyStates(simDays)` map the body draw pass already built this frame, so - * the re-read is free and the ring shares the bodies' exact epoch). It returns + * its position out of this frame's `sceneBodyStates` snapshot through + * `liveBodyPosition` — the single live-body resolution site, and the same map + * the body draw pass reads, so the ring shares the bodies' exact epoch. It returns * null for a non-body row AND for a body-typed row absent from the orbital * snapshot; the `?? worldPos` fallback covers both, and is right rather than * defensive because a row's baked `worldPos` and its snapshot position are the @@ -88,6 +87,7 @@ import type { Vec3 } from '../../../../@types/math/Vec3'; import { NEAR0 } from '../slabs'; import { selectionHalo } from '../../helpers/selectionHaloTable'; import { liveBodyPosition } from '../../camera/liveBodyPosition'; +import { sceneBodyStates } from '../sceneBodyStates'; import { near0RingRadiusPx } from '../../helpers/near0RingRadiusPx'; import { rebaseViewProj } from '../../../../utils/camera/rebaseViewProj'; import { narrowMat4 } from '../../../../utils/math/narrowMat4'; @@ -121,7 +121,7 @@ export const near0SelectionRingPass: ContentPass = { // tracks the animated planet/moon instead of its stale pick-time snapshot // (see the module header). A row the snapshot cannot place falls back to the // baked `worldPos`, which is the same value for anything static. - const centreWorld = liveBodyPosition(row, ctx.simDays) ?? worldPos; + const centreWorld = liveBodyPosition(row, sceneBodyStates(state, ctx)) ?? worldPos; // Re-express the ring centre as a small camera-relative vector in f64 // BEFORE the renderer narrows to f32 — see the module header's rebase seam. diff --git a/src/services/engine/frame/passes/starCatalogPass.ts b/src/services/engine/frame/passes/starCatalogPass.ts index bc3ae27329..4c938f345d 100644 --- a/src/services/engine/frame/passes/starCatalogPass.ts +++ b/src/services/engine/frame/passes/starCatalogPass.ts @@ -1009,8 +1009,8 @@ export const starCatalogPass: ContentPass = { // Pick aspect — stamps every visible LEAF star's packed identity into the // NEAR0 r32uint pick pass. The pick pass runs on a FRESH `ctx` minted by - // `pickFrameContext` (→ `deriveFrameContext` from `lastPose.current`, the pose - // the last frame actually rendered), so `prepareStarCut`'s per-`ctx` memo + // `pickFrameContext` (→ `deriveFrameContext` from `outputs.displayed`, + // the pose the last frame actually rendered), so `prepareStarCut`'s per-`ctx` memo // (`preparedByCtx`) MISSES and recomputes the leaf cut here — a second octree // walk, but against that same last-rendered camera, so the pick lands exactly // where the sprite drew, reading each node's CURRENT LOD-fade opacity diff --git a/src/services/engine/frame/projectFramePose.ts b/src/services/engine/frame/projectFramePose.ts new file mode 100644 index 0000000000..0532d64516 --- /dev/null +++ b/src/services/engine/frame/projectFramePose.ts @@ -0,0 +1,174 @@ +/** + * projectFramePose — the produced pose projected for the draw, and THE FOLD. + * ORDER IS THE CONTRACT (spec §7 steps 5-6, FW-G): pin → `noteBody` → tilt + * projection → world arm → regime flip → crossing commit. The fold sits below + * every pose writer because a fold above one is discarded by whatever writes + * after it; the tilt projection sits between the pin and the fold so the + * engage edge converts the image already on screen (ruling 13). The AUTHORED + * register comes back beside the DISPLAYED pose — the projection reaches the + * register on no path (R12b-1), which keeps the produce→pin→project loop dead. + */ + +import type { UnknownAction } from '@reduxjs/toolkit'; + +import type { BodyId } from '../../../@types/data/body/BodyId'; +import type { BodyState } from '../../../@types/scene/BodyState'; +import type { CameraPose } from '../../../@types/camera/CameraPose'; +import type { CameraState } from '../../../@types/camera/CameraState'; +import type { CameraTuning } from '../../../@types/camera/CameraTuning'; +import type { FollowMemory } from '../../../@types/engine/camera/FollowMemory'; +import type { FramedCameraPose } from '../../../@types/camera/FramedCameraPose'; +import type { Mat3 } from '../../../@types/math/Mat3'; +import type { SelectionRow } from '../../../@types/engine/SelectionRow'; +import type { SurfaceMemory } from '../../../@types/camera/SurfaceMemory'; +import type { Vec3 } from '../../../@types/math/Vec3'; + +import { noteBody } from '../../camera/surfaceStep'; +import { applyFocusedBodyPivot } from '../camera/applyFocusedBodyPivot'; +import { approachTiltedPose } from '../camera/approachTiltedPose'; +import { resolveWorldArm, toBodyArm } from '../camera/poseFrameConversion'; +import { regimeArmFor } from '../camera/regimeArmFor'; +import { absoluteArm } from '../../../utils/camera/absoluteArm'; +import { eyeMpcOf } from '../../../utils/camera/eyeMpcOf'; +import { orbitAnglesLookingAlong } from '../../../utils/camera/orbitAnglesLookingAlong'; +import { normalize3 } from '../../../utils/math/normalize3'; +import { commitCameraPose } from '../../../state/camera/cameraSlice'; + +/** The pin's strafe while no follow memory exists. */ +const NO_PAN: Vec3 = [0, 0, 0]; + +export function projectFramePose(args: { + readonly render: FramedCameraPose; + readonly authoredOverride: FramedCameraPose | null; + readonly pivotsOnFocusedBody: boolean; + readonly focus: SelectionRow | null; + readonly follow: FollowMemory | null; + readonly surface: SurfaceMemory; + /** The frame's effective camera intent: `base.frame` IS the regime, `dragging` skips the fold. */ + readonly intent: CameraState; + readonly bodies: ReadonlyMap; + readonly poseBasis: Mat3; + readonly upBasis: Mat3; + readonly tuning: CameraTuning; +}): { + readonly register: FramedCameraPose; + readonly displayed: FramedCameraPose; + readonly surface: SurfaceMemory; + /** The world arm the frame draws — pre-flip on a crossing frame. */ + readonly world: CameraPose; + readonly actions: readonly UnknownAction[]; + readonly requestRender: boolean; +} { + const { + render, + authoredOverride, + pivotsOnFocusedBody, + focus, + follow, + surface, + intent, + bodies, + poseBasis, + upBasis, + tuning, + } = args; + + // The pin SETS the target (never adds), so baking the displayed pose into + // `base` on the next edge cannot double-apply the body translation. A pan + // strafe rides the follow memory's `panOffset` (world frame) so the shifted + // pivot still translate-follows the body. + let displayed = applyFocusedBodyPivot( + render, + pivotsOnFocusedBody, + focus, + bodies, + follow?.panOffset ?? NO_PAN, + ); + // Post-pin, PRE-projection (R12b-1). + let register = authoredOverride ?? displayed; + // The body the tilt memory belongs to: the ENGAGED one while a body arm holds + // (a differing focus has already released it), else the FOCUSED one. + const regime = intent.base.frame; + const noted = noteBody( + surface, + regime !== 'absolute' ? regime.body : focus?.type === 'body' ? focus.id : null, + ); + displayed = approachTiltedPose( + displayed, + pivotsOnFocusedBody, + focus, + bodies, + noted.rememberedTiltRad, + poseBasis, + upBasis, + tuning, + ); + + // The register stays FRAMED; every world-Mpc reader takes this value. + const world = resolveWorldArm(displayed, bodies, poseBasis, upBasis); + + const actions: UnknownAction[] = []; + let requestRender = false; + // No flip during a gesture (ruled, Q6): skipped WHOLE — not clamped, not + // latched — and re-evaluated at gesture end. + if (!intent.dragging) { + // `base.frame` IS the regime (spec §4), not the arm this frame's winner + // authored: `tween` and `clip` are not arm-gated, so the produced pose + // would re-engage every frame of an animation inside the band. + const eyeMpc = eyeMpcOf(world, poseBasis); + // The focused body constrains the regime (round 10). + const arm = regimeArmFor( + regime, + eyeMpc, + bodies, + focus?.type === 'body' ? focus.id : null, + tuning, + ); + if (arm === 'absolute') { + if (displayed.frame !== 'absolute') { + // Disengage commits target-at-centre, eye preserved: the pivot pin + // re-reads an absolute `target` as the body's centre one frame later + // and rebuilds the eye from `target + dir·distance`, so committing + // `world`'s on-ray surface target verbatim teleported the eye one body + // radius inward (pop-2). Zoom-driven recessions cross at tilt 0, so + // this is view-exact; other crossings re-aim by at most the remaining + // tilt on the flip frame. + const centreMpc = bodies.get(displayed.frame.body)!.positionMpc; + const toCentre: Vec3 = [ + centreMpc[0] - eyeMpc[0], + centreMpc[1] - eyeMpc[1], + centreMpc[2] - eyeMpc[2], + ]; + const { yaw, pitch } = orbitAnglesLookingAlong(normalize3(toCentre), poseBasis); + displayed = absoluteArm({ + target: [centreMpc[0], centreMpc[1], centreMpc[2]], + yaw, + pitch, + distance: Math.hypot(toCentre[0], toCentre[1], toCentre[2]), + roll: world.roll, + }); + // Centre-looking, so authored and displayed coincide. + register = displayed; + } + } else if (displayed.frame === 'absolute') { + // Total: `regimeArmFor` only names a body it resolved out of THIS map. + const bodyState = bodies.get(arm.body)!; + displayed = { + frame: arm, + pose: toBodyArm(world, poseBasis, upBasis, arm.body, bodyState), + }; + // Engage converts the DISPLAYED pose (ruling 13); on the body arm the + // tilt is geometry, not a projection, so the register holds it too. + register = displayed; + } + // Once per crossing. The wake is the fold's own: `shouldKeepTicking` reads + // the pre-fold snapshot, so a flip that quiets the last live term would + // otherwise park the loop. + if ((arm === 'absolute' ? null : arm.body) !== (regime === 'absolute' ? null : regime.body)) { + actions.push(commitCameraPose(displayed)); + requestRender = true; + } + } + + return { register, displayed, surface: noted, world, actions, requestRender }; +} diff --git a/src/services/engine/frame/runFrame.ts b/src/services/engine/frame/runFrame.ts index 1cd84a485d..562b161c28 100644 --- a/src/services/engine/frame/runFrame.ts +++ b/src/services/engine/frame/runFrame.ts @@ -1,71 +1,23 @@ /** - * runFrame — the per-frame body of the render loop, kept in its own module so - * `engine.ts` stays focused on bootstrap + the public handle. - * - * Engine.ts is responsible for *constructing* dependencies; runFrame.ts is - * responsible for *consuming* them. The two concerns sit behind a single seam — - * `RunFrameDeps` — which makes the inputs the body relies on legible at a glance. - * - * ### What counts as the 'frame body' - * - * Everything from the camera-driver resolve at the top to the `renderFrame()` - * GPU dispatch and the `drawPickDebugOverlay` call that follows. The - * still-animating predicate ('keep ticking ONLY if motion or async work is in - * flight') lives here too — a single condition that fires - * `scheduler.requestRender()` if any busy-flag is set. - * - * ### Why deps are passed explicitly instead of lifted to EngineState - * - * The IIFE-local `device` and `context` GPU handles are read *only* by the - * frame body; promoting them to `state.gpu.*` would widen `EngineState`'s - * contract for one consumer and force every other reader to null-check - * fields it never touches. They flow through `RunFrameDeps` instead. Every - * per-frame renderer (`milkyWayCloudRenderer`, `filamentRenderer`, - * `texturedDiskRenderer`, …) DOES live on `state.gpu.*` already — every - * `ContentPass.draw` reads its renderer straight from there (see - * `passes/index.ts`), so `RunFrameDeps` carries no renderer fields. - * - * ### Camera produce → commit-on-edge ordering - * - * The frame body runs five camera steps, in this exact order: - * - * 0. DRAIN INPUT: apply this frame's aggregated gestures to the drag register - * and dispatch their store edges (`drainInput`) — the only input-apply site. - * 1. PRODUCE the pose from the driver table (single-writer, one pose per frame). - * 2. TWEEN COMPLETION: if the tween driver won and its elapsed >= durationMs, - * dispatch `cancelCameraTween()`. The tween deactivates on the NEXT frame; - * this frame's pose is already == to exactly (saturation). No activeId change - * here — the commit fires on the next frame's deactivation edge. - * 3. COMMIT-ON-EDGE: if the winning driver changed, and the PREVIOUS driver - * has `commitsOnEdge: true`, fold the last produced pose into `base` - * exactly once. Drivers that declare this flag (tween, autoRotate, clip) - * must bake their saturated pose into base on deactivation. `orbitDrag` - * and `resting` are excluded (orbitDrag commits via `onGestureEnd`; - * resting's pose IS `base`). - * 3b. PIVOT-PIN: while a scene body is focused, overwrite the winning driver's - * target with the live body position (for drivers that declare - * `pivotsOnFocusedBody`). The body owns the pivot; the driver owns the orbit - * terms — so a drag / auto-rotate orbits AROUND the moving body. - * 4. UPDATE Resources: `prevActiveId.current = activeId`, - * `lastPose.current = pose`. - * - * Then `deriveFrameContext` receives the already-produced `pose` and the live - * `projection` Resource, assembles a full `OrbitCamera`, and computes vp etc. - * The clock is advanced exactly once per frame by step 1's `runCameraDrivers`. + * runFrame — the per-frame body of the render loop; `engine.ts` constructs the + * deps (`RunFrameDeps`), this module consumes them. The clip tick is the FIRST + * statement by contract. Then: the sim instant and body snapshot → + * `stepCameraRuntime` (the camera as one pure step over the frame's ONE store + * snapshot) → THE one `state.cameraRuntime` assignment → the step's actions, + * in order → the frame context, the planners, the GPU dispatch and the + * keep-ticking vote. Non-camera dispatches (scale bar, body distance) stay here. */ import type { EngineState } from '../../../@types/engine/state/EngineState'; import type { RunFrameDeps } from '../../../@types/engine/frame/RunFrameDeps'; import type { SurfaceCutTile } from '../../../@types/scene/SurfaceCutTile'; +import type { BodyId } from '../../../@types/data/body/BodyId'; +import type { BodyState } from '../../../@types/scene/BodyState'; -import { drainInput } from './drainInput'; -import { runCameraDrivers } from '../camera/cameraDrivers'; -import { activeDriverId } from '../camera/activeDriverId'; -import { applyFocusedBodyPivot } from '../camera/applyFocusedBodyPivot'; import { pivotRadiusMpc } from '../camera/pivotRadiusMpc'; -import { bodyMovesThisFrame } from '../../../utils/scene/bodyMovesThisFrame'; -import { tweenElapsed, accumulateFollowPan, frameTweenElapsed } from '../camera/cameraClock'; -import { resolveFrameBasis } from '../camera/resolveFrameBasis'; +import { orientDeltasWatched, recordOrientDeltas } from '../camera/orientDeltas'; +import { stepCameraRuntime } from '../camera/stepCameraRuntime'; +import { cameraDofAnglesOf } from '../../../utils/camera/cameraDofAnglesOf'; import { ORIENTATION_FRAMES } from '../../../data/orientation/orientationFrames'; import { resizeCanvasToDisplay } from '../../gpu/device'; import { shouldKeepTicking } from '../helpers/shouldKeepTicking'; @@ -83,11 +35,6 @@ import { deriveSourceMasks } from './deriveSourceMasks'; import { renderFrame } from './renderFrame'; import { drawPickDebugOverlay } from './drawPickDebugOverlay'; import { reevaluateDemand } from '../wiring/reevaluateDemand'; -import { - commitCameraPose, - cancelCameraTween, - clearFrameTween, -} from '../../../state/camera/cameraSlice'; import { computeScaleInfo } from '../helpers/scaleBar'; import { engineScaleChanged, engineBodyDistanceReported } from '../../../state/engine/engineSlice'; import { deriveSimDays } from '../../../utils/time/deriveSimDays'; @@ -96,359 +43,113 @@ import { throttleByTime } from '../../../utils/throttle/throttleByTime'; import { distanceMpc } from '../../../utils/math/distanceMpc'; /** - * Desired scale-bar width in CSS pixels. The engine computes this per-frame - * and dispatches the result to the store, so every consumer (ScaleBar, tour - * sagas) reads a consistent value without a React-side computation callback. - * Capped by the ScaleBar panel's content box: that panel now hugs the TimeBar - * pill's mono readout (~145 px content width; see ScaleBar.module.css), so the - * legend must stay comfortably under it to avoid clamping. 120 px reads clearly - * and leaves margin against the ~145 px box; if that panel width changes, this - * ceiling follows. + * Scale-bar width, CSS px. Must stay under the ScaleBar panel's ~145 px content + * box (it hugs the TimeBar pill; see ScaleBar.module.css) or the legend clamps. */ const SCALE_TARGET_PX = 120; /** - * Rate-limit the `engineBodyDistanceReported` publication to ~4 Hz. The store - * field it writes feeds the InfoCard's live distance row, which does not need - * a 60 Hz firehose — a per-frame dispatch would churn React for a value humans - * read at reading speed. The gate is created ONCE at module scope (not per - * frame): a fresh `throttleByTime(250)` every call would reset its closure - * state and defeat the throttle. It pairs with the reducer's dedup-on-write, - * so an unchanged report inside an open window still costs nothing downstream. + * ~4 Hz gate for the InfoCard's live distance row. Module scope on purpose: a + * per-frame `throttleByTime(250)` would reset its closure and defeat itself. */ const publishBodyDistanceGate = throttleByTime(250); /** - * Idle-tick cadence for a LIVE sim clock, in milliseconds. Live time advances - * one sim day per real day, so the terminator sweeps `0.00417° * T` of ground - * per tick of length T — on screen that maps to pixels via `2 * h * tan(fovY / - * 2)` (h = camera altitude) over the canvas's pixel height. - * - * The altitude term is what makes the cadence tight: at the 127 km standoff - * over streamed surface tiles the viewport spans only ~147 km vertically, so a - * 3 s tick is a visible ~8 px jump on a ~900 px-tall canvas. 500 ms holds that - * drift under 1.5 px at every reachable altitude — ground drift scales - * linearly with tick length, screen-space drift inversely with altitude. - * - * Kept OUT of `shouldKeepTicking` regardless: pinning the loop at 60 fps for a - * rotation this slow would burn the GPU for no visible gain, so instead we ask - * the scheduler for ONE frame per tick — a heartbeat that keeps the terminator - * honest while the loop sleeps in between. The React TimeBar readout runs its - * own timer, so this heartbeat serves only the 3D scene. - * - * A `setInterval` would be the wrong tool: it fires unconditionally, fighting - * render-on-demand and double-scheduling whenever a real wake (drag, fade) is - * already driving the loop. The scheduler's `requestIdleFrame` instead arms a - * single one-shot that self-cancels once fired and is ignored while a rAF frame - * is already queued — so it only ever supplies the frames the busy loop didn't. + * Idle-tick cadence for a LIVE sim clock, ms. The terminator sweeps 0.00417°·T + * of ground per tick; at the 127 km tile standoff the viewport spans ~147 km, so + * a 3 s tick is a visible ~8 px jump on a 900 px canvas — 500 ms holds it under + * 1.5 px at every altitude. A one-shot `requestIdleFrame`, not `setInterval`: + * an interval fires unconditionally and double-schedules against real wakes. */ const LIVE_IDLE_TICK_MS = 500; -/** - * Run one frame of the render loop. Called every rAF tick by the scheduler in - * `state.subsystems.scheduler` (see engine.ts's forward-declared `frame` - * binding for the wiring). - * - * `nowMs` is `performance.now()`-shaped; engine.ts passes that exact value at - * the call site. We accept it as a parameter rather than reading the global so - * tests can drive deterministic timing. - */ +/** `nowMs` is `performance.now()`-shaped, passed in so tests drive the timing. */ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): void { - // ── Clip-player tick (MUST run first) ───────────────────────────────────── - // - // Task 12 contract: `clipPlayer.tick(nowMs)` is the first statement of - // `runFrame` — before `deriveSourceMasks` / `reevaluateDemand` and before - // the camera produce step. Scene cues (fade / show / hide / focus) fired - // here are therefore committed before this frame derives masks, demand, or - // the camera pose from store state. A cue that dispatches a store action - // (e.g. `settings.milkyWay.enabled → false`) is seen by every downstream - // reader in the same frame, rather than lagging one frame behind. - // - // `clipPlayer` is non-null from t=0 (no GPU dep), so no null-check needed. - state.subsystems.clipPlayer.tick(nowMs); + // First statement, by contract: scene cues fired here (fade / show / hide / + // focus) are in the store before this frame derives masks, demand or the pose, + // and a frameTween a cue dispatches is seen by this frame's basis. + const { clipEpoch } = state.subsystems.clipPlayer.tick(state.cameraRuntime.epochs.clip, nowMs); - // ── Demand re-evaluation ────────────────────────────────────────────────── - // - // Re-derive what should be loading from current state, every frame. The - // single seam that turns any state change into the right loads: a handle - // setter flips its demand-gating state and calls requestRender, which wakes - // the loop, which runs this. No setter has to remember to trigger loading — - // requestRender is the universal 'something changed' signal it already must - // send. Idle-guarded, so an already loading/ready/error asset is a cheap - // no-op on steady-state frames. - // - // Derive the galaxy catalog draw/pick masks from settings + live fade opacity - // at the top of every frame, before any reader (render or pick pass) touches - // them — so the masks are always a fresh projection of the single source of - // truth, never a hand-maintained mirror. `deriveSourceMasks` is pure: it - // returns the masks, which live as a per-frame-derived local here (no longer - // written into state) and are threaded into the render + pick passes below. - // Demand itself reads settings directly, not the masks. + // The masks are a per-frame projection of settings + fade opacity, never a + // hand-maintained mirror; demand itself reads settings directly. const masks = deriveSourceMasks(state); reevaluateDemand(state); - // ── Resize → projection Resource, then reconcile the offscreen table ───── - // - // `resizeCanvasToDisplay` returns `true` only when dimensions changed, so - // `cameraRuntime.projection.aspect` is patched only in that branch. Aspect - // lives on `projection` (the engine Resource), NOT on `state.cam`: - // `state.cam` is the drag register, and `assembleOrbitCamera` merges the - // projection Resource's aspect onto every produced pose instead. - // - // `reconcile` runs UNCONDITIONALLY — one seam answering two inputs, the - // canvas size and every state-driven `scale` (the `mw-aggregate` divisor is - // a live slider). It reallocates only the rows whose pixel size actually - // moved, so a steady-state frame allocates nothing. Both can run - // pre-bootstrap; `renderTargets` is null until initGpu, hence the `?.`. - if (resizeCanvasToDisplay(deps.canvas)) { - state.cameraRuntime.projection.aspect = deps.canvas.width / deps.canvas.height; - } - // Unlike aspect (canvas-resize-gated), the FOV slider can change on ANY frame - // with no resize event, so this write runs unconditionally — a settings-slider - // twin of the aspect assignment above, both landing on the same projection - // Resource `assembleOrbitCamera` merges into the live camera every frame. - state.cameraRuntime.projection.fovYRad = state.settings.camera.fovDeg * (Math.PI / 180); + // `reconcile` runs unconditionally (canvas size AND every state-driven scale + // feed it) and reallocates only rows whose pixel size moved; `renderTargets` + // is null until initGpu. The resize is the backing store's side effect, not + // the camera's; the step is handed the aspect it produced. + resizeCanvasToDisplay(deps.canvas); state.gpu.renderTargets?.reconcile(state, { width: deps.canvas.width, height: deps.canvas.height, }); - // ── Milky-Way star count → cloud regeneration ─────────────────────────── - // - // Unconditional like `renderTargets.reconcile` above; the mismatch check - // and regeneration rationale live on `MilkyWayCloud.reconcile` itself. state.gpu.milkyWayCloud?.reconcile(state.settings.milkyWay.starCount); - // ── (0) DRAIN INPUT: apply this frame's gestures, once ──────────────────── - // - // Above the `getState()` below so the gesture boundaries it dispatches - // (beginDrag / endDrag / the at-rest wheel commit) are already in the - // snapshot the driver table resolves against. - drainInput(state, deps, nowMs); - - // ── Camera produce → commit-on-edge ────────────────────────────────────── - // - // Single camera-write site per frame. The produce step calls `runCameraDrivers` - // (which calls `pickWinner` and the winner's `pose`), then the tween-completion - // and commit-on-edge steps gate on the active driver identity. The four steps - // run before `deriveFrameContext` so a camera-only-ready frame still makes - // motion progress before we early-return for missing GPU handles. - const rootState = deps.cb.store.getState(); - - // ── Sim-clock instant for this frame (derived ONCE, before produce) ─────── - // - // `deriveSimDays` resolves the sim clock from the time-intent slice plus this - // frame's wall-clock sample — pure, no accumulator. We compute it here, above - // the camera produce step, for two reasons: - // 1. A body-following camera driver (a later feature) runs INSIDE the - // produce step and must aim at where the body is THIS frame, so the body - // snapshot has to exist before `runCameraDrivers` is called. - // 2. `deriveFrameContext` stamps `simDays` onto `ctx` (below) so every - // per-frame body reader shares one epoch via `sceneBodyStates(state, ctx)`. - // - // `deriveBodyStates(simDays)` primes the one-deep memo at this instant so the - // pre-produce driver and every post-ready pass reader hit the SAME cached map - // by reference — one Kepler solve per frame, not one per reader. The result is - // intentionally discarded here; the memo IS the shared snapshot. - const simDays = deriveSimDays(selectTimeState(rootState), nowMs); - deriveBodyStates(simDays); - - // Record the frame's instant as single-writer state, the exact analogue of - // `lastPose.current` for the pose (updated in step 4 below). The pick path - // reads THIS — not the derive memo's cached key — so a between-frames - // `deriveBodyStates(CONST_J2000)` (extractSelectionRow, construction-time - // consts) cannot repoint the epoch the pick sees. runFrame is the only writer. - state.cameraRuntime.lastRenderedSimDays.current = simDays; - - // ── (1) PRODUCE the pose from the driver table ──────────────────────────── - // - // One call to `runCameraDrivers` per frame. `pickWinner` is called inside - // `runCameraDrivers` and the clock is advanced once here — `deriveFrameContext` - // receives the already-produced pose so it does NOT re-call the drivers or - // advance the clock again. - // - // This runs even if `state.cam` is null (pre-bootstrap): in that case - // `orbitDrag` calls `poseOf(null)` which would crash — but `orbitDrag` is - // only active when `s.camera.dragging` is true, and dragging cannot be true - // before the controls are attached (which happens in wireInput, after cam is - // non-null). So the resting or tween/autoRotate drivers win pre-bootstrap, - // both of which ignore `cam`. The guard below for the scale-bar snapshot - // still keeps the post-cam path distinct. - const pose = runCameraDrivers( - deps.drivers, + // The frame's ONE store snapshot. The camera step runs before + // `deriveFrameContext` so a camera-only-ready frame still makes motion + // progress before the missing-GPU early return. + const steps = state.subsystems.inputAggregator.drain(); + const stored = deps.cb.store.getState(); + + // The sim instant is derived BEFORE the step: the follow driver aims at where + // the body is this frame, and `deriveBodyStates` is memoised one-deep, so + // this call primes the map every later reader gets by reference. + const simDays = deriveSimDays(selectTimeState(stored), nowMs); + const bodyStates = deriveBodyStates(simDays) as ReadonlyMap; + + const { + next, + actions, + requestRender, + world: worldPose, rootState, - state.cam!, - state.cameraRuntime.clock, - nowMs, - ); - const activeId = activeDriverId(deps.drivers, rootState); - - // ── Orientation basis: two readers, two different values ───────────────── - // - // `poseBasis` is the COMMITTED frame (`ORIENTATION_FRAMES[orientation]`). - // `watchOrientationChangeSaga` writes the DESTINATION into `settings.orientation` - // the instant a switch starts, so this never moves during a roll — the eye, - // decoded through it via `updatePosition`, holds still; only up rotates. - // - // `upBasis` is `resolveFrameBasis`'s live B(t), resolved exactly once here. - // - // `state.cameraRuntime.upBasis.current` gets `upBasis`, NOT `poseBasis`: it - // seeds the NEXT switch's `fromQuat` (`watchOrientationChangeSaga`), and a - // re-switch mid-roll must compose from the live pole, not the committed one. - // - // `state.cam` and `deriveFrameContext` below both take the same split — - // committed basis for position decode, live basis for up — see - // `OrbitCameraInit.d.ts` for why the two camera fields exist. - const poseBasis = ORIENTATION_FRAMES[rootState.settings.orientation]; - const upBasis = resolveFrameBasis( - rootState.settings.orientation, - rootState.camera.frameTween, - state.cameraRuntime.clock, + } = stepCameraRuntime(state.cameraRuntime, { nowMs, - ); - state.cameraRuntime.upBasis.current = upBasis; - if (state.cam) { - // Pre-bootstrap `cam` is null; a grab is impossible until wireInput attaches - // controls, so there is no decode to keep in sync until then. - state.cam.poseBasis = poseBasis; - state.cam.upBasis = upBasis; - } - - // Clear a finished frame roll exactly once, mirroring the camera-tween - // completion block below: when the roll's elapsed saturates its duration, the - // slerp has landed on the destination basis, so drop the descriptor and let the - // steady branch take over next frame. Re-calling `frameTweenElapsed` is safe — - // the descriptor reference is unchanged, so the clock-reset branch does not fire - // and no double-tick occurs. `EASE` clamps the slerp parameter, so THIS frame's - // already-resolved basis is the destination exactly; clearing only affects the - // next frame's getState. - if (rootState.camera.frameTween !== null) { - const rollElapsed = frameTweenElapsed( - state.cameraRuntime.clock, - rootState.camera.frameTween, - nowMs, + simDays, + rootState: stored, + canvasPx: [deps.canvas.clientWidth || 1, deps.canvas.clientHeight || 1], + aspect: deps.canvas.width / deps.canvas.height, + steps, + bodies: bodyStates, + clipEpoch, + drivers: deps.drivers, + }); + // The runtime is installed BEFORE any action reaches the store (ruled): a + // listener fired by a commit sees this frame's register, not last frame's. + state.cameraRuntime = next; + for (const action of actions) deps.cb.store.dispatch(action); + if (requestRender) state.subsystems.scheduler.requestRender(); + + const { displayed: renderPose, projection, upBasis } = next.outputs; + const poseBasis = ORIENTATION_FRAMES[stored.settings.orientation]; + const pivotFocus = stored.selectionRows.focus; + + // The debug panel's Δ/peak columns, at FRAME rate: its 4 Hz poll averages + // ~15 frames into one reading, which is precisely how a per-frame decay + // passes for a per-notch one. Gated on the panel being mounted — the + // derivation walks the body roster, which nobody who never opens it pays for. + if (orientDeltasWatched()) { + recordOrientDeltas( + cameraDofAnglesOf({ + storedFrame: rootState.camera.base.frame, + worldPose, + poseBasis, + upBasis, + bodyStates, + rememberedTiltRad: next.surface.rememberedTiltRad, + tuning: rootState.camera.tuning, + }), ); - if (rollElapsed >= rootState.camera.frameTween.durationMs) { - deps.cb.store.dispatch(clearFrameTween()); - } - } - - // ── (2) TWEEN COMPLETION: cancel a finished tween exactly once ──────────── - // - // Must run AFTER the pose is produced (so `pose` already == to via saturation - // at elapsed >= durationMs) and BEFORE commit-on-edge sees the - // deactivation. The cancel sets tween=null in the store; on the NEXT frame the - // tween driver is inactive → winner changes away from 'tween' → commit fires. - // Exactly one commit per tween: the commit-on-edge prev!==activeId guard is - // false while the tween is still the winner, so no per-frame commit fires. - // - // Re-calling `tweenElapsed` here is safe (idempotent same-frame): the - // descriptor reference is unchanged, so the clock-reset branch in - // `tweenElapsed` does not fire, and the returned elapsed is the same value - // `runCameraDrivers` used — no double-tick of the clock. - if (activeId === 'tween' && rootState.camera.tween !== null) { - const elapsed = tweenElapsed(state.cameraRuntime.clock, rootState.camera.tween, nowMs); - if (elapsed >= rootState.camera.tween.durationMs) { - deps.cb.store.dispatch(cancelCameraTween()); - // The tween driver deactivates on the NEXT frame; the commit fires then - // (via the prevActiveId edge), baking `lastPose` (== to, saturated) - // into base. We do NOT change `activeId` here. - } - } - - // ── (3) COMMIT-ON-EDGE: fold the last produced pose into base, once ─────── - // - // Fires when the active driver changed AND the departing driver declared - // `commitsOnEdge: true`. Drivers that declare this (tween, autoRotate, clip) - // must bake their final pose into `base` on deactivation so the camera holds - // the saturated pose rather than snapping back to the pre-animation base. - // `orbitDrag` and `resting` do NOT declare it: - // - orbitDrag commits via `onGestureEnd` (the synchronous DOM handler), - // which bakes the final cam pose before the next frame sees dragging=false. - // - resting's pose IS base; committing it is a noise-write. - // - // Reading the flag off the driver row (rather than a hardcoded id set) means - // adding a new committing driver is a one-line declaration in buildCameraDrivers, - // with no surgery here. The clip driver is among them: its deactivation edge - // bakes the final composed pose into base for free. - // - // `lastPose.current` at this point holds the PREVIOUS frame's pose (it has - // not been updated for this frame yet — that happens in step 4). So when the - // tween deactivates on frame N, `lastPose` holds frame N-1's saturated pose - // (== desc.to exactly), and that is what lands in `base`. Exactly one commit, - // exactly at the `desc.to` value. - const { lastPose, prevActiveId } = state.cameraRuntime; - const prev = prevActiveId.current; - // The pose this frame actually renders. Normally the freshly produced pose; - // on a deactivation edge it is overridden to the just-committed pose (below). - let renderPose = pose; - if (prev !== activeId && deps.drivers.find((d) => d.id === prev)?.commitsOnEdge) { - deps.cb.store.dispatch(commitCameraPose(lastPose.current)); - // Commit-on-edge fires AFTER produce, so the produce step above ran the - // INCOMING driver against the PRE-commit `base`. For a driver that reads - // `base` (resting / autoRotate) that pose is the stale pre-edge value — - // rendering it flashes the camera back to where the tween, spin, or clip - // started for one frame. `lastPose.current` is the animation's final pose - // and the value we just baked into `base`, so render THAT this frame instead. - renderPose = lastPose.current; - } - - // ── (3b) PIVOT-PIN: re-centre the pose on a focused body ────────────────── - // - // Body focus is un-braided into two concerns: the focused body owns the PIVOT - // (target), whichever driver won owns the ORBIT terms (yaw/pitch/distance). - // Here we apply the body pivot to the winning driver's pose in ONE place, so a - // drag orbits around the moving body (no drift), the autoRotate button spins - // around it, and the idle follow holds it — without followBody having to win - // the whole pose. Only drivers that declare `pivotsOnFocusedBody` are pinned - // (clip / tween keyframe a full path including target and opt out). The pin is - // absolute (SETS the target), so baking `renderPose` into `base` on the next - // commit-on-edge can never double-apply the body translation. - // - // A right-drag STRAFE while following is folded into the clock's world-frame - // `followPanOffset` FIRST (a follow-drag frame is orbitDrag winning over a body - // focus), then the pin resolves the pivot to `bodyPosition + followPanOffset`. - // The offset — not `cam.target`, which the pin overwrites — is the strafe's home, - // so the shifted pivot still translate-follows the body and a fresh focus zeroes - // it (in `followElapsed`). - // Read the pivot focus off `rootState` (the SAME store snapshot the drivers - // resolved against this frame), so the pin and the winner never disagree on - // what is focused. A separate `focusRow` local below reads the EngineState - // mirror for the structure-focus / time-report sections. - const pivotFocus = rootState.selectionRows.focus; - const clock = state.cameraRuntime.clock; - const followingBody = bodyMovesThisFrame(pivotFocus); - if (state.cam) { - accumulateFollowPan(clock, activeId === 'orbitDrag' && followingBody, state.cam.target); - } else { - // Pre-bootstrap: no cam, no drag possible — keep the delta chain reset. - clock.lastPanTarget = null; } - renderPose = applyFocusedBodyPivot( - renderPose, - deps.drivers.find((d) => d.id === activeId)?.pivotsOnFocusedBody ?? false, - pivotFocus, - simDays, - clock.followPanOffset, - ); - - // ── (4) UPDATE Resources for next frame ─────────────────────────────────── - // - // `prevActiveId` and `lastPose` are updated AFTER the commit-on-edge so the - // commit correctly reads the PREVIOUS frame's values. - prevActiveId.current = activeId; - lastPose.current = renderPose; - // Compute the scale-bar legend engine-side so the store's `engine.scale` - // slice stays authoritative for every consumer (ScaleBar, tour sagas). - // `clientWidth`/`clientHeight` are CSS pixels — required by computeScaleInfo; - // using `width`/`height` (backing-store px) silently breaks the bar on retina. - // state.cam non-null is the bootstrap-ready proxy — snap values come from - // lastPose + projection, not from state.cam. - if (state.cam) { + // `clientWidth`/`clientHeight` are CSS px; backing-store `width`/`height` + // silently breaks the bar on retina. + if (state.booted) { const snap = { - distance: lastPose.current.distance, - fovYRad: state.cameraRuntime.projection.fovYRad, + distance: worldPose.distance, + fovYRad: projection.fovYRad, }; const scaleInfo = computeScaleInfo({ cam: snap, @@ -461,22 +162,13 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): } } - // ── Per-frame derived snapshot ──────────────────────────────────────────── - // - // `deriveFrameContext` receives the already-produced `pose` and the live - // `projection` Resource, assembles the full OrbitCamera, and pre-computes the - // view-projection matrix, camera-position tuple, and pixel-per-radian scalar - // for downstream `renderFrame()`. The 'not ready' branch is the brief window - // before the first cloud lands; once cam + GPU handles populate together, - // it's never taken again. + // The 'not ready' branch is the window before cam + GPU handles populate. const ctx = deriveFrameContext( state, deps.canvas, + worldPose, renderPose, - state.cameraRuntime.projection, - // The committed pose basis (holds still through a roll) and the live up - // basis (rolls) — the same split fed to the drag register above, so the - // draw decode shares both poles with the switch surfaces. + projection, poseBasis, upBasis, masks.draw, @@ -484,51 +176,24 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): simDays, ); if (!ctx.isReady) { - // Essential wake: bootstrap populates cam/GPU handles without waking any - // channel — keep re-polling until the gate opens. + // Bootstrap populates the handles without waking any channel: keep polling. state.subsystems.scheduler.requestRender(); return; } - // ── Structure-focus recession (computed ONCE, EARLY) ───────────────────── - // - // Focus mode fades non-member galaxies away when a cluster / supercluster / - // void / group structure is focused. Resolve the focused structure (a bare - // single-click select does not count; galaxy / nothing both → null) and let - // the subsystem diff it against its focused id to drive the 400 ms - // member-isolation fade. - // - // `produceFocusUniforms(nowMs)` TICKS the focus fade controller, so it - // must run EXACTLY ONCE per frame — a second call would double-advance - // the ramp (a visible glitch). We compute it here, before the label - // director, marker upload, and render-settings sections, because all of - // those (and later per-galaxy presentation producers) consume the blend - // via `ctx.focusBlend`. The single returned `FocusUniformsValue` is - // captured in `focusUniforms`; `ctx.focusBlend` and the render - // `settings.focus` both read THAT captured value — never a fresh - // `produceFocusUniforms` call. + // `produceFocusUniforms(nowMs)` TICKS the focus fade, so it runs EXACTLY ONCE + // per frame, before every consumer of the blend (label director, markers, + // render settings); all of them read the captured value, never a fresh call. const focusRow = state.selectionRows.focus; - // The focus row is the saga-reconciled SelectionRow for the focus slot. - // A structure row IS a StructureInfo, so passing it directly typechecks. - // A galaxy / milkyWay / nothing resolves to null, collapsing the - // member-isolation fade. const focusedStructure = focusRow !== null && focusRow.type === 'structure' ? focusRow : null; state.subsystems.structureFocus.update(focusedStructure, nowMs); const focusUniforms = state.subsystems.structureFocus.produceFocusUniforms(nowMs); ctx.focusBlend = focusUniforms.blend; ctx.focus = focusUniforms; - // ── Focused-body distance publication (throttled) ───────────────────────── - // - // Publish the camera→focused-body distance to the store so the InfoCard - // reads it off `state.engine.focusedBodyDistanceMpc` without ever touching - // the engine snapshot (the store-boundary rule). The ~4 Hz gate keeps this - // off the per-frame React path; the reducer dedups an unchanged report. - // The distance is from the RENDERED camera position to the focused scene - // body's position IN THIS FRAME'S SNAPSHOT — so it tracks the body as the - // clock moves it — and is null unless an orbital body (present in the - // snapshot) is the current focus. A star or structure focus, or no focus, - // reports null. + // Camera→focused-body distance for the InfoCard (the store-boundary rule: + // React never reads the engine snapshot). Null unless an orbital body in this + // frame's snapshot is focused. if (publishBodyDistanceGate(nowMs)) { let focusedBodyDistanceMpc: number | null = null; if (focusRow !== null && focusRow.type === 'body') { @@ -540,16 +205,9 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): deps.cb.store.dispatch(engineBodyDistanceReported(focusedBodyDistanceMpc)); } - // ── Per-frame impostor planners ─────────────────────────────────────────── - // - // CPU-side step that populates the LOD subsystems' `lastOutput` arrays, which - // `proceduralDisksPass` / `texturedDisksPass` read at draw time. The - // atlas subsystem is mutated transitively by the textured-disk run (slot - // allocations + fetch enqueues). // hiResFamous must run BEFORE the shared disk walk: the textured-disk body - // reads hiResFamous.lastOutput.byFamousIdx and folds layer indices + crossfade - // alphas into the DiskInstance literals it emits. Running it after would lag - // by a frame and produce a visible flicker on close approach to a famous galaxy. + // folds `hiResFamous.lastOutput.byFamousIdx` into the instances it emits; + // after would lag a frame and flicker on close approach. if (state.subsystems.hiResFamous !== null) { state.subsystems.hiResFamous.runFrame({ cam: ctx.cam, @@ -559,11 +217,8 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): famousGalaxiesMeta: state.famousGalaxiesMeta, }); } - // ONE shared catalog walk drives both disk-planner bodies. It computes each - // surviving row's geometry once and hands the scalars to the procedural body - // (LOD-1) then the textured body (LOD-2) at two fixed call sites; each - // subsystem's `beginFrame` returns the per-frame visitor the walk drives, and - // that visitor's `endFrame` stashes the sorted result on its `lastOutput`. + // ONE catalog walk feeds both disk planners (LOD-1 procedural, then LOD-2 + // textured); each `beginFrame` returns the visitor the walk drives. const { proceduralDisks, texturedDisks, diskPlannerWalk } = state.subsystems; if (proceduralDisks !== null && texturedDisks !== null && diskPlannerWalk !== null) { const sharedInput = { @@ -588,44 +243,28 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): ); } - // ── Earth surface virtual texture — the tile planner ────────────────────── - // A CPU-side planner, sited with the disk planners above. Resolved off - // Earth's OWN body-slab row rather than a fixed NEAR0 index — no row this - // frame means Task 1 already culled Earth, so there is nothing to plan. - // Where a row DOES exist, the gate is still `earthPass.enabled` itself, - // not a hand-copied predicate, so the tiles and the layer they refine can - // never disagree about whether Earth is on screen. + // The tile planner keys off Earth's OWN slab row (no row = already culled) + // and the layer's own `enabled`, so tiles and layer never disagree about + // whether Earth is on screen. const earthTiles = state.subsystems.earthTiles; const earth = state.data.bodies.earth; const earthSlab = ctx.slabs.find( (slab) => slab.frame.kind === 'body-m' && slab.frame.bodyId === 'earth', ); if (earthTiles !== null && earth !== null && earthSlab !== undefined) { - // Same slab resolution `earthPass.draw` uses, resolved once and reused - // below so the tiles the planner asks for never drift from the pixels - // the fragment samples them into. + // The same slab view `earthPass.draw` samples into. const earthTilesView = slabViewOf(ctx, earthSlab.index); if (earthPass.enabled(state, ctx, earthTilesView)) { - // `earthSurfaceTier` reads the tier off the committed texture slot, not the - // app-wide request, so a tier swap in flight can't make the planner believe - // in detail that isn't on the GPU yet. Null until the manifest lands. + // The tier off the COMMITTED texture slot, so a swap in flight cannot make + // the planner believe in detail that is not on the GPU yet. const params = earthTiles.plannerParams(earthSurfaceTier(state)); - // Empty by default: overwritten below only on the path that actually - // resolves a fresh cut. `setLastCut` runs unconditionally at the bottom - // of this block so a null-params/null-prepared frame (a tier swap in - // flight) draws nothing stale rather than last frame's cut. + // `setLastCut` runs unconditionally so a tier swap in flight draws + // nothing stale rather than last frame's cut. let cut: readonly SurfaceCutTile[] = []; if (params !== null) { - // Skip when Earth's frame derivation is null — mirrors the `earth !== - // null` guard above (prepareBodySurfaceFrame returns null on exactly - // that condition); kept explicit so this block doesn't lean on the - // outer guard's reasoning to satisfy the type checker. const prepared = prepareBodySurfaceFrame(state, ctx, earthTilesView); if (prepared !== null) { - // The single walk: `cut` is what earthPass.draw draws this frame, - // `requests` is what update()'s fetch loop drives — see - // cutSurfaceTiles's header for why one walk produces both rather - // than two independently re-deriving the same horizon/frustum logic. + // One walk yields both the draw cut and the fetch requests. const result = cutSurfaceTiles({ ...params, camPosLocalM: prepared.pose.eyeRelBodyM, @@ -642,23 +281,14 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): } } - // Read OUTSIDE the gate above: `isAnimating()` is true while the manifest is - // in flight, a state entered BEFORE the subsystem can ever engage — voting - // only on engaged frames would let a stopped camera sleep the loop mid-fetch. + // Outside the gate: `isAnimating()` is true while the manifest is in flight, + // before the layer can engage — voting only on engaged frames would sleep + // the loop mid-fetch. const earthTilesAnimating = earthTiles?.isAnimating() ?? false; - // ── Label director per-frame update ────────────────────────────────────── - // - // Runs BEFORE the GPU dispatch so `labelRenderer.setLabels` / - // `markerLineRenderer.setLines` (and the NEAR0 pair) are uploaded before - // `renderFrame` reads those buffers. Each director polls its own registered - // `Label2DProducer`s, merges, change-detects via signature hash, and flushes - // once; both null-check their renderers, so this is safe before the atlas - // load completes. - // - // Three statements, not `a() || b() || c()`: each call FLUSHES GPU buffers - // as a side effect, and `||` short-circuits — the inline form would skip a - // sibling's flush the moment an earlier one votes true. + // Before the GPU dispatch (they upload the label buffers). Three statements, + // not `a() || b() || c()`: each call FLUSHES as a side effect and `||` + // short-circuits. const cosmoLabelsAnimating = state.subsystems.cosmoLabelDirector.runFrame(state, ctx); const nearLabelsAnimating = state.subsystems.foregroundLabelDirector.runFrame(state, ctx); const label3DAnimating = runLabel3DProducers(state, ctx); @@ -681,23 +311,11 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): // (renderer null / master off) — that maps to `starFadeAnimating: false` below. const starCut = advanceStarFades(state, ctx); - // ── Per-frame marker upload ─────────────────────────────────────────────── - // - // Like the label flush above: runMarkerProducers walks the producer array (no - // sort/filter/dedupe, picks order preserved) and hands descriptors to the - // renderer. Must run BEFORE the GPU dispatch so the instance buffer is - // uploaded before `structureMarkersPass` reads it. Null-checked for the - // pre-initGpu window. + // Before the GPU dispatch: uploads the instance buffer `structureMarkersPass` reads. if (state.gpu.structureMarkerRenderer !== null) { state.gpu.structureMarkerRenderer.setMarkers(runMarkerProducers(state, ctx)); } - // ── GPU dispatch ────────────────────────────────────────────────────────── - // - // The whole encoder lifecycle (createCommandEncoder, the FRAME program's - // render/composite steps, queue.submit) lives in `renderFrame.ts`; every - // value it reads is forwarded as a field on `RenderFrameInput` so this - // site stays free of GPU bookkeeping. renderFrame({ ctx, state, @@ -706,31 +324,13 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): timingService: deps.timingService, }); - // ── Pick-buffer debug overlay ───────────────────────────────────────────── - // - // Composite a colour-mapped pick-buffer overlay over the swap chain. - // Runs AFTER renderFrame's submit — placed post-frame purely as a latency - // choice (reflect the just-rendered pose with minimal lag), not because it - // depends on the frame having drawn: `pickProgram.renderForDebug()` rebuilds - // the pick-time camera as a value and re-draws the pickable layers itself. - // The helper owns its own encoder/submit with `loadOp: 'load'` so the OVER - // blend composites on top of the tone-mapped frame without re-rendering. - // - // Hover picking is now fully pointer-driven (hoverPickDriver, wired in - // wireInput.ts) — there is no longer an in-frame pick block here. + // After the submit as a latency choice only; it owns its own encoder with + // `loadOp: 'load'`. Hover picking is pointer-driven (hoverPickDriver). drawPickDebugOverlay(state, deps); - // ── Render-on-demand: continue ticking ONLY if motion or async work is in - // flight. Otherwise the loop sleeps until a channel mouth wakes it: input, - // a fade or tween start, a slot reaching ready, a selection/focus change, - // or a settings write. `shouldKeepTicking` owns the full predicate (camera - // motion, in-flight thumbnails, fades, structure-focus, animated flow) and - // is deliberately independent of what is pickable — see its module header. - // - // Tick the FadeRegistry BEFORE the predicate reads isAnyAnimating: tick is - // the single resolution site for fadeTo promises, so without it the awaited - // fade-out in galaxy-catalog visibility changes and tier-swap commits would - // hang forever. + // Render-on-demand. Tick the FadeRegistry BEFORE the predicate reads + // isAnyAnimating: tick is the single resolution site for fadeTo promises, so + // without it awaited fade-outs (catalog visibility, tier swaps) hang forever. state.subsystems.fades.tick(nowMs); const keepTicking = shouldKeepTicking(state, rootState, nowMs, { starFadeAnimating: starCut?.anyNodeFading ?? false, @@ -741,10 +341,8 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): if (keepTicking) { state.subsystems.scheduler.requestRender(); } else if (selectIsLiveTicking(rootState)) { - // The scene is otherwise at rest, but a live sim clock is advancing. Arm a - // coarse heartbeat (see LIVE_IDLE_TICK_MS) so the terminator stays honest - // without pinning the loop — the scheduler ignores this while a frame is - // already queued and never stacks timers, so it can't fight the wake path. + // At rest with a live sim clock: a coarse heartbeat (see LIVE_IDLE_TICK_MS) + // the scheduler ignores while a frame is already queued. state.subsystems.scheduler.requestIdleFrame(LIVE_IDLE_TICK_MS); } } diff --git a/src/services/engine/frame/skyCubemapFaceContext.ts b/src/services/engine/frame/skyCubemapFaceContext.ts index e34a3e0bd1..8d80185b80 100644 --- a/src/services/engine/frame/skyCubemapFaceContext.ts +++ b/src/services/engine/frame/skyCubemapFaceContext.ts @@ -23,9 +23,8 @@ import { SCALE_UNITS } from '../../../data/scaleUnits'; const SKY_CAPTURE_NEAR_MPC = 0.1 * SCALE_UNITS.AU_TO_MPC; /** - * Forward axis per `CubeFace` (±X/±Y/±Z), and the `texture_cube` convention's - * per-face up (the ±Y faces borrow world ±Z, since world ±Y is forward - * there). The cube-view bind relies on both matching that convention. + * Forward axis per `CubeFace` (±X/±Y/±Z) and the `texture_cube` convention's + * per-face up — the ±Y faces borrow world ±Z. The cube-view bind relies on both. */ const FACE_FORWARD: readonly Vec3[] = [ [1, 0, 0], @@ -45,10 +44,9 @@ const FACE_UP: readonly Vec3[] = [ ]; /** - * One basis per face, serving as both `poseBasis` and `upBasis` so the two - * cannot drift. `updatePosition` decodes local +Z through the THIRD column, - * so that column is `-forward`; `frameUp` reads the MIDDLE for screen-up, so - * that one carries `FACE_UP`. + * One basis per face, serving as both `poseBasis` and `upBasis` so the two cannot + * drift. `updatePosition` decodes local +Z through the THIRD column, so that column + * is `-forward`; `frameUp` reads the MIDDLE for screen-up, so it carries `FACE_UP`. */ const FACE_BASES: readonly Mat3[] = FACE_FORWARD.map((forward, i): Mat3 => { const up = FACE_UP[i]!; @@ -91,25 +89,28 @@ export function skyCubemapFaceContext(input: { // Only `.width`/`.height` are read, and an offscreen capture has no canvas. { width: faceSizePx, height: faceSizePx } as unknown as HTMLCanvasElement, pose, + // The capture pose is synthetic and world-absolute, so the pose-provider + // seam routes every body through the Mpc path — no body arm can be engaged + // on a face. + { frame: 'absolute', pose }, // 90° symmetric frustum, one cube face; `far` rides the live projection. { fovYRad: Math.PI / 2, aspect: 1, near: SKY_CAPTURE_NEAR_MPC, - far: state.cameraRuntime.projection.far, + far: state.cameraRuntime.outputs.projection.far, }, basis, basis, deriveSourceMasks(state).draw, // draw mask: a capture, not a click target nowMs, - state.cameraRuntime.lastRenderedSimDays.current, + state.cameraRuntime.outputs.simDays, ); if (!ctx.isReady) return null; // In place is safe: `deriveFrameContext` freshly allocated these arrays. flipClipY(ctx.vp); for (const slab of ctx.slabs) flipClipY(slab.vp); - // Slot 0 is the main view, so the view-slot rings keep this call's writes - // off the real frame's (`ReadyFrameContext.viewSlot`). + // Slot 0 is the main view, so this call's ring writes stay off the real frame's. return { ...ctx, viewSlot: face + 1 }; } diff --git a/src/services/engine/frame/slabs.ts b/src/services/engine/frame/slabs.ts index a827ac6a1f..de6e7565f3 100644 --- a/src/services/engine/frame/slabs.ts +++ b/src/services/engine/frame/slabs.ts @@ -1,18 +1,9 @@ /** - * slabs — per-frame derivation of the `Slab` table, and the executor-side - * lookup that resolves a `slab: number` index into a `SlabView`. + * slabs — per-frame derivation of the `Slab` table, and the executor-side lookup + * that resolves a `slab: number` index into a `SlabView`. * - * Skymap's depth range (Earth at near-field scale out to distant galaxies) - * spans a near/far ratio no single depth buffer can hold — see the `Slab` - * type doc for the full precision argument. The fix already shipped as two - * unlabelled ad-hoc concepts (a "foreground" near-field view-proj and the - * main cosmological view-proj); this module names both as rows of one table - * so a future third slab (e.g. an adaptive set during a zoom descent) is one - * more row, not a new code path threaded through every call site. - * - * `deriveSlabs` is called once per frame, right where the cosmological `vp` - * is already being computed (`frameContext.ts`) — see that module for why - * there must be exactly one derivation site. + * The scene's near/far ratio exceeds what one depth buffer can hold; each slab is + * one bracket of it. See the `Slab` type doc for the precision argument. */ import type { Mat4 } from 'wgpu-matrix'; @@ -47,79 +38,38 @@ export const NEAR0 = 0; /** Cosmological slab: galaxies, Milky Way, filaments — everything at Mpc scale. */ export const COSMO = 1; -/** - * True when `index` names a body-slab row (2, 3, … — `deriveSlabs` assigns - * them exactly where `frame.kind === 'body-m'`). The one place that knows - * this index layout: a call site holding a `Slab`/`SlabView` should read - * `frame.kind` directly instead (no layout knowledge needed there); this - * predicate exists for `frameProgram.ts`'s `timedSlotRowsOf`, which only has - * a step's numeric `slab` and no `ctx` to resolve a frame kind from. - */ +// The one place that knows the index layout, for callers holding only a numeric +// `slab` and no `ctx` — anyone holding a `Slab` should read `frame.kind` instead. export function isBodySlabIndex(index: number): boolean { return index >= 2; } -/** - * Human-readable slab name for debug surfaces: `'NEAR0'` | `'COSMO'` | - * `'BODY[k]'` for a body row at array position `k + 2` — the painter - * ordinal a body row's index already carries (see `deriveSlabs`'s body-row - * sort). `timedSlotRowsOf` (frameProgram.ts) calls this to build each render - * slot's `groupKey` — `'·'`, e.g. `'hdr·COSMO'` — which the - * DebugPanel buckets into a titled group. Every non-negative index resolves - * to a name; the function is total, so `groupKeyOf` needs no fallback. - */ +// `'BODY[k]'` for a body row at array position `k + 2` — the painter ordinal its +// index already carries. Total, so `groupKeyOf` needs no fallback. export function slabName(index: number): string { if (index === NEAR0) return 'NEAR0'; if (index === COSMO) return 'COSMO'; return `BODY[${index - 2}]`; } -/** - * The ONE definition of a merged group-timing slot key — the string a render - * step's whole layer group is billed against. `timedSlotRowsOf` (frameProgram) - * allocates the slot under this key; `executeFrame`'s merged pass resolves it - * via `descriptorFor(groupKey)`. The two sites must produce byte-identical - * keys, so the format lives here rather than as twin inline templates that - * could silently drift. The middle-dot separator (U+00B7) is part of that - * wire format — do not vary it. - */ +// The ONE definition of a merged group-timing slot key: allocation and lookup must +// produce byte-identical keys, so the middle-dot separator (U+00B7) is part of the +// wire format — do not vary it. export function groupKeyOf(target: string, slab: number): string { return `${target}·${slabName(slab)}`; } -/** - * The per-slot GPU-timing NAME for a layer drawing into `slabIndex` — bare - * `passName` for NEAR0/COSMO (one instance per frame, already unique), or - * `'·BODY[k]'` for a body row, so two body rows sharing one - * `'body'`-slab layer (e.g. `planetsPass` drawing Jupiter AND a moon) don't - * collide on the same query-set index pair. A capture step's `face` appends - * the same way and for the same reason: a roster layer draws once per - * captured face AND once for the real view, all on `(hdr|sky-cubemap, NEAR0)` - * — without the face, all seven passes would attach the same query pair and - * the last one silently overwrite the rest. `timedSlotRowsOf` - * (frameProgram.ts, the build-time slot-list/query-set-size derivation) and - * `executeFrame`'s `perLayerTimed` pass (the runtime `descriptorFor` lookup) - * both call this, so the allocated slot and the looked-up slot can never - * drift apart. - */ +// Body rows and capture faces are appended because both draw the same pass more +// than once per frame against one `(target, slab)` — without them the passes attach +// the same query pair and the last silently overwrites the rest. export function passTimingSlotName(passName: string, slabIndex: number, face?: number): string { const base = isBodySlabIndex(slabIndex) ? `${passName}·${slabName(slabIndex)}` : passName; return face === undefined ? base : `${base}·FACE[${face}]`; } -/** - * The per-STEP query-set slot name for a render step that may carry a - * `face` (the sky-cubemap capture) — `groupKey` unchanged, or - * `'·FACE[n]'` when a face is present. Six capture steps share one - * `(target, slab)` — `('sky-cubemap', NEAR0)` — because the array-layer they - * write isn't part of the `(target, slab)` key at all (unlike a body row, - * which gets its OWN `slab` index and so is already unique), so `groupKeyOf` - * alone collides across faces; this is `passTimingSlotName`'s counterpart - * one level up, disambiguating the STEP's own slot rather than a layer's. - * `timedSlotRowsOf` (frameProgram.ts) allocates under this name; - * `executeFrame`'s merged pass must resolve the identical name via - * `descriptorFor`, so both call this rather than templating `·FACE[n]` twice. - */ +// The same disambiguation one level up, for the STEP's own slot: six capture steps +// share one `(target, slab)` — the array layer they write is not part of that key — +// so `groupKeyOf` alone collides across faces. export function renderStepTimingSlotName( groupKey: string, face: number | undefined, @@ -155,54 +105,29 @@ export function matchesHdrPhase( } /** - * The single source of each slab's depth convention: `false` ⇒ the classic - * smaller-z-wins / clear-`1.0` / `mat4d.perspective` set; `true` ⇒ reversed-Z - * (greater-wins / clear-`0` / `mat4d.perspectiveReverseZ`). - * - * NEAR0 is `true`: its foreground bracket spans a ~1e8 near/far ratio (Earth's - * surface out to Jupiter's orbit), and a finite non-reversed perspective - * crowds nearly all its depth resolution against the near plane — so a body at - * the far end (the Sun at 1 AU) quantizes onto the far plane and flickers. - * Infinite-far reversed-Z spreads reciprocal-depth precision near-uniformly and - * removes the far plane entirely, so the whole near-field scene resolves in one - * `depth32float` buffer. COSMO stays `false`: its fixed 10 kpc → 50 Gpc bracket - * is served fine by the classic convention, and its pick pipelines + clear must - * stay smaller-z-wins. - * - * This one constant is the reversed-Z feature switch: `[NEAR0]` propagates to - * every pipeline `depthCompare`, both depth clears, and the foreground - * projection builder, because all of those read this constant — either directly - * at renderer construction, or via the `reversedZ` flag echoed onto the runtime - * `Slab` by `deriveSlabs`. Single-sourcing the convention here is what makes the - * flip one constant instead of ~14 scattered sites, and makes a partial - * (half-reversed) flip impossible. + * The single source of each slab's depth convention: `false` ⇒ smaller-z-wins / + * clear-`1.0` / `mat4d.perspective`; `true` ⇒ reversed-Z. NEAR0 is `true` because + * its ~1e8 near/far ratio crowds nearly all non-reversed depth resolution against + * the near plane, and the Sun at 1 AU then quantizes onto the far plane and + * flickers. Every pipeline `depthCompare`, both depth clears, and the foreground + * projection builder read this constant, so a half-reversed state is impossible. */ export const SLAB_REVERSED_Z: Readonly> = { [NEAR0]: true, [COSMO]: false, }; -// The near-field lookAt derives its image-plane up through the shared -// `imagePlaneBasis` seam. The base up is the frame pole (`frameUp(cam.upBasis)`; -// world +Y absent a basis). Roll is 0 here — roll parity with the cosmological -// slab's `computeViewProj` is deferred alongside the zoom-to-earth series. - -// Module-scope scratch reused each frame: the forward view direction, the -// frame-pole reference up, and the roll-adjusted basis. `deriveSlabs` runs once -// per frame, so all three are hoisted out to avoid per-call allocation. +// The near-field lookAt takes its image-plane up from `imagePlaneBasis` with +// `cam.roll` applied — the SAME roll COSMO's `computeViewProj` and the body rows' +// `camBasisWorld` honour. `toWorldArm` sets a non-zero roll whenever the body arm +// is live, so dropping it rotates every NEAR0 layer against every other slab. const forwardScratch: Vec3 = [0, 0, 0]; const upRefScratch: Vec3 = [0, 0, 0]; const basisScratch: ImagePlaneBasis = { rolledUp: [0, 0, 0], right: [0, 0, 0], up: [0, 0, 0] }; -// The cosmological slab's near/far are fixed: 10 kpc sits safely below the -// nearest cosmological content (the closest satellite galaxies at tens of -// kpc), 50 Gpc comfortably contains every catalogued galaxy. Anything that -// draws INSIDE 10 kpc cannot live on this slab — the Milky Way impostor -// learned that the hard way (the fixed plane clipped its disc mid-descent) -// and moved to NEAR0, joining the star/orbit/caption rows there. Unlike the -// near-field slab, "how far back does the cosmological scene go" doesn't -// change as the user zooms — only the near-field slab's range is -// camera-relative. +// Fixed, in Mpc: 10 kpc sits below the nearest cosmological content, 50 Gpc holds +// every catalogued galaxy. Anything drawing INSIDE 10 kpc cannot live on this slab +// — the Milky Way impostor's disc got clipped mid-descent and moved to NEAR0. const COSMO_NEAR_MPC = 0.01; const COSMO_FAR_MPC = 50000; @@ -213,28 +138,13 @@ const COSMO_FAR_MPC = 50000; const NEAR_MARGIN_EPS = 1e-3; /** - * Build one body's slab row (index left at a placeholder — the caller assigns - * the real painter-order index once every row is sorted), plus the DEV-only - * screen footprint the §7.2 overlap invariant needs (`chainRow`), or `null` - * when `pose` reports the body has no pose this frame (culled). - * - * `vp` is built ABOUT THE EYE: `basisM`'s forward/up columns feed `mat4d.lookAt` - * with the eye at the origin, so the rotation `lookAt` derives carries no - * translation term — geometry drawn into this slab is expected already - * expressed relative to the eye (RTC-native, no rebase step; see the `Slab` - * type doc). `near` uses the reversed-Z infinite-far projection NEAR0 already - * uses (spec §4); `far` is `+∞` (spec §4's row-numbers table) — the row's - * FINITE far bound is `distanceRangeM[1]`, a distinct field used by the - * painter sort, not by this projection. + * Build one body's slab row, or `null` when the body has no pose this frame. * - * `chainRow.centrePx` reprojects the body centre (`-eyeRelBodyM`) through - * this row's own `vp`. A body with no screen position (`clipW <= 0`) gets - * `[Infinity, Infinity]` — infinitely far from every other row's circle, so - * it never registers a false overlap. `chainRow.radiusPx` reuses - * `bodyApparentDiameterPx`, the same px conversion `visibleSlabBodies` - * already runs for the visibility gate, fed a synthetic radial Mpc position - * at `dM` (only the hypot distance matters to it) rather than threading the - * body's real Mpc position through just for this. + * `vp` is built ABOUT THE EYE, so `lookAt`'s rotation carries no translation and + * geometry drawn here must already be eye-relative (RTC-native, no rebase). `far` + * is `+∞`; the row's FINITE far bound is `distanceRangeM[1]`, used by the painter + * sort, not by this projection. A body with no screen position (`clipW <= 0`) gets + * `[Infinity, Infinity]` for `centrePx`, so it never registers a false overlap. */ function bodySlabRow(input: { readonly body: SceneBody; @@ -243,9 +153,7 @@ function bodySlabRow(input: { readonly aspect: number; readonly viewportPx: Readonly; }): { - // A body row always spans a real interval (its own drawn radius about its own - // distance), so its `distanceRangeM` is narrowed back to non-null here — - // nullability on `Slab` exists for NEAR0 alone. + // Narrowed back to non-null: nullability on `Slab` exists for NEAR0 alone. readonly slab: Omit & { readonly distanceRangeM: readonly [number, number]; }; @@ -261,44 +169,28 @@ function bodySlabRow(input: { const forward: Vec3 = [basisM[6], basisM[7], basisM[8]]; const up: Vec3 = [basisM[3], basisM[4], basisM[5]]; - // Clip depth is measured along the VIEW AXIS, not the radial distance dM: a - // body θ off-axis has view-z = dM·cosθ, which can be well short of dM. The - // old `dM - rMaxM` term budgeted `rMaxM - proxyRadius` for that shortfall; - // once dM·(1 − cosθ) exceeded it the near plane sliced through the body — - // see the investigation this fixes. `viewZ` is exact (not an approximation - // of the sphere's near face): for a sphere at any transverse offset, the - // surface point with minimum view-z is exactly centre·forward − radius, - // because the dot product is linear. + // Clip depth is measured along the VIEW AXIS, not the radial distance dM: a body + // θ off-axis has view-z = dM·cosθ, well short of dM. Exact, not an approximation + // of the sphere's near face: the dot product is linear, so a sphere's minimum + // view-z is exactly centre·forward − radius at any transverse offset. const viewZ = -( eyeRelBodyM[0] * forward[0] + eyeRelBodyM[1] * forward[1] + eyeRelBodyM[2] * forward[2] ); - // marginM covers whichever drawn shell reaches furthest along the view - // axis: the PROXY_SCALE-inflated globe/textured-body mesh, or an - // un-inflated outer shell (rings, atmosphere) wider than that. `NEAR_MARGIN_EPS` - // pads it a little further so a proxy's own near face never lands exactly - // on the plane. + // Whichever drawn shell reaches furthest along the view axis: the + // PROXY_SCALE-inflated mesh, or a wider un-inflated outer shell (rings, atmosphere). const marginM = Math.max(PROXY_SCALE * body.radiusM, rMaxM) * (1 + NEAR_MARGIN_EPS); - // The altitude-above-SURFACE term stays radial: it only wins once the - // camera is inside the outermost shell (viewZ - marginM goes deeply - // negative either way), and that regime is a close orbit/descent around - // THIS body, where θ ≈ 0. Dropping this term and falling straight to - // MIN_NEAR_M there measurably collapses the near-field label window at low - // altitude, so it must stay well-conditioned instead. + // The altitude-above-SURFACE term stays radial: it only wins once the camera is + // inside the outermost shell, a close orbit/descent around THIS body where θ ≈ 0. + // Dropping it and falling straight to MIN_NEAR_M collapses the near-field label + // window at low altitude. const near = Math.max(viewZ - marginM, (dM - body.radiusM) * NEAR_RATIO, MIN_NEAR_M); - // distanceRangeM STAYS RADIAL — the painter sort and pick ordering key off - // the body's actual distance, not its view-axis depth. + // STAYS RADIAL — the painter sort and pick ordering key off actual distance. const distanceRangeM: readonly [number, number] = [Math.max(dM - rMaxM, 0), dM + rMaxM]; - // Body rows share NEAR0's reversed-Z convention — reading `SLAB_REVERSED_Z` - // here (rather than hard-coding the reversed branch) is what makes it true, - // not just documented, that every body-row pipeline (already keyed off this - // SAME constant in `gpuHandleRegistry`) and this row's own projection + - // depth clear (`reversedZ` below, read via `depthClearValueFor`) can never - // disagree if the constant is ever flipped. The non-reversed branch needs - // A finite far; `dM + rMaxM` (== `distanceRangeM[1]`) is unused today - // (SLAB_REVERSED_Z[NEAR0] is `true`) but keeps the fallback well-defined. + // Body rows share NEAR0's convention; reading the constant rather than + // hard-coding the reversed branch is what keeps a flip from half-landing. const reversedZ = SLAB_REVERSED_Z[NEAR0]!; const view = mat4d.lookAt([0, 0, 0], forward, up); const proj = reversedZ @@ -306,8 +198,7 @@ function bodySlabRow(input: { : mat4d.perspective(fovYRad, aspect, near, dM + rMaxM); const vp = mat4d.multiply(proj, view) as Float64Array; - // The §7.2 overlap scan below is DEV-only, so the screen footprint it needs - // is too — a prod frame skips both, not just the scan reading it. + // DEV-only, like the §7.2 scan that reads it — a prod frame skips both. const centrePx = import.meta.env.DEV ? (projectToScreenPx([-eyeRelBodyM[0], -eyeRelBodyM[1], -eyeRelBodyM[2]], vp, viewportPx) ?? ([Infinity, Infinity] as const)) @@ -337,38 +228,13 @@ function bodySlabRow(input: { } /** - * Derive this frame's slab table from the live camera and the already-computed - * cosmological view-proj: `[near0, cosmo, ...bodyRows]`. - * - * The near-field row's vp is an origin-relative f64 view-projection built by - * `computeForegroundViewProj`: eye and target are subtracted from - * `RENDER_ORIGIN_MPC` in f64 before `lookAt`, so the translation stays small - * and sub-metre bodies (Sun, Earth) survive the eventual narrow to f32 at the - * GPU-upload boundary. The result is a native `Float64Array`, stored directly - * in the `Slab.vp: Float64Array` slot with no widening. - * - * The cosmological row's vp is the caller's already-computed `cosmoVp`, - * widened the same way. Because f32→f64 widening is exact, narrowing back - * (`Float32Array.from(slab.vp)`) round-trips byte-equal to the original f32 - * matrix — which is why `slabViewOf` needs no COSMO special case below. - * - * `pivotRadiusMpc` is the orbit pivot's physical radius when one is focused - * (`null` otherwise) — see `foregroundFrustum`'s near-plane bracket for why - * the near-field row keys off ALTITUDE above the pivot, not raw `cam.distance`, - * once a pivot is known. - * - * Body rows: one per `visibleBodies` entry whose `pose(bodyId)` resolves (a - * `null` pose — culled this frame — contributes no row), assigned indices - * `2, 3, …` in back-to-front painter order (sorted by `distanceRangeM[0]` - * descending, spec §4/§7) — so a body row's index doubles as its painter - * ordinal and `slabName`/`groupKeyOf` need no extra parameter. `viewportPx` - * feeds each body row's screen-space footprint (`bodySlabRow`'s `chainRow`), - * computed only in DEV, where it backs the `chainOverlapViolations` - * painter-order check (spec §7.2) below. + * Derive this frame's slab table: `[near0, cosmo, ...bodyRows]`, body rows taking + * indices `2, 3, …` in back-to-front painter order (spec §4/§7). * - * NEAR0's `distanceRangeM` comes from `starSphereRangeM` — the interval the - * star spheres ACTUALLY drawn this frame span (spec §7.1), not the - * foreground-frustum bracket — and stays `null` when no sphere was drawn. + * NEAR0's vp subtracts `RENDER_ORIGIN_MPC` in f64 before `lookAt`, so the + * translation stays small and sub-metre bodies survive the narrow to f32 at the + * GPU-upload boundary. f32→f64 widening is exact, so narrowing COSMO's vp back + * round-trips byte-equal — which is why `slabViewOf` needs no COSMO special case. */ export function deriveSlabs(input: { readonly cam: OrbitCamera; @@ -380,18 +246,11 @@ export function deriveSlabs(input: { readonly starSphereRangeM: readonly [number, number] | null; }): readonly Slab[] { const { cam, cosmoVp, pivotRadiusMpc, pose, visibleBodies, viewportPx } = input; - // The near-field slab's near/far are adaptive, sized from the camera's - // ALTITUDE above a known pivot (else raw orbit distance) by - // `foregroundFrustum`, so depth precision holds from galaxy scale down to - // standing on a body's surface — a large body's radius no longer dominates - // the bracket the way raw distance did. This is unlike the COSMO row's fixed - // `COSMO_NEAR_MPC`/`COSMO_FAR_MPC`: the cosmological scene's depth doesn't - // change as the user zooms, only the near-field's does. + // NEAR0's bracket is adaptive, sized from ALTITUDE above a known pivot (else raw + // orbit distance), so depth precision holds from galaxy scale down to standing on + // a surface — with raw distance a large body's radius dominated the bracket. const altitudeMpc = pivotRadiusMpc !== null ? cam.distance - pivotRadiusMpc : cam.distance; const { near, far } = foregroundFrustum(altitudeMpc); - // The image-plane up comes from the shared basis seam. At roll 0 `rolledUp` - // is exactly the frame pole (`frameUp(cam.upBasis)`; world +Y absent a - // basis), so this tracks the cosmological slab's up through the one seam. const fx = cam.target[0] - cam.position[0]; const fy = cam.target[1] - cam.position[1]; const fz = cam.target[2] - cam.position[2]; @@ -401,7 +260,7 @@ export function deriveSlabs(input: { forwardScratch[2] = fz / flen; const { rolledUp } = imagePlaneBasis( forwardScratch, - 0, + cam.roll ?? 0, frameUp(cam.upBasis, upRefScratch), basisScratch, ); @@ -422,17 +281,11 @@ export function deriveSlabs(input: { near, far, vp: nearFieldVp, - // The vp above is expressed relative to `RENDER_ORIGIN_MPC`, so any layer - // bound to this slab must upload origin-relative model matrices. - // `RENDER_ORIGIN_MPC` is the world origin today, which makes - // `ctx.drawCamPos` (derived from `cam.position`) already origin-relative; a - // future floating origin would re-derive a per-slab `camPos` in - // `slabViewOf`. + // The vp is relative to `RENDER_ORIGIN_MPC`, so any layer on this slab must + // upload origin-relative model matrices. frame: { kind: 'world-mpc', originRelative: true }, - // §7.1: the star spheres actually drawn this frame, not the frustum - // bracket — see `starSphereRangeM`. `null` (empty drawn set) travels as - // itself; `foregroundChainOrder` is where "unresolved" gets its sort - // position. + // §7.1: the star spheres actually drawn this frame, not the frustum bracket. + // `null` travels as itself; `foregroundChainOrder` gives it a sort position. distanceRangeM: input.starSphereRangeM, precision: 'f64', reversedZ: SLAB_REVERSED_Z[NEAR0]!, @@ -443,16 +296,13 @@ export function deriveSlabs(input: { far: COSMO_FAR_MPC, vp: Float64Array.from(cosmoVp), frame: { kind: 'world-mpc', originRelative: false }, - // COSMO never enters the painter chain (not a `foreground:0` target), so - // this fixed bracket is permanent — unlike NEAR0's derived one above. + // COSMO never enters the painter chain, so this fixed bracket is permanent. distanceRangeM: [COSMO_NEAR_MPC * SCALE_UNITS.MPC_TO_M, COSMO_FAR_MPC * SCALE_UNITS.MPC_TO_M], precision: 'f32', reversedZ: SLAB_REVERSED_Z[COSMO]!, }; - // Body rows sort back-to-front by distanceRangeM[0] BEFORE indices are - // assigned, so index === painter ordinal (see the header note) — a body - // whose pose is null this frame (culled) contributes no row. + // Sorted BEFORE indices are assigned, so index === painter ordinal. const sortedBodyRows = visibleBodies .map((body) => bodySlabRow({ body, pose, fovYRad: cam.fovYRad, aspect: cam.aspect, viewportPx }), @@ -461,11 +311,9 @@ export function deriveSlabs(input: { .sort((a, b) => b.slab.distanceRangeM[0] - a.slab.distanceRangeM[0]); const bodyRows: Slab[] = sortedBodyRows.map((row, i) => ({ ...row.slab, index: i + 2 })); - // Dev-only painter-order check (spec §7.2): screen-overlapping body rows - // must have disjoint distance intervals, else one silently paints over the - // other in the wrong order. A warn only, never a throw — see - // `chainOverlapViolations`'s header for why "intervals never overlap" is - // too strong a reading. + // Spec §7.2: screen-overlapping body rows must have disjoint distance intervals, + // else one silently paints over the other. A warn, never a throw — see + // `chainOverlapViolations` for why "never overlap" is too strong a reading. if (import.meta.env.DEV) { const chainRows: readonly ChainRow[] = sortedBodyRows.map((row, i) => ({ index: i + 2, @@ -479,14 +327,9 @@ export function deriveSlabs(input: { return [near0, cosmo, ...bodyRows]; } -/** - * Painter-ordered slab indices for the `foreground:0` chain, back-to-front: - * NEAR0 plus every body row, sorted by `distanceRangeM[0]` descending — the - * same key the body rows are already stored by, so a body-only chain is - * already in order and NEAR0 (the Sun's slab, spec §7.1) merges in by the one - * shared key with no `frame.kind` special case. COSMO never appears here — it - * is not a `foreground:0` target. - */ +// NEAR0 plus every body row by `distanceRangeM[0]` descending — the same key body +// rows are stored by, so NEAR0 merges in with no `frame.kind` special case. COSMO +// never appears; it is not a `foreground:0` target. export function foregroundChainOrder(slabs: readonly Slab[]): readonly number[] { return slabs .filter((slab) => slab.index === NEAR0 || slab.frame.kind === 'body-m') @@ -494,33 +337,17 @@ export function foregroundChainOrder(slabs: readonly Slab[]): readonly number[] .map((slab) => slab.index); } -/** - * A row's sort key: its nearest distance, or unknown-FAR for a row that - * resolved no depth-bearing content this frame (only NEAR0 can — `null` - * `distanceRangeM`). Unknown-far, not the `[0, 0]` this used to degrade to, - * which sorts NEAREST: the pick path holds NEAR0 candidates with no sphere - * behind them (the star catalog, the Milky Way impostor) that must not claim - * the frontmost hit. - * - * The render path can't be mis-ordered by the choice. The one thing that paints - * into `foreground:0` on NEAR0 without setting the range is the Gaia field-star - * sphere — and its presence query (`nearestResolvableStar`) needs a catalog star - * within ~an AU of the camera, i.e. the camera parsecs from the Sun, where every - * solar-system body is orders of magnitude under `SUB_PIXEL_BODY_CULL_PX` and no - * body row exists to be ordered against. The two are mutually exclusive; with - * body rows present and no sphere, NEAR0 paints nothing. - */ +// Unknown resolves FAR, not nearest: the pick path holds NEAR0 candidates with no +// sphere behind them (the star catalog, the MW impostor) that must not claim the +// frontmost hit. function nearestM(slab: Slab): number { return slab.distanceRangeM?.[0] ?? Infinity; } /** - * Resolve a `slab: number` index (as named by a `FrameStep`) into the - * `SlabView` a layer's `draw` call consumes. - * - * `ctx.slabs` is indexed by array position === `Slab.index` (deriveSlabs - * builds it that way), so this is a direct array lookup rather than a - * `.find()` scan. + * Resolve a `slab: number` index into the `SlabView` a layer's `draw` consumes. + * `ctx.slabs` is indexed by array position === `Slab.index`, so this is a direct + * lookup rather than a scan. */ export function slabViewOf(ctx: ReadyFrameContext, slabIndex: number): SlabView { const slab = ctx.slabs[slabIndex]; @@ -530,11 +357,6 @@ export function slabViewOf(ctx: ReadyFrameContext, slabIndex: number): SlabView return { slab, vp: Float32Array.from(slab.vp), - // `ctx.drawCamPos` is `Readonly` (a readonly tuple); `SlabView.camPos` - // is the plain mutable `Vec3` alias. Copying the three elements into a - // fresh tuple satisfies that shape without widening the readonly-ness of - // the source — same pattern `deriveFrameContext` uses to produce - // `drawCamPos` from `cam.position` in the first place. camPos: [ctx.drawCamPos[0], ctx.drawCamPos[1], ctx.drawCamPos[2]], viewportPx: [ctx.canvasSize.width, ctx.canvasSize.height], }; diff --git a/src/services/engine/helpers/engineReady.ts b/src/services/engine/helpers/engineReady.ts index bf7a7f80f8..4217f70aad 100644 --- a/src/services/engine/helpers/engineReady.ts +++ b/src/services/engine/helpers/engineReady.ts @@ -6,7 +6,7 @@ * Pre-D.4, the codebase asked the question "did `initGpu` / `wireSlots` / * `wireInput` finish?" in five different shapes: * - * - `runFrame.ts` had a 5-way `||` chain across `state.cam`, + * - `runFrame.ts` had a 5-way `||` chain across the boot flag, * `state.gpu.galaxyPointRenderer`, `state.gpu.postProcess`, * `state.gpu.galaxyPickRenderer`, and `state.subsystems.texturedDisks` * (later consolidated by D.1's `FrameContext`, but minus @@ -117,7 +117,7 @@ import type { ReadyEngineState } from '../../../@types/engine/ReadyEngineState'; */ export function isEngineReady(state: EngineState): state is ReadyEngineState { return ( - state.cam !== null && + state.booted && state.gpu.galaxyPointRenderer !== null && state.gpu.galaxyPickRenderer !== null && // `renderTargets` owns every offscreen row (`hdr`, `volume`) the frame diff --git a/src/services/engine/helpers/liveFocusRow.ts b/src/services/engine/helpers/liveFocusRow.ts index a56b46ad02..cb3377d3f4 100644 --- a/src/services/engine/helpers/liveFocusRow.ts +++ b/src/services/engine/helpers/liveFocusRow.ts @@ -10,13 +10,11 @@ */ import type { SelectionRow } from '../../../@types/engine/SelectionRow'; +import { deriveBodyStates } from '../frame/deriveBodyStates'; import { liveBodyPosition } from '../camera/liveBodyPosition'; -export function liveFocusRow( - focusRow: SelectionRow | null, - simDays: number, -): SelectionRow | null { +export function liveFocusRow(focusRow: SelectionRow | null, simDays: number): SelectionRow | null { if (focusRow === null || focusRow.type !== 'body') return focusRow; - const positionMpc = liveBodyPosition(focusRow, simDays); + const positionMpc = liveBodyPosition(focusRow, deriveBodyStates(simDays)); return positionMpc === null ? focusRow : { ...focusRow, positionMpc }; } diff --git a/src/services/engine/helpers/liveRenderCamera.ts b/src/services/engine/helpers/liveRenderCamera.ts index 3231adcd76..81d0cb74a3 100644 --- a/src/services/engine/helpers/liveRenderCamera.ts +++ b/src/services/engine/helpers/liveRenderCamera.ts @@ -1,26 +1,22 @@ /** - * liveRenderCamera — the OrbitCamera actually drawn last frame, for debug - * tooling that runs OUTSIDE the frame loop (the `l` hotkey). - * - * `runFrame` never stores a full assembled camera on `EngineState` — only the - * orbit params it produced (`cameraRuntime.lastPose`, pivot-corrected) plus - * the projection and orientation bases it refreshes every frame. This re-runs - * the same `assembleOrbitCamera` merge `runFrame`/`deriveFrameContext` use, so - * a caller off the frame gets the identical camera, not the stale - * `state.cam` drag register (see `frameContext.ts`'s header). + * liveRenderCamera — the OrbitCamera actually drawn last frame, for debug tooling + * that runs OUTSIDE the frame loop. `runFrame` stores only the orbit params it + * drew, so this re-runs the same `assembleOrbitCamera` merge the frame path uses + * rather than re-deriving from the boot framing. */ import type { EngineState } from '../../../@types/engine/state/EngineState'; import type { OrbitCamera } from '../../../@types/camera/OrbitCamera'; import { assembleOrbitCamera } from '../camera/assembleOrbitCamera'; +import { liveWorldPose } from './liveWorldPose'; import { ORIENTATION_FRAMES } from '../../../data/orientation/orientationFrames'; export function liveRenderCamera(state: EngineState): OrbitCamera | null { - if (!state.cam) return null; + if (!state.booted) return null; return assembleOrbitCamera( - state.cameraRuntime.lastPose.current, - state.cameraRuntime.projection, + liveWorldPose(state), + state.cameraRuntime.outputs.projection, ORIENTATION_FRAMES[state.settings.orientation], - state.cameraRuntime.upBasis.current, + state.cameraRuntime.outputs.upBasis, ); } diff --git a/src/services/engine/helpers/liveWorldPose.ts b/src/services/engine/helpers/liveWorldPose.ts new file mode 100644 index 0000000000..445a7f34ab --- /dev/null +++ b/src/services/engine/helpers/liveWorldPose.ts @@ -0,0 +1,23 @@ +/** The world arm of the DISPLAYED pose (tilt projection included), the one + * on-screen resolution site. Authoring paths must NOT read it — feeding a + * projected pose back in re-creates the R12b-1 register walk; they resolve the + * register themselves. Always at `outputs.simDays`: the epoch last frame DREW. */ + +import type { BodyId } from '../../../@types/data/body/BodyId'; +import type { BodyState } from '../../../@types/scene/BodyState'; +import type { CameraPose } from '../../../@types/camera/CameraPose'; +import type { EngineState } from '../../../@types/engine/state/EngineState'; +import { deriveBodyStates } from '../frame/deriveBodyStates'; +import { resolveWorldArm } from '../camera/poseFrameConversion'; +import { ORIENTATION_FRAMES } from '../../../data/orientation/orientationFrames'; + +export function liveWorldPose(state: EngineState): CameraPose { + return resolveWorldArm( + state.cameraRuntime.outputs.displayed, + deriveBodyStates(state.cameraRuntime.outputs.simDays) as ReadonlyMap, + // The committed pose basis (the decode never mid-slerps) and the live + // up-basis — the same split `runFrame` feeds the draw path. + ORIENTATION_FRAMES[state.settings.orientation], + state.cameraRuntime.outputs.upBasis, + ); +} diff --git a/src/services/engine/helpers/logCameraState.ts b/src/services/engine/helpers/logCameraState.ts index b5e783d7c0..69ca5963f2 100644 --- a/src/services/engine/helpers/logCameraState.ts +++ b/src/services/engine/helpers/logCameraState.ts @@ -16,10 +16,12 @@ * blob exists to debug THAT feature. */ +import type { FramedCameraPose } from '../../../@types/camera/FramedCameraPose'; import type { OrbitCamera } from '../../../@types/camera/OrbitCamera'; import type { SelectionRow } from '../../../@types/engine/SelectionRow'; import type { EarthTileDebugSnapshot } from '../../../@types/scene/EarthTileDebugSnapshot'; import { pivotRadiusMpc } from '../camera/pivotRadiusMpc'; +import { bodyFixedEyeM } from '../../../utils/camera/bodyFixedEyeM'; import { distanceMpc } from '../../../utils/math/distanceMpc'; import { SCALE_UNITS } from '../../../data/scaleUnits'; @@ -29,6 +31,7 @@ export function logCameraState( focusRow: SelectionRow | null, simDays: number, earthSubCamera: EarthTileDebugSnapshot['subCamera'] = null, + framed: FramedCameraPose | null = null, ): void { if (!cam) { console.log('[engine] logCameraState: camera not ready yet'); @@ -52,7 +55,22 @@ export function logCameraState( }; } + // The stored arm, named (spec §8): a body arm's numbers are body-FIXED + // metres, so reading the Mpc rows above as the whole truth would mislead. + // Absent ⇒ absolute, the same rule untagged serialized input parses under. + const bodyArm = framed !== null && framed.frame !== 'absolute' ? framed.pose : null; + const out = { + frame: bodyArm === null ? 'absolute' : bodyArm.bodyId, + bodyArmMetres: + bodyArm === null + ? null + : { + anchorLocalM: bodyArm.anchorLocalM, + eyeRelAnchorM: bodyArm.eyeRelAnchorM, + eyeFromCentreM: Math.hypot(...bodyFixedEyeM(bodyArm)), + basisLocal: bodyArm.basisLocal, + }, target: cam.target, yaw: cam.yaw, pitch: cam.pitch, diff --git a/src/services/engine/helpers/milkyWayVisible.ts b/src/services/engine/helpers/milkyWayVisible.ts index 1008a57883..9b52d399f6 100644 --- a/src/services/engine/helpers/milkyWayVisible.ts +++ b/src/services/engine/helpers/milkyWayVisible.ts @@ -1,19 +1,10 @@ /** - * milkyWayVisible — is the Milky Way point cloud drawn for a given camera? - * THE single home of the MW visibility predicate. Two gates: the user toggle - * (or its fade-out tail: `settings.milkyWay.enabled` OR - * `fades.opacityOf({kind:'milkyWay'}) > 0`, so the cloud stays alive and - * pickable through the ~100 ms toggle ramp), AND the apparent-size fade band - * (`milkyWayFadeAlpha(camDist, fovY, viewportH) > 0`). - * - * The camera and clock come in as parameters rather than read off state - * because the draw and pick programs evaluate this SAME gate against - * DIFFERENT cameras — draw passes the frame-frozen `ctx.drawCamPos` / - * `ctx.fovYRad` / `ctx.nowMs`, pick passes its own pick-time replay. Sharing - * one gate (not a mirrored pair) means the pick answer can't drift from the - * draw answer for that camera; reading the live drag register instead would - * lag a wheel-zoom/tween between frames and let a vanished disc claim a - * click, or a visible one miss. + * milkyWayVisible — THE single home of the MW visibility predicate: the user + * toggle OR its fade-out tail (so the cloud stays pickable through the ~100 ms + * ramp), AND the apparent-size fade band. Camera and clock arrive as parameters + * because draw and pick evaluate this same gate against DIFFERENT cameras; + * reading the live pose instead would lag a wheel-zoom between frames and let a + * vanished disc claim a click. */ import type { EngineState } from '../../../@types/engine/state/EngineState'; diff --git a/src/services/engine/helpers/pickFrameContext.ts b/src/services/engine/helpers/pickFrameContext.ts index 1e19094d76..42422dfa5a 100644 --- a/src/services/engine/helpers/pickFrameContext.ts +++ b/src/services/engine/helpers/pickFrameContext.ts @@ -1,56 +1,15 @@ /** - * pickFrameContext — the pick-time camera, expressed as a value. - * - * ### What this is - * - * A click / hover resolves which galaxy sits under the cursor by drawing the - * scene into the r32uint pick texture from the SAME camera the user is looking - * at. The alternative — capturing that camera as a render-time side effect, a - * per-frame byte snapshot stashed onto engine state for the pick pass to - * replay — braids the pick camera into frame ordering: the pick pass can only - * run if a visual frame has already stashed, and "what camera does the pick - * use?" has no value you can name, only a buffer that must be populated at - * the right moment. - * - * `pickFrameContext` is a plain derivation instead: it re-derives a full - * `ReadyFrameContext` from the pose the last frame actually rendered - * (`state.cameraRuntime.lastPose.current`) and the live projection - * (`state.cameraRuntime.projection`). `lastPose.current` is the produced pose - * of the last frame (see `CameraRuntime.d.ts`), the value `runFrame` fed into - * that frame's `deriveFrameContext` — so the pick camera matches the frame on - * screen exactly, yet it is a value the pick path can ask for on demand, - * independent of whether a visual frame just ran. - * - * ### Why the pick mask, not the draw mask - * - * `deriveFrameContext`'s `visibleSourceMask` becomes `ctx.visibleSourceMask`, - * which every `drawPick` reads to decide which sources to rasterise into the - * pick texture. Pick follows INTENT, not pixels: a galaxy catalog toggled off is - * unclickable immediately, even while its draw-mask bit lingers through the - * fade-out tail (see `deriveSourceMasks`'s draw-vs-pick divergence). So this - * helper threads `deriveSourceMasks(state).pick` — not `.draw` — and the - * resulting `ctx.visibleSourceMask` means "pickable sources" to every downstream - * pick draw — the one place the pickable-source filter lives. - * - * ### Why this is side-effect-free - * - * `deriveFrameContext` is documented side-effect-free (see `frameContext.ts` — - * the module header's "the clock is advanced by `runFrame`'s produce step, not - * here" guarantee, restated at the `deriveFrameContext` docblock): it only calls - * `assembleOrbitCamera`, `computeViewProj`, and `deriveSlabs`. It does NOT tick - * the camera clock or advance any fade controller. That is precisely what makes - * it safe to call speculatively at pick time — the pick path can re-derive the - * frame camera without perturbing the animation state the visual loop owns. - * - * Returns `null` when the engine has not finished bootstrapping (the - * `FrameContext.isReady` gate is false), so the pick path can bail cleanly - * before any GPU handle is touched. + * pickFrameContext — the pick camera as a plain derivation of engine state, so a + * pick can run between frames. Returns `null` until the engine has bootstrapped + * (`FrameContext.isReady`); safe to call speculatively — `deriveFrameContext` + * advances no clock and no fade controller. */ import type { EngineState } from '../../../@types/engine/state/EngineState'; import type { ReadyFrameContext } from '../../../@types/engine/frame/ReadyFrameContext'; import { deriveFrameContext } from '../frame/frameContext'; import { deriveSourceMasks } from '../frame/deriveSourceMasks'; +import { liveWorldPose } from './liveWorldPose'; import { ORIENTATION_FRAMES } from '../../../data/orientation/orientationFrames'; export function pickFrameContext( @@ -60,31 +19,20 @@ export function pickFrameContext( const ctx = deriveFrameContext( state, canvas, - // The pose the last frame actually rendered — the same value that frame's - // `deriveFrameContext` received, so the pick camera matches the frame on - // screen. - state.cameraRuntime.lastPose.current, - state.cameraRuntime.projection, - // Pick is a demand read at rest (between frames), so the steady - // `ORIENTATION_FRAMES[orientation]` is the correct basis for BOTH halves — - // the same reasoning as `buildDemandCtx`'s `cameraPosMpc`. At rest the live - // `upBasis` a real frame would resolve equals this steady value anyway, so - // there is nothing to diverge; the pick camera decodes both position and - // screen-up through the pole the frame drew with. + liveWorldPose(state), + // The DISPLAYED pose, not the authored register: the authored register is + // untilted in-window and a pick against it misses (round-12c two-box contract). + state.cameraRuntime.outputs.displayed, + state.cameraRuntime.outputs.projection, + // A demand read at rest, where the live `upBasis` equals the steady frame. ORIENTATION_FRAMES[state.settings.orientation], ORIENTATION_FRAMES[state.settings.orientation], - // Pick mask, not draw mask: pickability follows intent (see docblock). + // Pick mask, not draw mask: pickability follows intent, not the fade-out tail. deriveSourceMasks(state).pick, - // Pick-time wall clock. No animated consumer reads it in the pick pass — - // it exists only to satisfy the shared `deriveFrameContext` contract — but - // sampling here keeps a consistent "now" for any value that does stamp it. performance.now(), - // Sim instant: the one the last frame derived its bodies at, so pickable - // body sprites are re-derived exactly where they were drawn — the time - // analogue of reading `lastPose.current` for the pose. Single-writer state - // (`runFrame` only), so an unrelated `deriveBodyStates(CONST_J2000)` between - // frames cannot repoint the epoch the pick sees. - state.cameraRuntime.lastRenderedSimDays.current, + // The instant the last frame derived its bodies at, so pickable body sprites + // are re-derived exactly where they were drawn. + state.cameraRuntime.outputs.simDays, ); return ctx.isReady ? ctx : null; } diff --git a/src/services/engine/helpers/shouldKeepTicking.ts b/src/services/engine/helpers/shouldKeepTicking.ts index 2f8f8b3686..9ef320c6f5 100644 --- a/src/services/engine/helpers/shouldKeepTicking.ts +++ b/src/services/engine/helpers/shouldKeepTicking.ts @@ -1,81 +1,12 @@ /** - * shouldKeepTicking — the render-on-demand keep-alive predicate. - * - * Render-on-demand sleeps the loop when nothing is changing, and wakes it from - * a channel mouth (input, a fade or tween start, a slot reaching ready, a - * selection/focus change, a settings write). But some work is self-sustaining: - * an in-flight tween, a thumbnail fade, an animated overlay. This predicate is - * the single authority on "must the loop schedule another frame on its own?" — - * true while any motion or async work is in flight, false when the scene is at - * rest and the loop may sleep until a channel wakes it. - * - * It deliberately takes NO information about what is pickable on screen. Frame - * liveness and hover-pickability are independent concerns: an animated overlay - * (the flow field) must keep the loop ticking even when every galaxy catalog is - * hidden and nothing is pickable. Entangling the two — letting the hover-pick - * path decide whether to keep ticking — is what froze the flow field whenever - * the cursor stopped moving or left the canvas. The signature keeps them apart. - * - * Every term except the last is read off `(state, s, nowMs)` — the signature is - * the proof the predicate depends on nothing else. The one exception is `anim`, - * an explicit bag of IN-FRAME animation votes collected by the planners runFrame - * has already run this frame: the star LOD-fade `anyNodeFading` from - * `advanceStarFades`, the Earth tile subsystem's `isAnimating()`, and the two - * label directors' runFrame votes, folded together before the call. It is - * threaded as a PARAMETER rather than read off EngineState precisely because it - * is gathered per frame at the drive sites: passing it in keeps the predicate a - * pure function of its inputs, and keeps the one wake authority here — a planner - * or subsystem computes the vote, this predicate decides, and nothing wakes the - * loop on its own behalf. New in-frame animators extend the bag, never a hidden - * state read. - * - * Predicate breakdown: - * - camera active: `selectCameraActive(s)` — the continuation predicate - * (design §4), true while a drag is held, a focus tween is in flight, or - * auto-rotate is spinning. It reads the camera-slice flags straight off the - * store `RootState`, so the keep-tick gate and the React play/pause - * affordance share one definition of 'the camera is moving'. - * - texturedDisks.hasInFlightWork(): a thumbnail fetch is racing the network - * OR a landed bitmap is in its 400 ms load-fade window. Guarded by - * isEngineReady so the subsystem is non-null before the deref. - * - fades.isAnyAnimating(): a galaxy-catalog / filament layer is ramping its - * opacity (the FadeRegistry owns every clock, filaments included). The - * caller must tick() the registry before calling this, so isAnyAnimating - * reads post-tick state. - * - structureFocus.isAwake(): the member-isolation fade (its own controller, - * not in the registry) across its 400 ms ramp. - * - flow enabled + loaded: the flow layer keeps animating while on (advect - * drifts, streamline pulses), so the loop must keep ticking. Read straight - * off its two authoritative sources — settings.flow.enabled and - * slotReady(assetSlots.flow) — rather than round-tripping through the - * renderer; slotReady IS the 'field loaded' truth (the slot dispatches - * 'ready' only after upload commits), selecting exactly the animating set - * with no renderer mirror. - * - follow approach ease: `followApproachEaseActive` — the followBody driver's - * time-based approach ease (it replaced the body-focus tween, which used to - * contribute a wake term; the ease had none). True only while followBody is - * the winner and the ease is unsaturated; goes false at saturation so steady - * follow does not pin the loop. See the helper for why steady-follow-under- - * motion is deliberately excluded. - * - `anim.earthTilesAnimating`: Earth's surface virtual texture has its - * manifest or a tile in flight, or a landed tile still ramping through its - * load fade. The manifest leg is the one that matters: it is in flight - * BEFORE the feature can engage, so runFrame reads this vote outside its - * engage gate — otherwise a camera that stops moving mid-fetch sleeps the - * loop and the tiles never appear. - * - `anim.labelsAnimating`: EITHER label director's own producers or its - * appear/disappear envelope are mid-ramp — `cosmoLabelDirector.runFrame` OR - * `foregroundLabelDirector.runFrame`'s vote, folded with plain `||` at - * the call site in `runFrame.ts` (see its comment for why the two calls - * stay separate statements), rather than either director calling - * `requestRender` itself. - * - manual clock playing: `selectIsManualPlaying(s)` — a manual sim clock that - * is advancing (not paused) moves every body every frame, so playback must - * be continuous. LIVE mode is deliberately absent: it advances at real-time - * rate, so nothing perceptible changes frame-to-frame and pinning the loop - * at 60 fps would be waste — runFrame arms a coarse idle tick for the live - * terminator instead (see its wake tail), keeping that path OUT of this - * predicate. + * shouldKeepTicking — the render-on-demand keep-alive: true while any motion + * or async work is self-sustaining, false when a channel wake is enough. A + * pure function of `(state, s, nowMs, anim)`: `anim` is the in-frame votes + * the planners already computed, passed in rather than read off state so + * nothing wakes the loop on its own behalf. Deliberately blind to what is + * pickable — coupling hover-pick to liveness froze the flow field whenever + * the cursor stopped. LIVE sim time is absent on purpose: it advances + * imperceptibly per frame, so runFrame arms a coarse idle tick for it instead. */ import type { EngineState } from '../../../@types/engine/state/EngineState'; @@ -84,36 +15,20 @@ import { selectCameraActive } from '../../../state/camera/selectors'; import { selectIsManualPlaying } from '../../../state/time/selectors'; import { isEngineReady } from './engineReady'; import { slotReady } from '../../loading/slotReady'; -import { FOCUS_TWEEN_MS } from '../camera/focusTweenDuration'; +import { isFollowDriverId } from '../../../utils/camera/isFollowDriverId'; /** - * The `followBody` driver's approach ease is a TIME-based animation (easeOutCubic - * over FOCUS_TWEEN_MS since `clock.followStartMs`) with NO camera-slice flag - * behind it — unlike a tween, which `selectCameraActive` already covers. It - * replaced the old body-focus tween, but the tween contributed a `currentTween` - * wake term and the ease contributed none. Without a wake term the loop renders - * the focus frame, sleeps, the ease saturates WHILE ASLEEP, and the next - * interaction reveals a finished (snapped) zoom. This term keeps the loop ticking - * while followBody is the winner AND the ease is still running; it goes FALSE at - * saturation so a steady follow does not pin 60 fps. - * - * `prevActiveId.current` holds THIS frame's winner (runFrame writes it before the - * keep-tick check), and `followStartMs` is maintained by `followElapsed` on every - * frame followBody wins — so both are current here. - * - * DELIBERATELY NOT a wake term: STEADY follow of a MOVING body after saturation. - * The pivot-pin only affects a RENDERED frame, so a slept steady-follow re-centres - * on the next wake. Manual playback already ticks (`selectIsManualPlaying`), and - * live-1x advances sub-perceptibly (the same coarse-idle-tick regime the - * terminator uses via runFrame's wake tail). Pinning 60 fps whenever any body is - * focused would defeat that power-saving for a drift that is imperceptible at the - * live rate — so steady follow stays out of this predicate, exactly as live time - * itself does. + * Ease with no camera-slice flag behind it: without this term the loop sleeps + * mid-ease and wakes to a snapped zoom. Reads the approach's OWN exit signal + * (the memory it saturated on), not a second copy of the ease duration — steady + * follow of a moving body is not a wake term (the pin re-centres on wake). */ -function followApproachEaseActive(state: EngineState, nowMs: number): boolean { - if (state.cameraRuntime.prevActiveId.current !== 'followBody') return false; - const start = state.cameraRuntime.clock.followStartMs; - return start !== null && nowMs - start < FOCUS_TWEEN_MS; +function followApproachEaseActive(state: EngineState): boolean { + const { register, follow } = state.cameraRuntime; + if (!isFollowDriverId(register.winner)) return false; + // A winning follow row always leaves memory (its null-guard arms are unreachable + // while `followActive` holds); a null here would park the loop mid-ease. + return follow !== null && !follow.saturated; } export function shouldKeepTicking( @@ -129,7 +44,7 @@ export function shouldKeepTicking( state.subsystems.structureFocus.isAwake(nowMs) || (state.settings.flow.enabled && slotReady(state.assetSlots.flow)) || selectIsManualPlaying(s) || - followApproachEaseActive(state, nowMs) || + followApproachEaseActive(state) || anim.starFadeAnimating || anim.earthTilesAnimating || anim.labelsAnimating diff --git a/src/services/engine/phases/startLoop.ts b/src/services/engine/phases/startLoop.ts index 13f60e6aaf..83dcf12d9e 100644 --- a/src/services/engine/phases/startLoop.ts +++ b/src/services/engine/phases/startLoop.ts @@ -34,7 +34,7 @@ * - The renderers from `initGpu` (read off `state.gpu.*`). * - The thumbnail subsystem from `wireSlots` * (via `state.subsystems.thumbnails`). - * - The orbit camera from `wireInput` (via `state.cam`). + * - The boot camera pose from `wireInput` (via `state.booted`). * * Firing `requestRender()` before any of those exist would either * crash on the first tick or render a black canvas. Putting this @@ -60,7 +60,7 @@ */ import { runFrame } from '../frame/runFrame'; -import { buildCameraDrivers } from '../camera/cameraDrivers'; +import { CAMERA_DRIVERS } from '../camera/cameraDrivers'; import { goLiveNowAction } from '../../../state/time/goLiveNowAction'; import { selectTimeState } from '../../../state/time/selectors'; import type { RunFrameDeps } from '../../../@types/engine/frame/RunFrameDeps'; @@ -99,11 +99,7 @@ export async function startLoop(state: EngineState, deps: BootstrapDeps): Promis // Forward the timing service hung off `state.gpu` by initGpu. // Always non-null; `renderFrame` gates work behind `.enabled`. timingService: state.gpu.timingService, - // Wrap the engine's camera movers as drivers once, here. The - // wrappers close over the live `state`, so the list never needs - // rebuilding — toggled settings and subsystem state are read fresh - // each frame through the closures. - drivers: buildCameraDrivers(state), + drivers: CAMERA_DRIVERS, }; // Assign the real frame body to the forward-declared `frame` diff --git a/src/services/engine/phases/wireInput.ts b/src/services/engine/phases/wireInput.ts index 9f34c1768a..4e3d1ebfec 100644 --- a/src/services/engine/phases/wireInput.ts +++ b/src/services/engine/phases/wireInput.ts @@ -1,44 +1,23 @@ /** - * wireInput — bootstrap phase that wires the pick renderer, the orbit - * camera, click + double-click handlers, and the input-bindings listener - * bag. + * wireInput — bootstrap phase that wires the pick renderer, the orbit camera, + * click + double-click handlers, and the input-bindings listener bag. * - * Runs without waiting on any galaxy catalog load: the camera framing uses pure - * constants from `cameraFraming.ts`, so the orbit camera and the loop - * can come up immediately and galaxy catalogs can fade in as they arrive. The - * `ready` status emission lives in `wireSlots` as a per-arrival - * subscriber. - * - * No settings seed runs here: every settings cluster lives in the - * engine-owned store (seeded at construction from the same - * `data/defaults.ts` values React reads through `useStore` selectors), - * so there is no startup echo to fan out. - * - * ### State writes - * - * - `state.cam`. - * - `state.gpu.galaxyPickRenderer`, `state.gpu.pickProgram`. - * - `state.subsystems.clickResolver`, `state.subsystems.inputBindings`. - * - * ### Side effects on `deps` - * - * - Mutates `deps.detachControlsRef.current` — written with the - * orbit-controls detach function. + * Runs without waiting on any galaxy catalog load: the camera framing is pure + * constants from `cameraFraming.ts`, so the loop can come up immediately. */ -import { createOrbitCamera } from '../../../utils/camera/createOrbitCamera'; import { attachOrbitControls } from '../../camera/orbitControls'; import { constructGpuHandles } from '../gpuHandles/constructGpuHandles'; import { GPU_HANDLE_ROWS } from '../gpuHandles/gpuHandleRegistry'; import { createClickResolver } from '../interaction/clickHandler'; import { createHoverPickDriver } from '../interaction/hoverPickDriver'; import { attachEngineInputs } from '../interaction/inputBindings'; -import { poseOf } from '../camera/poseOf'; -import { projectionOf } from '../camera/projectionOf'; import { computeInitialCamera, DEFAULT_FOV_Y_RAD } from '../camera/cameraFraming'; +import { seedCameraRuntime } from '../camera/seedCameraRuntime'; import { cssToTexPx } from '../helpers/cssToTexPx'; import { unixMsToJulianDays } from '../../../utils/time/unixMsToJulianDays'; import { commitCameraPose, beginDrag, cancelCameraTween } from '../../../state/camera/cameraSlice'; +import { absoluteArm } from '../../../utils/camera/absoluteArm'; import { updateSelectionSelect, updateSelectionFocus, @@ -55,21 +34,12 @@ import type { BootstrapDeps } from '../../../@types/engine/BootstrapDeps'; import type { GpuHandleConstructDeps } from '../../../@types/engine/handles/GpuHandleConstructDeps'; import type { GpuHandleRow } from '../../../@types/engine/handles/GpuHandleRow'; -/** - * Bootstrap phase 3: pick renderer + camera + orbit controls + click - * handlers + input bindings + status-ready + settings seed. - */ export async function wireInput(state: EngineState, deps: BootstrapDeps): Promise { const { canvas, home } = deps; - // The two GPU_HANDLE_ROWS rows marked `constructPhase: 'wireInput'`: they - // read `state.gpu.focusUniform`, built by `initGpu`'s walker call, so they - // wait for this phase. `ctx.context`/`ctx.format`/`ctx.hdrCapable` and - // `fontAtlases` stay getters — neither row reads them — so this bag never - // widens this phase's boot-state requirement past what the old inline code - // required. `ctx.format` has no live source at this phase (no row here - // bakes a swap-format pipeline); it throws rather than silently re-deriving - // a value that could diverge from `initGpu`'s boot format. + // `ctx.format` has no live source at this phase (no row here bakes a + // swap-format pipeline); it throws rather than silently re-deriving a value + // that could diverge from `initGpu`'s boot format. const handleDeps: GpuHandleConstructDeps = { ctx: { device: deps.phaseLocals!.device, @@ -98,20 +68,10 @@ export async function wireInput(state: EngineState, deps: BootstrapDeps): Promis state, handleDeps, ); - // `pickProgram` derives its pick-time inputs from `state`; used below only - // as the hover/click resolvers' entry point. const pickProgram = state.gpu.pickProgram!; - // ── Hover-pick driver ──────────────────────────────────────────────── - // - // `hoverPickDriver` owns the full async hover-pick path, decoupled from - // the render frame. A pointer move feeds `onPointerMove`; the driver - // coalesces moves (GPU readback latency is the natural throttle), - // fires an async pick, and dispatches the hover result to the Redux store. - // Because hover feeds only the React InfoCard text — not a visual halo — - // no `requestRender` is ever needed here. The driver hands the program a - // texture-space cursor position and nothing else; the program derives every - // other pick input live from `state`. + // Hover feeds only the React InfoCard text, not a visual halo, so the hover + // path never needs a `requestRender`; GPU readback latency is its throttle. const store = deps.cb.store; const hoverPickDriver = createHoverPickDriver({ state, @@ -120,33 +80,21 @@ export async function wireInput(state: EngineState, deps: BootstrapDeps): Promis resolveDeps: { structures: state.data.structures }, }); - // The resolver runs the whole pixel → SelectionRef boundary via - // `resolvePick`: it decodes the pick and emits an identity ref. Galaxy - // identity is purely positional (no cloud read at pick time); the - // reconciler resolves the cloud at display time. Structure hits resolve - // the pick index to the record's durable id via the structure store. + // Galaxy identity is purely positional — no cloud read at pick time; the + // reconciler resolves the cloud at display time. state.subsystems.clickResolver = createClickResolver({ pickProgram, structures: state.data.structures, }); - // ── Camera auto-framing ────────────────────────────────────────────── - // - // Boot straight into the composition's home pose — the recipe is a pure - // function of the boot sim instant (the ephemeris is analytic), so the - // camera is still built before any galaxy catalog has arrived. - // - // `simDays` is the live wall-clock instant `startLoop`'s `goLive` re-anchors - // the sim clock to a moment later (`unixMsToJulianDays(Date.now())`). Reading - // the same source here — rather than `deriveSimDays(state.time, …)`, which - // would run against the still-placeholder J2000 anchor at this phase — frames - // Earth where it will actually sit the instant the loop goes live, so there - // is no jump on the first follow frame. + // Boot straight into the composition's home pose. The live wall-clock instant + // `startLoop`'s `goLive` re-anchors the sim clock to, NOT + // `deriveSimDays(state.time, …)` — that would run against the still-placeholder + // J2000 anchor at this phase and frame the body where it isn't, giving a jump + // on the first follow frame. const simDays = unixMsToJulianDays(Date.now()); // The committed orientation basis the boot pose encodes through, so first-paint - // yaw/pitch round-trip under the same frame the render path decodes with. A - // `#orientation=` deep link is already committed by this async phase (see - // the boot-ordering note below), so this reads the URL frame when present. + // yaw/pitch round-trip under the same frame the render path decodes with. const frameBasis = ORIENTATION_FRAMES[selectOrientation(store.getState())]; const bodyId = home.focus === null ? null : home.focus.ref.id; const initialCam = computeInitialCamera({ @@ -156,72 +104,46 @@ export async function wireInput(state: EngineState, deps: BootstrapDeps): Promis frameBasis, }); - // `InitialCam` is exactly an `OrbitCameraInit` minus `aspect` (reset uses the - // live canvas ratio, not a captured one), so the camera is the framing - // snapshot plus the current aspect. - const cam = createOrbitCamera({ ...initialCam, aspect: canvas.width / canvas.height }); - state.cam = cam; + state.booted = true; - // ── Bootstrap seed ─────────────────────────────────────────────────────── - // - // Fill the cameraRuntime Resources with real values now that the initial - // OrbitCamera exists. Without this seed the first resting frame would return - // the placeholder `base` (yaw 0, distance 0.43) rather than the computed - // framing pose, causing a visible camera jump on the first frame. + // Without this seed the first resting frame returns the placeholder `base` + // (yaw 0, distance 0.43) rather than the computed framing pose — a visible + // camera jump on frame one. // - // Boot ordering vs the URL orientation frame: `watchHashSaga` holds both halves - // of the hash bridge on `sagaContextRegistered`, and `createEngine` dispatches - // that (via `setSagaContext`) SYNCHRONOUSLY, before it kicks off the async - // bootstrap IIFE this phase runs inside. So the read's `#orientation=` - // `setOrientation` is committed before this `commitCameraPose` and before the - // first produced frame — `runFrame` resolves B(t) from `settings.orientation`, - // so the first paint is framed in the URL's frame with no roll (the read snaps - // via `setOrientation`, never `requestOrientationChange`, so the frame-roll - // saga never fires on arrival). The load-bearing gap is registration-before- - // bootstrap, not construction-before-bootstrap: moving `setSagaContext` into a - // bootstrap phase, or making bootstrap synchronous with engine construction, - // would silently regress the boot frame to the default orientation. - // - // Three writes, in dependency order: - // 1. `projection` — read off the assembled camera via `projectionOf`. - // `runFrame` patches `aspect` on resize and `fovYRad` from the settings - // slider every frame thereafter. - // 2. `lastPose.current` — the initial pose so the first commit-on-edge has - // a valid previous pose to refer to. - // 3. `commitCameraPose` dispatch — makes `camera.base` in the Redux store - // authoritative before the first produced frame, so the `resting` driver - // returns the correct pose and the first frame does not jump. - state.cameraRuntime.projection = projectionOf(cam); - state.cameraRuntime.lastPose.current = poseOf(cam); - store.dispatch(commitCameraPose(poseOf(cam))); + // The URL orientation frame is already committed here because `createEngine` + // dispatches `setSagaContext` SYNCHRONOUSLY, before the async bootstrap IIFE + // this phase runs inside. Registration-before-bootstrap is the load-bearing + // gap: moving `setSagaContext` into a bootstrap phase, or making bootstrap + // synchronous with engine construction, silently regresses the boot frame to + // the default orientation. + // `target` is COPIED: `initialCam.target` is mutable and this pose outlives the seed. + const committed = absoluteArm({ + target: [initialCam.target[0], initialCam.target[1], initialCam.target[2]], + yaw: initialCam.yaw, + pitch: initialCam.pitch, + distance: initialCam.distance, + }); + state.cameraRuntime = seedCameraRuntime({ + committed, + projection: { + fovYRad: initialCam.fovYRad, + aspect: canvas.width / canvas.height, + near: initialCam.near, + far: initialCam.far, + }, + }); + store.dispatch(commitCameraPose(committed)); - // ── Home selection seed ────────────────────────────────────────────────── - // - // The seed only fires when there is no selection INTENT at all — resolved or - // still in flight. This phase runs asynchronously after the store is built, and - // a URL-hash focus with a statically-resolvable id (`body-*`, milkyWay, - // structures) lands in the store during `watchHashReadSaga`'s arrival read — - // before this line runs. An unconditional seed would clobber that deep link, - // and `watchHashWriteSaga` would then publish the seeded state — which composes - // no `focus` param at all, Earth being the omitted home target — stripping the - // link off the address bar. + // Boot IS the home state: the sim clock boots live, so Earth moves from the + // first frame and a bare pose would let the globe slide out of frame. // - // A ref-only guard (`selectSelectedRef`/`selectFocusRef` both null) is not - // enough: a galaxy/star id defers until its catalog pulse lands - // (`resolveFocusRefDeferring` parks it), so the resolved ref slot reads null - // for the whole boot window while `selection.pending.focus` already holds - // the requested id. That window is exactly where this phase runs, so a - // ref-only guard sees "empty" and seeds Earth over a deep link that is - // simply still resolving — `resolveRef` then clears the pending id - // unconditionally, destroying the in-flight request along with the seed. - // `selectHasSelectionIntent` reads the pending slots too, so a parked - // request counts as non-empty and the seed defers to it. - // - // Accepted consequence: a junk `#focus=zzz` that never resolves parks - // forever, which permanently suppresses the Earth seed for that session. - // That is inherent to honouring intent over resolved state — the seed - // cannot tell "still resolving" from "never will" — and a junk deep link is - // already a broken URL. + // The guard is INTENT, not resolved refs: a galaxy/star id from `#focus=` + // defers until its catalog pulse lands, so the resolved ref slot reads null + // for the whole boot window this phase runs in. A ref-only guard would seed + // the home body over a deep link that is merely still resolving, and + // `resolveRef` would then clear the pending id along with it. Consequence + // accepted: a junk `#focus=zzz` parks forever and suppresses the seed for + // that session. const rootState = store.getState(); if (home.focus !== null && !selectHasSelectionIntent(rootState)) { // Cinema is an app mode gating what the composition asked for — the same @@ -233,38 +155,20 @@ export async function wireInput(state: EngineState, deps: BootstrapDeps): Promis store.dispatch(updateSelectionFocus(home.focus.ref)); } - // ── Pointer / keyboard / resize listeners ──────────────────────────── - // - // Centralised in `inputBindings.ts` so every DOM listener the - // engine cares about lives in one module. Each callback below - // is the *semantic* engine action — the inputBindings module - // already converts `e.clientX/Y` to a CSS-pixel record and owns - // the requestRender wake for channel-uncovered events (see its - // module header for the contract). pointermove is wake-free: the - // hoverPickDriver owns the async pick path and dispatches to the - // store; no render frame is required for hover. + // Callbacks are the semantic engine actions: `inputBindings` already converts + // `e.clientX/Y` to CSS pixels and owns the requestRender wake for + // channel-uncovered events (see its module header for the contract). state.subsystems.inputBindings = attachEngineInputs({ canvas, - // Scheduler by reference — created eagerly in the state literal (the - // forward-declared `frame` binding handles the construction-vs-body - // chicken-and-egg). scheduler: state.subsystems.scheduler, - // Delegate to the hoverPickDriver, which coalesces moves and fires an - // async GPU pick. The driver dispatches the hover SelectionRef to the - // store; React reads it via selectors. No requestRender needed. onPointerMove: (cssPx) => { hoverPickDriver.onPointerMove(cssPx); }, - // Pointer left the canvas → clear hover state. If a point - // is selected the card stays visible (showing the pinned - // point) — selection state is unaffected. onPointerLeave: () => { store.dispatch(updateSelectionHover(null)); }, - // Clear hover on pointerdown so the card immediately reflects "nothing - // hovered" instead of lagging until the drag ends. Cancelling an in-flight - // tween on a grab is owned by `onGestureStart` (it dispatches - // `cancelCameraTween()` when a drag actually begins). + // Clear hover on pointerdown so the card reflects "nothing hovered" + // immediately instead of lagging until the drag ends. onPointerDown: () => { state.picking.pointerDown = true; store.dispatch(updateSelectionHover(null)); @@ -272,32 +176,19 @@ export async function wireInput(state: EngineState, deps: BootstrapDeps): Promis onPointerUp: () => { state.picking.pointerDown = false; }, - // Esc is an explicit dismiss: clear BOTH the select and focus ref slots - // (close the card, collapse the cluster-focus fade). `clearSelection` - // targets select + focus only — hover is not cleared, which is correct - // since the pointer hasn't moved. Self-contained at the engine level so - // it doesn't depend on the React Esc path — App.tsx also forwards Esc - // through the handle's `clearSelection()`, which dispatches the same - // action; the reducer dedupes, so a double-fire is a no-op. + // App.tsx forwards Esc through the handle's `clearSelection()` too; the + // reducer dedupes, so the double-fire is a no-op. onEscape: () => { store.dispatch(clearSelection()); }, - // resize: the next frame's resizeCanvasToDisplay() picks up - // the new dimensions and recreates the HDR target. All we - // need to do is wake the loop, which inputBindings already - // does via `scheduler.requestRender()` — so this callback is - // a no-op. + // A no-op: `inputBindings` already wakes the loop, and the next frame's + // `resizeCanvasToDisplay` picks up the new dimensions. onResize: () => {}, }); - // ── Click handling ─────────────────────────────────────────────────── - // - // Click detection is delegated to `attachOrbitControls` via the `onClick` - // option. A "click" fires only when pointerup is within 4 CSS pixels of - // pointerdown — pure drags (orbit gestures) are suppressed. - - // Shared pick body for single-click. Inline rather than module-level - // because it closes over `state` and `canvas`. + // `attachOrbitControls` owns click detection: `onClick` fires only when + // pointerup lands within 4 CSS pixels of pointerdown, so pure orbit drags are + // suppressed. const runPickAtCss = ( xCss: number, yCss: number, @@ -305,31 +196,21 @@ export async function wireInput(state: EngineState, deps: BootstrapDeps): Promis const cr = state.subsystems.clickResolver; if (!cr) return null; - // The pick program owns every other decision — the pick-time camera, - // which layers are pickable, the timing slot. It resolves to null for a - // not-ready engine or an empty scene, so no pre-pick readiness / target - // gate is needed here. A zero-catalog scene with visible rings is now - // clickable (matching the hover path, which never had that gate); an - // all-hidden scene resolves to null and clears any stale selection. + // The pick program resolves to null for a not-ready engine or an empty + // scene, so no pre-pick readiness gate is needed here. return cr.resolveClick({ pickXPx: cssToTexPx(xCss), pickYPx: cssToTexPx(yCss), }); }; - // The recognizer only emits; `drainInput` applies the frame's queue at the - // top of `runFrame`. Waking the loop is this sink's job — without a frame - // nothing would ever drain. - // - // The two gesture-start STORE edges fire here, at DOM time, not at the drain. - // `cancelCameraTween` must land before any tween the same click can start: - // `onDoubleClick` below dispatches focus synchronously and `watchFocusTweenSaga` - // reaches `put(startCameraTween)` with no intervening yield, so a cancel - // deferred to the next frame would kill the tween that double-tap-to-focus - // just started. `beginDrag` rides along to keep the pair atomic. Only the - // register seed and the camera math stay deferred — nothing between the - // pointerdown and the drain can move `lastPose.current`, which is the seed's - // only input. + // The recognizer only emits; `runFrame` replays the queue at the top of + // `runFrame`, so waking the loop is this sink's job. The two gesture-start + // STORE edges fire here at DOM time, not at the drain: `onDoubleClick` + // dispatches focus synchronously and `watchFocusTweenSaga` reaches + // `put(startCameraTween)` with no intervening yield, so a `cancelCameraTween` + // deferred to the next frame would kill the tween double-tap-to-focus just + // started. `beginDrag` rides along to keep the pair atomic. deps.detachControlsRef.current = attachOrbitControls( canvas, (event) => { @@ -342,29 +223,20 @@ export async function wireInput(state: EngineState, deps: BootstrapDeps): Promis }, { onClick: (xCss, yCss) => { - // Run a one-shot pick at the click position. No throttle guard — - // clicks are infrequent and want an immediate, synchronous-feeling - // response. const pick = runPickAtCss(xCss, yCss); if (!pick) return; - // Single-click dispatches the identity ref (null clears). The - // reconciler saga watches the slot and fills `selectionRows`. pick .then((ref) => { store.dispatch(updateSelectionSelect(ref)); }) .catch(() => { - // A failed pick readback should not crash input handling; the - // click is simply dropped and the prior selection stands. + // A failed pick readback must not crash input handling; the click is + // dropped and the prior selection stands. }); }, onDoubleClick: () => { - // Upgrade the current select ref to focus. The preceding single-click - // already wrote the ref to the store, so we read it back from the - // authoritative slot rather than running a second pick (racing - // readbacks resolve out of order). A null select ref means empty space: - // dispatch focus(null) to lift the cluster-focus fade. The camera tween - // is triggered by the watchFocusTweenSaga — not here. + // Read the select ref the preceding single-click wrote rather than + // running a second pick — racing readbacks resolve out of order. const ref = selectSelectedRef(store.getState()); store.dispatch(updateSelectionFocus(ref)); }, diff --git a/src/services/engine/subsystems/clipPlayer.ts b/src/services/engine/subsystems/clipPlayer.ts index 49e3d30f76..8f0d4e2b6d 100644 --- a/src/services/engine/subsystems/clipPlayer.ts +++ b/src/services/engine/subsystems/clipPlayer.ts @@ -1,173 +1,63 @@ /** - * clipPlayer — the side-effecting Resource that owns the clip's scene cues, - * the `clipOpacity` channel, and clip-completion lifecycle. - * - * ### Responsibility split: clipPlayer vs the clip driver - * - * The clip DRIVER (`evaluateClip` in the driver table, Task 9) is a PURE - * function: given elapsed seconds, produce a CameraPose. It has no side - * effects and no mutable state. - * - * `clipPlayer` is the IMPURE complement. Each frame it: - * 1. Reads `camera.clip` from the store (idle if null). - * 2. Fires any scene cues whose `atSec` falls in `(prevElapsed, elapsed]`. - * 3. Advances the `clipOpacity` channel (the clip-owned per-layer opacity). - * 4. Records clip completion for the two-frame deferred `clipEnded` dispatch. - * - * ### Why `clock` is injected (rides cameraClock) - * - * `clipPlayer` has no wall-clock access — it takes `nowMs` as a parameter - * and delegates elapsed-seconds computation to `clipElapsed(clock, clip, nowMs)`. - * The same `CameraClock` instance the driver table uses: reference-identity - * change detection fires once on the transition frame, keeping the clip start - * stamp synchronised between the evaluator and the cue-firer. - * - * ### Why `getEngineState` is injected - * - * `applySceneEffect` takes an `EngineState` argument (for the bridge read path - * inside `show`/`hide`). The engine state is mutable-in-place (`const state` - * in `engine.ts`); passing a lazy `() => EngineState` accessor means the cue - * sees the LIVE state at fire time, not a stale snapshot captured at - * `createClipPlayer` call time. The same lazy-closure pattern `structureFocus` - * uses for its `requestRender` dep. - * - * ### Two-frame deferred completion — the post-produce safety contract - * - * `clipPlayer.tick` is called as the FIRST step of `runFrame` (Task 12), - * BEFORE the camera produce step runs `evaluateClip`. If `clipEnded()` were - * dispatched on the SAME frame `elapsed` first reaches `durationSec`, the - * produce step that same frame would see `camera.clip === null`, the clip - * driver would be inactive, and commit-on-edge would bake the PREVIOUS frame's - * pre-saturation pose — a one-frame-stale final pose. - * - * The solution: on the frame `elapsed` first reaches `durationSec`, record - * `pendingEnd = true` (step 8) but do NOT dispatch. The clip stays active, so - * the produce step runs `evaluateClip` saturated at `durationSec` (the exact - * held final pose) and `lastPose` captures it. On the NEXT frame, step 1 - * dispatches `clipEnded()` → `camera.clip` goes null → produce's resting driver - * wins → commit-on-edge (prev='clip', clip.commitsOnEdge=true) bakes `lastPose` - * = the saturated final pose. This mirrors the tween-completion ordering - * (`runFrame.ts`: cancel this frame, commit next). - * - * NOTE: The Task 11 brief's checkbox title says "dispatches clipEnded the frame - * the clip reaches durationSec" — that wording is superseded by this contract. - * The test pins the two-frame defer explicitly. - * - * ### Looping (`ClipData.loop`) - * - * A looping clip skips the pendingEnd/clipEnded arm entirely: on completion it - * rewinds `clock.clipStartMs` (keeping the sub-second remainder past - * `durationSec` so a slow frame doesn't cost drift) and resets `prevElapsed` - * so top-of-timeline cues re-fire. `clipEnded()` then only fires via `stop()` — - * the saga's `stopClip` race arm or `takeLatest` cancellation are the only exits. + * clipPlayer — the impure complement of the pure clip driver: per tick it fires + * the scene cues whose `atSec` falls in `(prevElapsedSec, elapsedSec]`, advances + * the `clipOpacity` channel, and runs clip-completion lifecycle. It holds no + * reference to the camera runtime: the clip epoch comes in through `tick` and + * goes back out, rebased on a loop wrap. */ import { createClipOpacityChannel } from '../../animation/clipOpacityChannel'; import { compileClip } from '../animation/compileClip'; -import { clipElapsed } from '../camera/cameraClock'; +import { advanceEpoch, elapsedMs } from '../camera/cameraEpochs'; import { applySceneEffect } from '../../animation/applySceneEffect'; import { clipEnded } from '../../../state/camera/cameraSlice'; import type { ClipPlayer } from '../../../@types/engine/subsystems/ClipPlayer'; +import type { CameraEpochs } from '../../../@types/engine/camera/CameraEpochs'; import type { VisibilityLayerKey } from '../../../@types/animation/VisibilityLayerKey'; -import type { CameraClock } from '../../../@types/engine/camera/CameraClock'; import type { EngineState } from '../../../@types/engine/state/EngineState'; import type { RootState } from '../../../store/types'; import type { AppDispatch } from '../../../store/types'; import type { CompiledClip, SceneCue } from '../../../@types/animation/CompiledClip'; import type { ClipData } from '../../../@types/animation/ClipData'; -// --------------------------------------------------------------------------- -// Deps shape -// --------------------------------------------------------------------------- - export type ClipPlayerDeps = { - /** - * Injected Redux store accessor. The clipPlayer reads `camera.clip` from - * `getState()` each tick and dispatches `clipEnded()` on completion. A - * narrow stub (`{ getState, dispatch }`) is sufficient — the full AppStore - * type is satisfied by this shape at the engine wiring site. - */ + /** A narrow `{ getState, dispatch }` stub satisfies this at the wiring site. */ store: { getState(): RootState; dispatch: AppDispatch }; - - /** - * Wake the render scheduler. Called after firing a `fade` cue so the frame - * loop stays alive for the fade ramp, even if no other activity is present. - */ + /** Wakes the loop after a `fade` cue so the ramp is drawn. */ requestRender: () => void; - - /** - * The engine's shared camera clock. `clipElapsed(clock, clip, nowMs)` returns - * elapsed SECONDS for the active clip, keyed on the clip's reference identity - * — the same identity the clip driver uses. Injecting the clock instead of - * calling `performance.now()` keeps tick deterministic and testable. - */ - clock: CameraClock; - /** - * Lazy accessor for the live EngineState. `applySceneEffect` needs the current - * state (for the bridge's `syncVisibilityFades` read). A closure accessor - * rather than a snapshot ensures the cue sees the current state at fire time. + * Lazy, not a snapshot: `applySceneEffect` must see the live `EngineState` + * at fire time (the bridge read inside `show`/`hide`). */ getEngineState: () => EngineState; }; -// --------------------------------------------------------------------------- -// Compile memoisation cache -// --------------------------------------------------------------------------- - type CompileCache = { data: ClipData; compiled: CompiledClip; }; -// --------------------------------------------------------------------------- -// createClipPlayer -// --------------------------------------------------------------------------- - -/** - * Create a `ClipPlayer` Resource from its dependencies. - * - * The returned object is the sole mutable owner of: - * - `prevElapsed` — cue-firing cursor (seconds), initialised to -Infinity - * so a cue at atSec=0 fires on the clip's arrival frame. - * - `pendingEnd` — set true on the frame completion is first detected; - * cleared and `clipEnded()` dispatched on the following frame (see the - * two-frame deferred completion rationale in the module header above). - * - `compileCache` — last-seen `{ data, compiled }` pair, keyed by - * reference identity so recompilation is O(1) on steady frames. - * - `clipOpacity` — the `ClipOpacityChannel` (one FadeController per - * `VisibilityLayerKey`, lazily created on first `fadeTo`). - */ export function createClipPlayer(deps: ClipPlayerDeps): ClipPlayer { - const { store, requestRender, clock, getEngineState } = deps; + const { store, requestRender, getEngineState } = deps; - // The clipOpacity channel: one private FadeController per VisibilityLayerKey, - // lazily created on the first fadeTo. Default factor = 1 (untouched layers). const clipOpacity = createClipOpacityChannel(); - // Cue-cursor: the elapsed (in seconds) up to which cues have been fired. - // Initialised to -Infinity so a cue at atSec=0 fires on the arrival frame - // (the first tick where elapsed crosses from -Infinity to ≥0). - let prevElapsed = -Infinity; + // -Infinity so a cue at atSec=0 fires on the arrival frame. + let prevElapsedSec = -Infinity; - // Two-frame completion defer: true after the frame where elapsed first - // reaches durationSec. On the NEXT tick, clipEnded() is dispatched. + // Two-frame deferred completion: `tick` runs BEFORE the produce step, so a + // `clipEnded` on the frame elapsed first reaches `durationSec` would leave the + // clip driver inactive that same frame and commit-on-edge would bake the + // PRE-saturation pose. Instead this latches, the produce step runs saturated + // and the register captures the held final pose; the NEXT tick dispatches. let pendingEnd = false; - // Last-seen compile cache: recompile only when clip.data changes by reference. let compileCache: CompileCache | null = null; - // One-shot Promise resolver registered by `playClip` (Plan B). Fires on - // BOTH clip-end edges (natural deferred completion in tick step 1 and stop). - // Cleared immediately after firing so the slot is free for the next clip. - // Lives outside resetState intentionally — resetState clears playback - // bookkeeping; the resolver is a caller-owned Promise handle, not playback - // state, and must fire AFTER clipEnded() so the Promise settles with the - // correct store shape already in place. + // The `playClip` Promise resolver. Not cleared by `resetState`: it must fire + // AFTER `clipEnded()` lands so the awaiter sees `camera.clip === null`. let endResolver: (() => void) | null = null; - // ── helpers ────────────────────────────────────────────────────────────── - function getCompiled(data: ClipData): CompiledClip { if (compileCache === null || compileCache.data !== data) { compileCache = { data, compiled: compileClip(data) }; @@ -177,16 +67,11 @@ export function createClipPlayer(deps: ClipPlayerDeps): ClipPlayer { function resetState(): void { pendingEnd = false; - prevElapsed = -Infinity; + prevElapsedSec = -Infinity; clipOpacity.reset(); compileCache = null; - // NOTE: endResolver is deliberately NOT cleared here — fireEndResolver - // clears it after invoking it. Clearing here would race the fire call. } - // Fire the registered end-resolver exactly once, then clear the slot. - // Called right AFTER each store.dispatch(clipEnded()) to ensure the Promise - // resolves with the up-to-date store (clip === null) already in place. function fireEndResolver(): void { const cb = endResolver; endResolver = null; @@ -196,96 +81,65 @@ export function createClipPlayer(deps: ClipPlayerDeps): ClipPlayer { function fireCue(cue: SceneCue, nowMs: number): void { const { effect } = cue; if (effect.kind === 'fade') { - // The fade verb is clipPlayer's own: write the clipOpacity channel directly. - // Never route through applySceneEffect (it throws on 'fade'). + // The fade verb is the player's own (`applySceneEffect` throws on it). for (const layer of effect.layers) { - // effect.over is SECONDS; fadeTo expects durationMs (milliseconds). + // `effect.over` is SECONDS; `fadeTo` takes ms. clipOpacity.fadeTo(layer, effect.to, effect.over * 1000, nowMs); } - // Wake the render loop so the fade ramp actually gets drawn. requestRender(); } else { - // All other verbs (show / hide / scene / focus) route through the - // verb→side-effect dispatch table. applySceneEffect throws on 'fade', - // so the branch above is the complete guard. applySceneEffect(effect, { state: getEngineState(), store }); } } - // ── ClipPlayer API ─────────────────────────────────────────────────────── - - function tick(nowMs: number): void { - // Step 1 — deferred completion: if we recorded pendingEnd on a prior - // frame, dispatch clipEnded NOW (before reading the new clip state), then - // reset and return. This is the post-produce-safe exit: the produce step - // on the PRIOR frame ran evaluateClip saturated at durationSec and baked - // the final pose into lastPose. Now that clipEnded() fires, commit-on-edge - // will capture that pose on this frame's produce edge. + function tick( + clipEpoch: CameraEpochs['clip'], + nowMs: number, + ): { readonly clipEpoch: CameraEpochs['clip'] } { if (pendingEnd) { store.dispatch(clipEnded()); resetState(); - // Resolve the playClip Promise (if any) AFTER clipEnded() has landed in the - // store, so the Promise settler sees camera.clip === null immediately. fireEndResolver(); - return; + // Advanced against the POST-dispatch clip: the resolver may start the + // next clip synchronously, and the driver reads this epoch this frame. + return { clipEpoch: advanceEpoch(clipEpoch, store.getState().camera.clip, nowMs) }; } - // Step 2 — idle guard: no clip active, nothing to tick. const clip = store.getState().camera.clip; - if (clip === null) return; + const advanced = advanceEpoch(clipEpoch, clip, nowMs); + if (clip === null) return { clipEpoch: advanced }; - // Step 3 — memoised compile: recompile only when data reference changes. const compiled = getCompiled(clip.data); + const elapsedSec = elapsedMs(advanced, nowMs) / 1000; - // Step 4 — elapsed in SECONDS from the shared camera clock. - const elapsed = clipElapsed(clock, clip, nowMs); - - // Step 5 — advance the clipOpacity channel's bookkeeping. clipOpacity.tick(nowMs); - // Step 6 — fire scene cues in (prevElapsed, elapsed], ascending atSec. - // compiled.cues is already sorted ascending by atSec (compileClip sorts). + // `compiled.cues` is sorted ascending by atSec. for (const cue of compiled.cues) { - if (cue.atSec > prevElapsed && cue.atSec <= elapsed) { + if (cue.atSec > prevElapsedSec && cue.atSec <= elapsedSec) { fireCue(cue, nowMs); } } + prevElapsedSec = elapsedSec; - // Step 7 — advance the cue cursor. - prevElapsed = elapsed; - - // Step 8 — completion detection: record pendingEnd on the frame elapsed - // first reaches durationSec. DO NOT dispatch clipEnded this frame — the - // produce step must still run evaluateClip saturated at durationSec to - // bake the correct final pose. clipEnded dispatches on the NEXT tick (step 1). - if (elapsed >= compiled.durationSec) { + if (elapsedSec >= compiled.durationSec) { if (clip.data.loop) { - // Looping clip: rewind instead of ending. Keep the remainder past - // durationSec (rather than snapping to 0) so a slow frame doesn't cost - // a few ms of drift every cycle. Writing clock.clipStartMs directly - // (bypassing clipElapsed's own ref-change reset) is safe because the - // clip reference is unchanged — the next clipElapsed(clock, clip, nowMs) - // call sees clip === clock.lastClipRef and just reads the value we set. - const overshoot = elapsed - compiled.durationSec; - clock.clipStartMs = nowMs - overshoot * 1000; - // Rewind the cue cursor too, so cues at the top of the timeline (e.g. an - // atSec=0 fade) re-fire on the next pass instead of staying "already fired". - prevElapsed = -Infinity; - } else { - pendingEnd = true; + // Rebased, not snapped: the overshoot carries into the next lap so a + // slow frame costs no drift per cycle. The cursor rewinds with it so a + // top-of-timeline cue re-fires. A looping clip ends only via `stop()`. + const overshootSec = elapsedSec - compiled.durationSec; + prevElapsedSec = -Infinity; + return { clipEpoch: { ...advanced, startMs: nowMs - overshootSec * 1000 } }; } + pendingEnd = true; } + return { clipEpoch: advanced }; } function stop(): void { - // Abort the active clip immediately: dispatch clipEnded, then clean up so - // the next tick starts from a blank slate. clipOpacity.reset() snaps - // every faded layer back to factor 1. store.dispatch(clipEnded()); resetState(); - // Resolve the playClip Promise (if any) on the abort edge — the [CANCEL] - // hook on the returned Promise calls stop(), so this makes cancellation - // RESOLVE rather than reject (no try/catch needed at call sites). + // Cancellation RESOLVES the `playClip` Promise (the [CANCEL] hook calls this). fireEndResolver(); } @@ -294,24 +148,18 @@ export function createClipPlayer(deps: ClipPlayerDeps): ClipPlayer { } function destroy(): void { - // No GPU resources to free. Reset channel and bookkeeping to leave a - // clean state in case the subsystem bag is inspected after destroy. clipOpacity.reset(); compileCache = null; pendingEnd = false; - prevElapsed = -Infinity; - // Settle any in-flight playClip Promise so an awaiter unwinds on teardown - // rather than hanging forever. Unlike stop(), this doesn't dispatch clipEnded - // because destroy() intentionally leaves the store untouched. + prevElapsedSec = -Infinity; + // Settles an in-flight `playClip` so the awaiter unwinds; the store is + // deliberately left untouched. fireEndResolver(); } function registerEndResolver(onEnd: () => void): void { - // Overwrites any previously registered resolver. In normal usage only one - // playClip call is in flight per clip, so overwriting is a safety valve - // rather than an expected code path (e.g. if a prior Promise was abandoned - // without being awaited). The prior resolver is simply replaced — it was - // already unreachable by the call site that created it. + // Overwrites: one playClip is in flight per clip, the prior resolver's + // call site can no longer reach it. endResolver = onEnd; } diff --git a/src/services/engine/subsystems/inputAggregator.ts b/src/services/engine/subsystems/inputAggregator.ts index e6e37f854b..680c2a1a1d 100644 --- a/src/services/engine/subsystems/inputAggregator.ts +++ b/src/services/engine/subsystems/inputAggregator.ts @@ -21,7 +21,7 @@ import type { Vec2 } from '../../../@types/math/Vec2'; * step. Exponential rather than additive so the proportional step is the same * whether the camera sits 0.1 Mpc or 1000 Mpc out. */ -const WHEEL_ZOOM_K = 0.001; +export const WHEEL_ZOOM_K = 0.001; export function createInputAggregator(): InputAggregator { const steps: InputStep[] = []; @@ -33,14 +33,19 @@ export function createInputAggregator(): InputAggregator { let lastPx: Vec2 | null = null; let lastPinchDist = 0; - /** Extend the trailing zoom run when it has the same owner, else open one. */ - const foldZoom = (factor: number, duringGesture: boolean): void => { + /** + * Extend the trailing zoom run when it has the same owner, else open one. + * A run keeps the LAST event's cursor — the same "where the pointer ended + * up" rule the drag runs use for `endPx`. + */ + const foldZoom = (factor: number, duringGesture: boolean, cursorPx: Vec2 | null): void => { const tail = steps[steps.length - 1]; if (tail !== undefined && tail.kind === 'zoom' && tail.duringGesture === duringGesture) { tail.factor *= factor; + tail.cursorPx = cursorPx; return; } - steps.push({ kind: 'zoom', factor, duringGesture }); + steps.push({ kind: 'zoom', factor, duringGesture, cursorPx }); }; return { @@ -78,12 +83,15 @@ export function createInputAggregator(): InputAggregator { // Fingers spreading (distance grows) gives a ratio < 1 → the camera // distance shrinks → zoom in, the "stretch the world" model. if (lastPinchDist <= 0 || event.distPx <= 0) return; - foldZoom(lastPinchDist / event.distPx, true); + foldZoom(lastPinchDist / event.distPx, true, null); lastPinchDist = event.distPx; return; case 'wheel': - foldZoom(Math.exp(event.deltaY * WHEEL_ZOOM_K), event.duringGesture); + foldZoom(Math.exp(event.deltaY * WHEEL_ZOOM_K), event.duringGesture, [ + event.xPx, + event.yPx, + ]); return; } }, diff --git a/src/services/engine/wiring/buildDemandCtx.ts b/src/services/engine/wiring/buildDemandCtx.ts index d19b319b74..b57c408d9b 100644 --- a/src/services/engine/wiring/buildDemandCtx.ts +++ b/src/services/engine/wiring/buildDemandCtx.ts @@ -1,28 +1,11 @@ /** - * buildDemandCtx — snapshots the read surfaces a demand predicate may - * consult into a single `DemandCtx` (see `@types/loading/DemandCtx.d.ts` for - * the rationale behind each surface). - * - * ### Why a builder rather than passing `state` to predicates directly - * - * Predicates must be cheap to test and impossible to misuse. Handing them the - * whole `EngineState` would let a predicate reach into unrelated bags (mutate - * GPU handles, fire callbacks) and would couple every predicate test to the - * full engine shape. `DemandCtx` is a narrow read-only facade: query - * functions over the slices a load policy legitimately depends on. The builder - * is the single place that maps `state` → those queries, so the mapping - * (request-flag set, slot-state accessor) lives in one spot. - * - * ### Why built once per evaluation cycle, not memoised - * - * `reevaluateDemand` calls this once and shares the result across every row. - * The closures capture `state` by reference, so reads are always live against - * the current engine state — there's no stale snapshot. Rebuilding per row - * would allocate fresh closures per row for no benefit. + * buildDemandCtx — snapshots the read surfaces a demand predicate may consult + * into a single `DemandCtx` (per-surface rationale: `@types/loading/DemandCtx.d.ts`). */ import { slotFor } from './slotFor'; import { assembleOrbitCamera } from '../camera/assembleOrbitCamera'; +import { liveWorldPose } from '../helpers/liveWorldPose'; import { ORIENTATION_FRAMES } from '../../../data/orientation/orientationFrames'; import type { DemandCtx } from '../../../@types/loading/DemandCtx'; @@ -35,40 +18,21 @@ export function buildDemandCtx(state: EngineState): DemandCtx { return { settings: state.settings, request: (k: RequestKey) => state.requests.has(k), - // `?? 'idle'` covers the not-yet-minted slot: an absent (null/undefined) - // slot has never been asked to load, which is exactly what `idle` means. + // An absent slot has never been asked to load — exactly what `idle` means. slotState: (k: AssetKey): LoadState['kind'] => slotFor(state, k)?.state().kind ?? 'idle', - // The previous frame's produced world eye position — the one proximity read - // surface (see DemandCtx surface 4). `lastPose` is constructed + - // placeholder-seeded in `engine.ts`, so it is never null. Derived with the - // SAME `assembleOrbitCamera(pose, projection, poseBasis, upBasis)` the frame - // runs for `drawCamPos` (see frameContext.ts), so a proximity demand/release - // predicate's demand-time read agrees byte-for-byte with the draw-time camera - // — deriving it a second way here would risk the two silently diverging. - // `.position` only ever decodes through `poseBasis`, so `upBasis` is a - // don't-care here — passed the same steady value for symmetry with the draw - // path's call shape, not because this read depends on it. - // `.position` is a fresh writable tuple per call; widening it to - // `Readonly` hands predicates a read-only view without a copy. - // - // Demand reevaluation runs between frames (and at the head of `runFrame`, - // before this frame's produce step), reading the PREVIOUS frame's pose — a - // read at rest. So the steady `ORIENTATION_FRAMES[orientation]` is the correct - // basis: at rest the resolved per-frame basis equals the steady frame basis, - // and an orientation tween is a transient the proximity gate is insensitive - // to. (Task 9 owns the per-frame resolved basis on the draw path.) + // Previous frame's DISPLAYED world eye, in Mpc, derived through the SAME + // `assembleOrbitCamera` call shape the frame runs for `drawCamPos`, so a + // proximity predicate's read agrees with the draw camera. Demand reevaluation + // is a read at rest, so the steady orientation frame is the correct basis. cameraPosMpc: assembleOrbitCamera( - state.cameraRuntime.lastPose.current, - state.cameraRuntime.projection, + liveWorldPose(state), + state.cameraRuntime.outputs.projection, ORIENTATION_FRAMES[state.settings.orientation], ORIENTATION_FRAMES[state.settings.orientation], ).position, - // The instant the last frame derived its bodies at — the single-writer live - // clock position (`runFrame` writes it just before produce). The body-texture - // proximity gate derives host positions at this instant, exactly parallel to - // reading `cameraPosMpc` from the last pose, so demand-time body positions - // match the frame that drew them. - simDays: state.cameraRuntime.lastRenderedSimDays.current, + // The instant the last frame derived its bodies at, so demand-time body + // positions match the frame that drew them. + simDays: state.cameraRuntime.outputs.simDays, }; } diff --git a/src/services/engine/wiring/makeReconcileEffects.ts b/src/services/engine/wiring/makeReconcileEffects.ts index d9a6a8daf0..fdc351e4c6 100644 --- a/src/services/engine/wiring/makeReconcileEffects.ts +++ b/src/services/engine/wiring/makeReconcileEffects.ts @@ -1,35 +1,10 @@ /** - * makeReconcileEffects — factory that binds engine-side closures into the - * ReconcileEffects surface the saga context exposes to the store layer. + * makeReconcileEffects — binds engine-side closures into the `ReconcileEffects` + * surface the saga context exposes: sagas drive Intent, these are the engine + * callbacks they call afterwards, in one registration point (intent.md §5). * - * Each closure here mirrors the corresponding body that lives in the per-setter - * handles (setMilkyWayEnabled, setFlow, setBiasMode) — relocated from those - * per-setter homes into one effects factory so all reactive engine consequences - * of settings Intent share a single registration point. That is the - * intent.md §5 "effects in one home" direction: sagas drive Intent; this - * factory provides the engine callbacks sagas call after dispatching. - * - * The effects: - * requestRender — wakes the render-on-demand scheduler (mirrors every setter - * that calls state.subsystems.scheduler.requestRender()). - * syncFades — delegates to syncVisibilityFades with animate: true and the - * caller-supplied row set (mirrors setMilkyWayEnabled's and - * setFlow's syncVisibilityFades call). - * reseedFlow — reseeds the flow particle field; tolerates a null renderer - * via optional chaining (mirrors setFlow's maybeReseed call). - * bakeBias — kicks the bias-correction worker bake via fire-and-forget; - * the `void` discards the Promise, matching setBiasMode's - * intent not to await (mirrors setBiasMode's setMode call). - * logCameraState — prints the LIVE rendered pose + focus via logCameraState, - * assembled fresh (not from the stale `state.cam` drag - * register — mirrors the engine's logCameraStateFn). - * applySwapFormat — forwards straight to the `applySwapFormat` phase, which - * owns the reconfigure-then-rebuild sequence and its own - * already-live guard. - * - * `syncFades` forwards its optional `rows` straight through as `only`: a row set - * narrows the pass, `undefined` re-fades every row (the full pass a tour restore - * triggers via watchFadesSaga's mergeSnapshot arm). + * `syncFades` forwards its optional `rows` straight through as `only` — + * `undefined` re-fades every row, the full pass a tour restore triggers. */ import type { EngineState } from '../../../@types/engine/state/EngineState'; @@ -50,13 +25,14 @@ export function makeReconcileEffects( reseedFlow: () => state.gpu.flowFieldRenderer?.maybeReseed(), bakeBias: (mode) => void state.subsystems.biasCorrection.setMode(mode), logCameraState: () => { - const simDays = state.cameraRuntime.lastRenderedSimDays.current; + const simDays = state.cameraRuntime.outputs.simDays; logCameraState( liveRenderCamera(state), canvas, liveFocusRow(state.selectionRows.focus, simDays), simDays, state.subsystems.earthTiles?.getDebugSnapshot().subCamera ?? null, + state.cameraRuntime.outputs.displayed, ); }, applySwapFormat: (desired) => applySwapFormat(state, desired), diff --git a/src/state/camera/cameraSlice.ts b/src/state/camera/cameraSlice.ts index 4648d0c8ef..1ecdd9d3d9 100644 --- a/src/state/camera/cameraSlice.ts +++ b/src/state/camera/cameraSlice.ts @@ -1,86 +1,50 @@ /** - * cameraSlice — the camera's full Intent state as a single Redux Toolkit slice, - * authored with inline Immer case reducers. + * cameraSlice — the camera's Intent state as one RTK slice. * - * Camera Intent belongs in the store because everything that needs to read or - * write camera position (orbit controls, tour storyboard, auto-rotate, sagas) - * should share a single authoritative source rather than coordinating via - * callbacks or ref-passing. The slice owns three independent concerns: - * - * `base` — the committed resting orbit pose (target, yaw, pitch, - * distance). Per-frame pose is DERIVED from `base` by the - * CameraDriver table (`runCameraDrivers`) — never written directly by renderers. - * Bootstrap dispatches `commitCameraPose` once to overwrite - * the placeholder initial value with the real computed pose. - * - * `tween` — an optional timeless from→to descriptor. Null when the - * camera is at rest. The animation clock lives in the engine - * as a Resource, not here — the descriptor is wall-clock-free - * so it remains valid across serialisation and replay. - * - * `autoRotate` — the active flag plus the per-frame yaw-delta rate. Both - * live here as the single home for auto-rotate config; the - * `spinAutoRotate` pure function reads the rate from the slice - * rather than from a scattered engine constant. - * - * `dragging` — transient gesture flag set by orbit-controls on - * pointerdown/pointerup. Suppresses auto-rotate while the - * user holds a drag. - * - * Inline Immer gives structural sharing for free: mutating `camera.base` - * produces a new `base` reference (selectors over it re-run) while `tween`, - * `autoRotate`, and `dragging` keep their prior references (their selectors - * skip) — the same guarantee the old copy-on-write spreads hand-maintained, - * with none of the nesting overhead. - * - * `clip` — an optional in-flight animation clip descriptor. Null when - * no clip is active. The clip@95 driver owns the camera during - * playback; `clip.data` is the serializable authored form. A - * FRESH `{ data }` wrapper is stored on each `clipStarted` — the - * Task 8 clock keys on this reference identity to detect a new - * clip (same pattern as `tween` reference equality in tweenSaga). - * - * `frameTween` — an optional in-flight orientation-frame roll descriptor. - * Null when no frame roll is in flight. The up-basis is - * DERIVED per frame by a resolver while the slerp runs; the - * descriptor is wall-clock-free so it stays valid across - * serialisation and replay, like `tween`. + * `base` carries the committed resting pose AND the arm it lives in: the arm tag + * IS the regime, so nothing stores a separate flag. The per-frame pose is DERIVED + * from `base` by the CameraDriver table (`pickWinner`) — never written + * directly by renderers. The `tween`, `clip` and `frameTween` descriptors are + * wall-clock-free, so they stay valid across serialisation and replay. */ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { DEFAULT_AUTO_ROTATE } from '../../data/defaults'; +import { DEFAULT_CAMERA_TUNING } from '../../data/camera/cameraTuning'; +import { absoluteArm } from '../../utils/camera/absoluteArm'; +import { clampCameraTuning } from '../../utils/camera/clampCameraTuning'; import type { CameraState } from '../../@types/camera/CameraState'; +import type { CameraTuning } from '../../@types/camera/CameraTuning'; import type { CameraPose } from '../../@types/camera/CameraPose'; +import type { FramedCameraPose } from '../../@types/camera/FramedCameraPose'; import type { CameraTweenDescriptor } from '../../@types/camera/CameraTweenDescriptor'; import type { ClipData } from '../../@types/animation/ClipData'; import type { FrameTween } from '../../@types/camera/FrameTween'; import type { OrientationFrameId } from '../../@types/camera/OrientationFrameId'; -// `base` is a placeholder; bootstrap overwrites via `commitCameraPose` once -// `computeInitialCamera` has run. 0.43 mirrors `cameraFraming.INITIAL_DISTANCE_MPC`, -// the value the engine boots with, so any frame rendered before bootstrap is -// at least in the right ballpark. +// `base` is a placeholder bootstrap overwrites via `commitCameraPose`; 0.43 Mpc +// mirrors `cameraFraming.INITIAL_DISTANCE_MPC` so a pre-bootstrap frame is in the +// right ballpark. const initialState: CameraState = { - base: { target: [0, 0, 0], yaw: 0, pitch: 0, distance: 0.43 }, + base: absoluteArm({ target: [0, 0, 0], yaw: 0, pitch: 0, distance: 0.43 }), tween: null, autoRotate: { active: DEFAULT_AUTO_ROTATE, - // rate = per-frame yaw advance at an assumed 60 fps (~0.05°/frame), the - // unit `spinAutoRotate` expects. The slice is its single home; do not - // import from the engine — that would couple state→engine the wrong way. + // Per-frame yaw advance in radians at an assumed 60 fps (~0.05°/frame), the + // unit `spinAutoRotate` expects. rate: 0.000873, }, dragging: false, clip: null, frameTween: null, + tuning: DEFAULT_CAMERA_TUNING, }; const cameraSlice = createSlice({ name: 'camera', initialState, reducers: { - // ── gesture state ─────────────────────────────────────────────────────── beginDrag: (camera) => { camera.dragging = true; }, @@ -88,14 +52,17 @@ const cameraSlice = createSlice({ camera.dragging = false; }, - // ── committed resting pose ────────────────────────────────────────────── - // Called once at bootstrap (after `computeInitialCamera`) and on every - // orbit-controls pointerup to bake the user's new resting pose. - commitCameraPose: (camera, action: PayloadAction) => { + // INVARIANT (R12b-3): every committed ABSOLUTE pose is centre-looking. The + // pivot pin re-reads an absolute `target` as the pivot and re-derives the eye + // from yaw/pitch/distance one frame later, so a pose aimed anywhere else + // teleports the eye by d·2sin(τ/2) (R12-1, up to ~24,000 km). Held by + // CONSTRUCTION at three sites — the pin's stamp (projectFramePose), the gesture + // folds (replayInput), and the fold's disengage retarget — never by a bake + // here. Break any of them and the teleport re-enters through this reducer. + commitCameraPose: (camera, action: PayloadAction) => { camera.base = action.payload; }, - // ── tween lifecycle ───────────────────────────────────────────────────── startCameraTween: (camera, action: PayloadAction) => { camera.tween = action.payload; }, @@ -103,40 +70,25 @@ const cameraSlice = createSlice({ camera.tween = null; }, - // ── clip lifecycle ────────────────────────────────────────────────────── - // `clipStarted` stores a FRESH `{ data, frame }` wrapper so Task 8's clock - // saga can detect a new clip by reference inequality (`prev !== next`) - // without comparing deep descriptor equality. `data` must already be - // resolved (no `start: 'live'` sentinel) — call `resolveClipStart` at the - // dispatch site before putting this action, mirroring `focusTweenSaga`'s - // pattern of baking the tween `from` before `put(startCameraTween)`. `frame` - // is the orientation frame live at dispatch time — the driver evaluates and - // holds the clip against THIS frame for its whole run, then re-encodes into - // the current one each tick (see cameraDrivers.ts's clip row). - // - // Past-tense `clipStarted`/`clipEnded` (not `startClip`/`endClip`): these are - // the low-level lifecycle WRITES. The user-facing request action that names a - // clip to play is `startClip(id)` in `clipActions.ts` — the saga resolves it - // and dispatches `clipStarted` here. + // A FRESH `{ data, frame }` wrapper each time: the clip clock detects a new + // clip by reference inequality, not deep descriptor equality. `data` must + // already be resolved (no `start: 'live'` sentinel) — `resolveClipStart` runs + // at the dispatch site. `frame` is the orientation frame live at dispatch time; + // the driver holds the clip against THIS frame for its whole run. clipStarted: (camera, action: PayloadAction<{ data: ClipData; frame: OrientationFrameId }>) => { camera.clip = action.payload; }, - // `clipEnded` clears BOTH `clip` and `tween`. A tween planted before or - // during the clip (e.g. by a focus saga) is dormant while the clip@95 - // driver wins priority, but once the clip deactivates an un-cleared @60 - // tween would outrank `resting`@0 and snap the camera to a stale target. - // Mirroring `cancelCameraTween`, this is the teardown contract. + // Clears BOTH `clip` and `tween`: a tween planted before or during the clip is + // dormant while the clip@95 driver wins, but once the clip deactivates an + // un-cleared @60 tween outranks `resting`@0 and snaps to a stale target. clipEnded: (camera) => { camera.clip = null; camera.tween = null; }, - // ── frame-tween lifecycle ─────────────────────────────────────────────── - // The orientation-frame roll is orthogonal to `setOrientation`: the latter - // snaps the committed target frame, this starts the up-basis slerp toward - // it. Keeping them separate lets a URL-boot apply or a tour cue set the - // frame without an animation they don't want. A resolver derives the basis - // per frame while `frameTween` is non-null. + // Orthogonal to `setOrientation`: that snaps the committed target frame, this + // starts the up-basis slerp toward it — so a URL boot or a tour cue can set the + // frame without an animation. startFrameTween: (camera, action: PayloadAction) => { camera.frameTween = action.payload; }, @@ -144,12 +96,15 @@ const cameraSlice = createSlice({ camera.frameTween = null; }, - // ── auto-rotate ───────────────────────────────────────────────────────── - // Replaces the whole sub-object so both `active` and `rate` can be - // updated atomically (e.g. a settings panel that exposes a rate slider). setAutoRotate: (camera, action: PayloadAction<{ active: boolean; rate: number }>) => { camera.autoRotate = action.payload; }, + + // A WHOLE new record, never a leaf write: the panel's sliders read it back + // through a selector, and an in-place edit leaves that render stale. + setCameraTuning: (camera, action: PayloadAction>) => { + camera.tuning = clampCameraTuning(action.payload, camera.tuning); + }, }, }); @@ -160,18 +115,16 @@ export const { startCameraTween, cancelCameraTween, setAutoRotate, + setCameraTuning, clipStarted, clipEnded, startFrameTween, clearFrameTween, } = cameraSlice.actions; -// ── pure helper (not a reducer) ────────────────────────────────────────────── -// Resolution happens at the dispatch site rather than inside the reducer because -// the reducer is pure and has no access to the live camera pose. This mirrors -// `focusTweenSaga.ts` baking the tween `from` before `put(startCameraTween)` — -// the store only ever receives already-concrete values, which keeps reducers -// testable without engine context and the payload safe to serialise/replay. +// Resolution happens at the dispatch site, not in the reducer, which is pure and +// has no access to the live pose — so the store only ever receives concrete, +// serialisable values. export function resolveClipStart(data: ClipData, live: CameraPose): ClipData { const start = data.start === 'live' || data.start === undefined ? live : data.start; return { ...data, start }; diff --git a/src/state/camera/selectors.ts b/src/state/camera/selectors.ts index 555af42f68..21b7af74e8 100644 --- a/src/state/camera/selectors.ts +++ b/src/state/camera/selectors.ts @@ -1,39 +1,24 @@ /** - * Camera selectors — the single read seam for the RTK camera slice, scoped - * through `RootState`. - * - * One consolidated module: this mirrors the settings and tier slice conventions - * (one read surface per slice), so any new camera selectors land here rather - * than as parallel one-function files. - * - * `selectCameraIntent` is the base selector — it lifts the camera slice out of - * `RootState` via `cameraRoute`. Every other selector composes through it, so - * the slice route is named exactly once. Every selector is `RootState`-scoped, - * so the same function drops into BOTH the React side - * (`useAppSelector(selectCameraActive)`) and the engine side - * (`selectCameraActive(store.getState())`) unchanged. - * - * `selectCameraActive` is the camera term of the render-loop continuation - * predicate (spec §4): it returns true whenever any non-resting driver is in - * play — a drag in progress, an active tween, or auto-rotate spinning. - * `shouldKeepTicking` ORs it with the non-camera movers (thumbnails, fades, - * structure-focus, animated flow) to decide whether the loop reschedules. A new - * camera driver with its own active flag adds that flag here too, so this - * selector stays the one definition of 'the camera is moving'. - * - * `selectAutoRotate` reads the camera slice exclusively — the settings-side - * `camera.autoRotate` field and its selector have been removed. The App toggle - * dispatches `setAutoRotate({ active, rate })` directly to this slice. + * Camera selectors — the single read seam for the RTK camera slice. Every + * selector is `RootState`-scoped, so the same function serves both the React side + * (`useAppSelector`) and the engine side (`selector(store.getState())`). */ import { cameraRoute } from '../../store/constants'; import type { RootState } from '../../store/types'; import type { CameraState } from '../../@types/camera/CameraState'; -import type { CameraPose } from '../../@types/camera/CameraPose'; +import type { CameraTuning } from '../../@types/camera/CameraTuning'; +import type { FramedCameraPose } from '../../@types/camera/FramedCameraPose'; -export const selectCameraIntent = (state: RootState): CameraState => state[cameraRoute]; +const selectCameraIntent = (state: RootState): CameraState => state[cameraRoute]; -export const selectCameraBase = (state: RootState): CameraPose => selectCameraIntent(state).base; +// The FRAMED base (spec §9): world-arm readers resolve it through +// `resolveWorldArm` / `liveWorldPose` rather than assuming the absolute arm. +export const selectCameraBase = (state: RootState): FramedCameraPose => + selectCameraIntent(state).base; + +export const selectCameraTuning = (state: RootState): CameraTuning => + selectCameraIntent(state).tuning; export const selectAutoRotate = (state: RootState): boolean => selectCameraIntent(state).autoRotate.active; @@ -41,24 +26,20 @@ export const selectAutoRotate = (state: RootState): boolean => export const selectAutoRotateRate = (state: RootState): number => selectCameraIntent(state).autoRotate.rate; -// Camera term of the loop-continuation predicate (spec §4): true while any -// non-resting driver would win. `shouldKeepTicking` ORs this with the other -// movers to decide whether to reschedule the next frame. The clip term keeps -// the loop alive for the full duration of an animation clip; the frameTween -// term keeps it alive through an orientation-frame roll's up-basis slerp. +// Camera term of the loop-continuation predicate (spec §4). The auto-rotate term +// carries the same arm gate as the driver it stands for: in a body arm the flag is +// stored intent with nothing acting on it, and an ungated term would pin the loop +// at 60 fps. `dragging` is NOT gated — the surface controller is a gesture driver. export const selectCameraActive = (state: RootState): boolean => { const c = selectCameraIntent(state); return ( c.clip !== null || c.dragging || c.tween !== null || - c.autoRotate.active || + (c.autoRotate.active && c.base.frame === 'absolute') || c.frameTween !== null ); }; -// True while an animation clip is playing. Plan B/C's `suspendDuringClip` -// guard and React-side clip-aware components read this rather than -// reaching into the camera slice directly. export const selectClipActive = (state: RootState): boolean => selectCameraIntent(state).clip !== null; diff --git a/src/state/camera/watchFlyToLonLatSaga.ts b/src/state/camera/watchFlyToLonLatSaga.ts index 97d3cb62b2..9ca4159b01 100644 --- a/src/state/camera/watchFlyToLonLatSaga.ts +++ b/src/state/camera/watchFlyToLonLatSaga.ts @@ -1,18 +1,11 @@ /** * watchFlyToLonLatSaga — the effect of the Earth Tile Atlas panel's - * fly-to-coordinates instrument: resolve the pose and commit it. + * fly-to-coordinates instrument: put the requested lon/lat under the camera, + * at the altitude and heading it already has. * - * Commits INSTANTLY (`commitCameraPose`, not a tween) — a snap, not a fly — - * which composes cleanly with the follow driver the same way a resting-pose - * commit always does: `followBody` re-centres `target` on Earth's live - * position every frame regardless of what `base.target` holds, and its - * yaw/pitch ease is already saturated whenever Earth has been focused for a - * while, the common case while poking at this panel. - * - * Deliberate behavior change from the old engine-handle version: `distance` - * now comes from the RESTING pose (`camera.base`, committed at drag-end/zoom/ - * driver-deactivation) rather than the engine's live per-frame pose. The - * instrument is used while idle, where the two agree. + * Commits INSTANTLY (`commitCameraPose`, not a tween) — a snap, not a fly — and + * commits a BODY arm whatever the altitude: the intent is body-relative, so it + * authors a body arm and the frame fold reconciles an out-of-band one (spec §9). */ import { takeLatest, select, put } from 'typed-redux-saga'; @@ -24,29 +17,47 @@ import { selectTimeState } from '../time/selectors'; import { deriveSimDays } from '../../utils/time/deriveSimDays'; import { deriveBodyStates } from '../../services/engine/frame/deriveBodyStates'; import { lonLatFocusPose } from '../../utils/camera/lonLatFocusPose'; +import { bodyFixedEyeM } from '../../utils/camera/bodyFixedEyeM'; +import { eyeFrameOf } from '../../utils/camera/eyeFrameOf'; +import { resolveWorldArm, toBodyArm } from '../../services/engine/camera/poseFrameConversion'; +import { BODY_LOCAL_FRAME } from '../../data/camera/bodyLocalFrame'; import { ORIENTATION_FRAMES } from '../../data/orientation/orientationFrames'; import { SCENE_EARTH } from '../../data/bodies/sceneEarth'; +import type { BodyId } from '../../@types/data/body/BodyId'; +import type { BodyState } from '../../@types/scene/BodyState'; export function* watchFlyToLonLatSaga() { yield* takeLatest(flyToLonLat, function* (action) { const { lonDeg, latDeg } = action.payload; + const bodyId = SCENE_EARTH.id as BodyId; - const distance = (yield* select(selectCameraBase)).distance; + const base = yield* select(selectCameraBase); const frameBasis = ORIENTATION_FRAMES[yield* select(selectOrientation)]; const simDays = deriveSimDays(yield* select(selectTimeState), performance.now()); - const earthState = deriveBodyStates(simDays).get(SCENE_EARTH.id); + const bodyStates = deriveBodyStates(simDays) as ReadonlyMap; + const earthState = bodyStates.get(bodyId); if (earthState === undefined) return; + // Where the camera stands now, in Earth's fixed metres — one reading for + // both halves of "same altitude, same heading". The resolved arm's + // `distance` cannot serve: it is a sightline range in a body arm but an + // orbit radius to an arbitrary target in the absolute one. This is an idle + // instrument, so the steady frame basis serves as both bases. + const here = toBodyArm( + resolveWorldArm(base, bodyStates, frameBasis, frameBasis), + frameBasis, + frameBasis, + bodyId, + earthState, + ); + const rangeM = Math.hypot(...bodyFixedEyeM(here)) - SCENE_EARTH.radiusM; + const headingRad = eyeFrameOf(here, 1, BODY_LOCAL_FRAME.pole)?.azimuthRad ?? 0; + yield* put( - commitCameraPose( - lonLatFocusPose( - { lonDeg, latDeg }, - earthState.positionMpc, - distance, - earthState.orientation, - frameBasis, - ), - ), + commitCameraPose({ + frame: { body: bodyId }, + pose: lonLatFocusPose({ lonDeg, latDeg }, bodyId, SCENE_EARTH.radiusM, rangeM, headingRad), + }), ); }); } diff --git a/src/state/camera/watchOrientationChangeSaga.ts b/src/state/camera/watchOrientationChangeSaga.ts index fa5c385057..9ddda35189 100644 --- a/src/state/camera/watchOrientationChangeSaga.ts +++ b/src/state/camera/watchOrientationChangeSaga.ts @@ -1,13 +1,12 @@ /** * watchOrientationChangeSaga — the three effects of an orientation switch. * - * `requestOrientationChange(frame)` becomes: persist the frame - * (`setOrientation`), re-express `camera.base` into it so the eye holds still - * the instant `poseBasis` flips (`commitCameraPose` + `reencodePose`), then - * roll the up-basis toward it (`startFrameTween`). The re-encode's `from` and - * the roll's `fromQuat` deliberately read DIFFERENT bases: `from` is the - * OUTGOING REGISTRY frame (`poseBasis` never mid-slerps, so that's what - * `base`'s angles are valid in); `fromQuat` is the LIVE up-basis. Do not unify. + * `requestOrientationChange(frame)` becomes: persist the frame, re-express + * `camera.base` into it so the eye holds still the instant `poseBasis` flips, then + * roll the up-basis toward it. The re-encode's `from` and the roll's `fromQuat` + * deliberately read DIFFERENT bases: `from` is the OUTGOING REGISTRY frame + * (`poseBasis` never mid-slerps, so that is what `base`'s angles are valid in); + * `fromQuat` is the LIVE up-basis. Do not unify. */ import { takeLatest, getContext, put, select } from 'typed-redux-saga'; @@ -18,6 +17,7 @@ import { setOrientation } from '../settings/settingsSlice'; import { selectOrientation } from '../settings/selectors'; import { ORIENTATION_FRAMES } from '../../data/orientation/orientationFrames'; import { reencodePose } from '../../utils/camera/reencodePose'; +import { absoluteArm } from '../../utils/camera/absoluteArm'; import type { SagaContext } from '../../store/types'; // Frame-roll duration (~1 s, spec §8); co-located since only this saga uses it. @@ -34,9 +34,17 @@ export function* watchOrientationChangeSaga() { const base = yield* select(selectCameraBase); yield* put(setOrientation(frame)); - yield* put( - commitCameraPose(reencodePose(base, ORIENTATION_FRAMES[previous], ORIENTATION_FRAMES[frame])), - ); + // World arm only: a body arm's pose is stored in the body's own axes, so no + // (yaw, pitch) is expressed against the pole that just moved. + if (base.frame === 'absolute') { + yield* put( + commitCameraPose( + absoluteArm( + reencodePose(base.pose, ORIENTATION_FRAMES[previous], ORIENTATION_FRAMES[frame]), + ), + ), + ); + } // The re-encode above needs no camera (pure store + registry); only the // roll does. Pre-bootstrap/post-destroy, the frame and pose already landed. diff --git a/src/state/perf/installPerfHook.ts b/src/state/perf/installPerfHook.ts index b8d41513ec..2ea65d5ac4 100644 --- a/src/state/perf/installPerfHook.ts +++ b/src/state/perf/installPerfHook.ts @@ -2,79 +2,17 @@ * installPerfHook — expose `window.__skymapPerf` (the perf harness's single * seam) when the page runs in perf mode. * - * The `?perf` gate lives INSIDE the installer, not at the call site: the caller - * invokes it unconditionally (one line, no branch to forget) and the gate - * itself stays unit-testable by mocking `isPerfMode`. Outside perf mode this is - * a pure no-op — nothing is attached to `window`, no subscription is created. - * - * ### Why it takes the engine handle, and installs from `useEngine` - * - * Everything the perf hook needs lives in the redux store EXCEPT the GPU timing - * service, which is reachable only via `engine.debug.timingService` — a LIVE - * GETTER on the engine handle (the async `initGpu` IIFE swaps a no-op stub for - * the device-aware service AFTER `createEngine` returns, so a copied reference - * would point at the stub forever). So the installer takes the handle and is - * called from `useEngine`'s effect right after `handleRef.current = handle`, - * NOT from `main.tsx` like the recorder hook. `collectTimings` reads - * `engine.debug.timingService` at CALL time — always after `ready` has - * resolved, by which point the real service is wired. - * - * ### `ready` — the shared debounced predicate - * - * `ready` is `whenStablyReady(store)` from `../lifecycle/whenStablyReady`, the - * same debounce the recorder awaits; its module header explains why a - * first-true resolve would fire mid-bootstrap and why the predicate must hold - * for a stability window instead. - * - * ### `setPose` — hard-cut the camera, then wait one frame - * - * A benchmark wants a reproducible vantage with no choreography: cancel any - * in-flight tween, commit the pose wholesale, and (re)arm auto-rotate — active, - * so the render-on-demand loop stays awake for the whole sampling window - * without a manual pump. `pose.rate` overrides the default orbit speed; - * omitted, it falls back to `PERF_AUTO_ROTATE_RATE` (mirrors the camera slice's - * inline default, which the slice does NOT export). Resolving on the next - * `requestAnimationFrame` is the cheapest honest "the pose has been committed to - * a frame" signal. - * - * ### `collectTimings` — reject-if-disabled, warm up, subscribe, accumulate, unsubscribe - * - * First guard: if the live timing service is a no-op STUB (`enabled === false` - * — the adapter lacks `timestamp-query`, or neither `?gpuTimings` nor `?perf` - * is set, see `initGpu`), reject IMMEDIATELY. The stub's `subscribe` never - * emits, so without this guard the Promise would never settle and the harness - * would hang forever inside `page.evaluate` with zero diagnostic. Converting the - * hang into an eager error is the honest failure mode: a benchmark on hardware - * that can't be timed should say so, not spin. - * - * Otherwise: subscribe to the live timing service; for each emitted - * `GpuTimingFrame`, flatten its `perPassMs` map into `{ slot, ms, frame }` - * samples — `frame` is the 0-based MEASURED-frame ordinal (assigned AFTER the - * warmup discard), so every slot on the Nth measured frame carries `frame: N`. - * That tag is what lets `frameTotals` reconstruct per-frame GPU cost downstream. - * After `frames` MEASURED frames have arrived, unsubscribe and resolve with the - * flat `PerfSample[]`. Auto-rotate - * (armed by the preceding `setPose`) is an active driver holding the loop awake - * for the whole window, so no manual render pump is needed. - * - * The first `PERF_WARMUP_FRAMES` delivered frames are DISCARDED, not measured: - * GPU timestamp readback lands 1–2 frames behind the render (the staging buffer - * is double-buffered — see `GpuTimingFrame`), so right after a `setStrategy` / - * `setPose` flip the first delivered frames still describe the PRIOR state. A - * merged-strategy frame bills one slot per render-step GROUP (`hdr·NEAR0`, …); - * a perLayerTimed frame bills one slot per LAYER (`orbit-trails`, …). Without - * the warmup a stale group-key slot leaks into a per-layer sample (and vice - * versa), corrupting the floor estimate. Skipping a small fixed count is the - * cheapest honest fence — no attempt to correlate frame indices across the flip. - * - * The `window` write goes through the `PerfWindow` cast instead of a - * `declare global` `interface Window` augmentation — the house style bans - * `interface`, and the only reader is the harness's untyped `page.evaluate`. + * Takes the engine HANDLE, never a copied reference: `engine.debug.timingService` + * is a live getter that `initGpu`'s async IIFE swaps from a no-op stub to the + * device-aware service AFTER `createEngine` returns, so a copy would point at the + * stub forever. That is also why it installs from `useEngine`'s effect rather + * than `main.tsx`. Outside perf mode the whole thing is a no-op. */ import { isPerfMode } from '../../utils/url/isPerfMode'; import { whenStablyReady } from '../lifecycle/whenStablyReady'; import { cancelCameraTween, commitCameraPose, setAutoRotate } from '../camera/cameraSlice'; +import { absoluteArm } from '../../utils/camera/absoluteArm'; import { clearSelection } from '../selection/selectionSlice'; import { setRenderStrategy } from '../settings/settingsSlice'; import { requestTier } from '../tier/requestTier'; @@ -89,64 +27,54 @@ import type { PerfSample } from '../../@types/perf/PerfSample'; import type { RenderStrategy } from '../../@types/engine/frame/RenderStrategy'; import type { Tier } from '../../@types/data/Tier'; -// Default per-frame yaw advance for a "slow orbit while sampling" pose. Mirrors -// the camera slice's inline `initialState.autoRotate.rate` — the slice does not -// export it, and importing the engine here would couple state→engine the wrong -// way (the slice's own comment). A scenario's `pose.rate` overrides this. +// Per-frame yaw advance in radians; mirrors the camera slice's inline +// `initialState.autoRotate.rate`, which the slice does not export. const PERF_AUTO_ROTATE_RATE = 0.000873; -// Delivered timing frames to discard before measuring. GPU timestamp readback -// lags the render by 1–2 frames (double-buffered staging), so right after a -// strategy/pose flip the first few delivered frames still describe the PRIOR -// state — a stale group-key vs. per-layer-key slot leaking into the wrong -// strategy's samples. Three covers the worst-case readback lag with margin; -// exported so `installPerfHook.test.ts` can drive the exact warmup count. +// Delivered timing frames to discard before measuring. GPU timestamp readback lags +// the render by 1–2 frames (double-buffered staging), so right after a +// strategy/pose flip the first delivered frames still describe the PRIOR state — +// a stale group-key slot leaking into per-layer samples corrupts the floor estimate. export const PERF_WARMUP_FRAMES = 3; -// Slot/layer name → render-step groupKey, flattened once from the same walk the -// DebugPanel groups on. Handed across the `window.__skymapPerf` seam so the Node -// harness can bucket its per-layer timings into groups (for the floor estimate) -// WITHOUT importing `frameProgram` — its transitive `.wesl?static` shader -// imports only resolve under Vite, so a `tsx` process would throw on them. Safe -// to reference here: this is a Vite-built src module. Group-key rows map to -// themselves (`'hdr·NEAR0' → 'hdr·NEAR0'`), so a merged-run group slot resolves -// through the same table as its per-layer children. +// Slot/layer name → render-step groupKey, handed across the `window.__skymapPerf` +// seam so the Node harness can bucket per-layer timings WITHOUT importing +// `frameProgram` — its transitive `.wesl?static` imports only resolve under Vite, +// so a `tsx` process would throw. Group-key rows map to themselves, so a merged-run +// group slot resolves through the same table as its per-layer children. const SLOT_GROUPS: Readonly> = Object.fromEntries( TIMED_SLOT_GROUPS.flatMap((group) => group.rows.map((row) => [row.name, row.groupKey])), ); -// Hard-cut the camera to `pose` and resolve once the next frame has been -// scheduled. No tween: a benchmark wants an exact vantage, not choreography. +// Hard-cut the camera to `pose`: a benchmark wants an exact vantage, and the +// re-armed auto-rotate keeps the render-on-demand loop awake for the whole window. async function setPose(store: AppStore, pose: PerfPose): Promise { if (pose.clearFocus === true) { store.dispatch(clearSelection()); - // Let one frame elapse before committing the pose: the deactivating - // follow driver's commit-on-edge bake writes its (stale) last pose into - // `camera.base` on the next produce, and must land BEFORE the commit - // below or it would overwrite the target this pose exists to set. + // Let one frame elapse first: the deactivating follow driver's commit-on-edge + // bake writes its stale last pose into `camera.base` on the next produce, and + // must land BEFORE the commit below or it overwrites this pose's target. await new Promise((resolve) => requestAnimationFrame(() => resolve())); } store.dispatch(cancelCameraTween()); store.dispatch( - commitCameraPose({ - target: pose.target, - yaw: pose.yaw, - pitch: pose.pitch, - distance: pose.distance, - }), + commitCameraPose( + absoluteArm({ + target: pose.target, + yaw: pose.yaw, + pitch: pose.pitch, + distance: pose.distance, + }), + ), ); store.dispatch(setAutoRotate({ active: true, rate: pose.rate ?? PERF_AUTO_ROTATE_RATE })); return new Promise((resolve) => requestAnimationFrame(() => resolve())); } -// Subscribe to the live GPU timing service, discard the first -// `PERF_WARMUP_FRAMES` delivered frames (readback lag — see the module header), -// then flatten each measured frame's per-pass map into samples and resolve once -// `frames` MEASURED frames have arrived. function collectTimings(engine: EngineHandle, frames: number): Promise { // Reject rather than subscribe when timing is a no-op stub: its `subscribe` - // never emits, so subscribing here would hang the harness forever with no - // diagnostic (see the module header). Fail loud and early instead. + // never emits, so subscribing would hang the harness inside `page.evaluate` + // forever with no diagnostic. if (!engine.debug.timingService.enabled) { return Promise.reject( new Error( @@ -161,8 +89,8 @@ function collectTimings(engine: EngineHandle, frames: number): Promise { delivered += 1; if (delivered <= PERF_WARMUP_FRAMES) return; - // `measured` is the 0-based ordinal of THIS (post-warmup) frame — tag it - // onto every slot so `frameTotals` can group by frame. Incremented after. + // `frame` is the 0-based MEASURED-frame ordinal (post-warmup), the tag + // `frameTotals` reconstructs per-frame GPU cost from downstream. for (const [slot, ms] of frame.perPassMs) { samples.push({ slot, ms, frame: measured }); } @@ -175,13 +103,8 @@ function collectTimings(engine: EngineHandle, frames: number): Promise { store.dispatch(requestTier(tier)); return whenStablyReady(store); @@ -195,7 +118,6 @@ export function installPerfHook(store: AppStore, engine: EngineHandle): void { setStrategy: (s: RenderStrategy) => store.dispatch(setRenderStrategy(s)), collectTimings: (frames: number) => collectTimings(engine, frames), setTier: (tier: Tier) => setTier(store, tier), - // The store's current tier — reports carry the ACTUAL tier, not a boot default. getTier: () => selectTier(store.getState()), slotGroups: SLOT_GROUPS, }; diff --git a/src/state/selection/watchFocusTweenSaga.ts b/src/state/selection/watchFocusTweenSaga.ts index 2fd39907c8..fdea5df498 100644 --- a/src/state/selection/watchFocusTweenSaga.ts +++ b/src/state/selection/watchFocusTweenSaga.ts @@ -27,7 +27,7 @@ * `engineStatusChanged` pulse rather than dropping the tween: a deep-link * focus whose id resolves statically (a scene body, the Milky Way, a star) * fires `updateSelectionFocus` during bootstrap, before `initGpu` has built - * `state.cam`, so `cameraRuntime()` is momentarily null. Galaxy deep links + * the camera, so `cameraRuntime()` is momentarily null. Galaxy deep links * dodge this because their `updateSelectionFocus` is itself deferred on * `catalogLoaded`, which only fires after the camera exists. `takeLatest` * (not `takeEvery`) aborts a still-waiting worker if a newer focus arrives, @@ -110,7 +110,7 @@ export function* watchFocusTweenSaga() { // inside the saga worker. if (!ROW_FOCUSABLE[row.type]) return; - // A body the `followBody` driver WILL handle is followed, not tweened — the + // A body the follow rows WILL handle is followed, not tweened — the // tween compiles fixed vec3 endpoints and cannot track a body the sim clock // moves. But 'body row' is BROADER than 'followed body': famous stars are // scene bodies too (star-body presence), yet they are static, so the follow @@ -121,7 +121,7 @@ export function* watchFocusTweenSaga() { if (bodyMovesThisFrame(row)) return; // A focus that resolves during bootstrap can outrun the camera: the ref is - // known but `state.cam` (hence `cameraRuntime()`) isn't built until wireInput + // known but the camera (hence `cameraRuntime()`) isn't seeded until wireInput // runs. Defer on the engine-status pulse — the first one past bootstrap fires // after the camera exists — re-reading the live Resources each time, so the // tween lands once the camera is ready instead of being dropped. `takeLatest` diff --git a/src/state/selection/watchGoHomeSaga.ts b/src/state/selection/watchGoHomeSaga.ts index 321d6cee1d..7e6318e122 100644 --- a/src/state/selection/watchGoHomeSaga.ts +++ b/src/state/selection/watchGoHomeSaga.ts @@ -19,9 +19,10 @@ * Earth focus row here plants no competing tween. This saga's `startCameraTween` * is the sole mover. On tween end the follow driver activates, captures the * tween's end pose as its `from`, and re-seeds its distance target to the exact - * framing distance the pose already carries (`followElapsed` in cameraClock.ts - * nulls `followDistanceTarget` on every focus-row change; the driver re-seeds it - * to `bodyLikeFraming`'s distance — the same one `bodyHomePose` used). Because + * framing distance the pose already carries (the memory drops when the follow + * EPOCH's ref changes, which advances only on a frame a follow row wins; the + * driver then re-seeds its distance target to `bodyLikeFraming`'s distance — + * the same one `bodyHomePose` used). Because * the pose already sits at that distance, the tween→follow handoff is seamless: * the driver takes over a camera already at rest. * diff --git a/src/state/settings/selectors.ts b/src/state/settings/selectors.ts index 89195d9cfd..d7364d678c 100644 --- a/src/state/settings/selectors.ts +++ b/src/state/settings/selectors.ts @@ -80,7 +80,7 @@ export const selectOrientation = (state: RootState): OrientationFrameId => /** * Vertical field of view, in degrees — the "Field of view" knob. A primitive * read, so no memoization. `runFrame` converts it to radians and writes it onto - * `cameraRuntime.projection.fovYRad` once per frame. + * `cameraRuntime.outputs.projection.fovYRad` once per frame. */ export const selectFovDeg = (state: RootState): number => selectSettings(state).camera.fovDeg; diff --git a/src/utils/camera/absoluteArm.ts b/src/utils/camera/absoluteArm.ts new file mode 100644 index 0000000000..038fca324f --- /dev/null +++ b/src/utils/camera/absoluteArm.ts @@ -0,0 +1,11 @@ +/** + * The single constructor for the `frame: 'absolute'` arm — one spelling for + * every world-arm producer, one writer in `commitCameraPose` (spec §7). + */ + +import type { CameraPose } from '../../@types/camera/CameraPose'; +import type { FramedCameraPose } from '../../@types/camera/FramedCameraPose'; + +export function absoluteArm(pose: CameraPose): FramedCameraPose { + return { frame: 'absolute', pose }; +} diff --git a/src/utils/camera/anchoredDragRotation.ts b/src/utils/camera/anchoredDragRotation.ts new file mode 100644 index 0000000000..169b9340d8 --- /dev/null +++ b/src/utils/camera/anchoredDragRotation.ts @@ -0,0 +1,71 @@ +/** + * anchoredDragRotation — the 1:1 drag (spec §6a). Both cursor rays meet the + * FROZEN pick sphere and the whole pose — position and basis — rotates + * rigidly, so the grabbed point re-projects onto the current pixel exactly. + * Pole-free: no `cos(latitude)` term exists to be wrong, and dragging over the + * pole is an ordinary rotation about a near-equatorial axis. + * + * `null` ⇒ the caller degrades the gesture (north-locked orbit on a miss, + * strafe in the anchor plane at grazing incidence). + */ + +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { raySphereRoots } from '../math/raySphereRoots'; +import { quatFromAxisAngle } from '../math/quatFromAxisAngle'; +import { rotateVec3ByQuat } from '../math/rotateVec3ByQuat'; +import { poseWithBasisTurn } from './poseWithBasisTurn'; +import { cross3 } from '../math/cross3'; +import { BODY_LOCAL_FRAME } from '../../data/camera/bodyLocalFrame'; + +/** + * |ray·normal| below this is edge-on enough that the rotation satisfying the + * drag is a teleport. A hard test, never a blend — a blend would be a second + * path hiding drift. Single home; the surface controller imports it. + */ +export const MIN_INCIDENCE_COS = 0.05; + +/** `dir` must be unit — `raySphereRoots` assumes it and scales `t` by `|dir|`. */ +type PickRay = { readonly originM: Readonly; readonly dir: Readonly }; + +/** Unit direction of the near pick; `null` on a miss, a hit behind the eye, or grazing. */ +function pickDir(ray: PickRay, radiusM: number): Vec3 | null { + const roots = raySphereRoots(ray.originM, ray.dir, BODY_LOCAL_FRAME.centreM, radiusM); + // Both roots behind the eye is a hit for the quadratic but a miss for a + // gesture — taking it would grab the far side of the body. + if (roots === null || roots[0] <= 0) return null; + + const t = roots[0]; + const n: Vec3 = [ + (ray.originM[0] + ray.dir[0] * t) / radiusM, + (ray.originM[1] + ray.dir[1] * t) / radiusM, + (ray.originM[2] + ray.dir[2] * t) / radiusM, + ]; + const incidence = ray.dir[0] * n[0] + ray.dir[1] * n[1] + ray.dir[2] * n[2]; + return Math.abs(incidence) < MIN_INCIDENCE_COS ? null : n; +} + +export function anchoredDragRotation( + pose: BodyFixedPose, + prevRay: PickRay, + currRay: PickRay, + anchorRadiusM: number, +): BodyFixedPose | null { + const grabbed = pickDir(prevRay, anchorRadiusM); + const under = pickDir(currRay, anchorRadiusM); + if (grabbed === null || under === null) return null; + + // The pose rotates WITH its rays, so the rotation that puts the grabbed + // point under the cursor is the one carrying the current pick BACK onto it. + const axis = cross3(under, grabbed); + const sin = Math.hypot(axis[0], axis[1], axis[2]); + if (sin === 0) return pose; + const cos = under[0] * grabbed[0] + under[1] * grabbed[1] + under[2] * grabbed[2]; + const q = quatFromAxisAngle([axis[0] / sin, axis[1] / sin, axis[2] / sin], Math.atan2(sin, cos)); + + return { + ...poseWithBasisTurn(pose, q), + anchorLocalM: rotateVec3ByQuat(q, pose.anchorLocalM), + eyeRelAnchorM: rotateVec3ByQuat(q, pose.eyeRelAnchorM), + }; +} diff --git a/src/utils/camera/anchoredZoomStep.ts b/src/utils/camera/anchoredZoomStep.ts new file mode 100644 index 0000000000..ebba67a395 --- /dev/null +++ b/src/utils/camera/anchoredZoomStep.ts @@ -0,0 +1,69 @@ +/** + * anchoredZoomStep — one stateless zoom tick on the body arm (spec §6b): + * `eye′ = anchor + factor · (eye − anchor)`, no accumulator anywhere (FW-B). + * + * The anchor is the cursor's body-local pick in BOTH wheel directions (ruling + * #7); a miss falls back to the surface point under the eye, keeping the step + * an altitude scale. With `eye·Â ≥ |A|` — which the floor below guarantees — + * `eye′·Â = |A| + f·(eye·Â − |A|) ≥ |A|` for all `f ≥ 0`, so no tangent-plane + * overshoot guard is needed. `factor` is centre-measured, never `|eye − A|`. + */ + +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { bodyFixedEyeM } from './bodyFixedEyeM'; +import { spentZoomFactor } from './spentZoomFactor'; +import { surfaceFloorM } from './surfaceFloorM'; + +export function anchoredZoomStep( + pose: BodyFixedPose, + factor: number, + cursorAnchorM: Vec3 | null, + bodyRadiusM: number, +): BodyFixedPose { + const clampedFactor = spentZoomFactor(factor); + const { anchorLocalM } = pose; + const eyeM = bodyFixedEyeM(pose); + + // The miss fallback is the eye's own nadir footprint, not the body centre + // (user ruling §12-R4): it lies on the eye's radial, so the step scales + // ALTITUDE rather than geocentric range, which is what makes one notch out + // undo one notch in near the ground. An eye exactly at the centre has no + // radial, so the centre is the only answer there. + const eyeMagM = Math.hypot(eyeM[0], eyeM[1], eyeM[2]); + const anchorM: Vec3 = + cursorAnchorM !== null + ? cursorAnchorM + : eyeMagM === 0 + ? [0, 0, 0] + : [ + (eyeM[0] / eyeMagM) * bodyRadiusM, + (eyeM[1] / eyeMagM) * bodyRadiusM, + (eyeM[2] / eyeMagM) * bodyRadiusM, + ]; + + const steppedM: Vec3 = [ + anchorM[0] + clampedFactor * (eyeM[0] - anchorM[0]), + anchorM[1] + clampedFactor * (eyeM[1] - anchorM[1]), + anchorM[2] + clampedFactor * (eyeM[2] - anchorM[2]), + ]; + + const floorM = surfaceFloorM(bodyRadiusM); + const steppedMagM = Math.hypot(steppedM[0], steppedM[1], steppedM[2]); + const floorScale = steppedMagM < floorM ? floorM / steppedMagM : 1; + + const eyeNewM: Vec3 = [ + steppedM[0] * floorScale, + steppedM[1] * floorScale, + steppedM[2] * floorScale, + ]; + + return { + ...pose, + eyeRelAnchorM: [ + eyeNewM[0] - anchorLocalM[0], + eyeNewM[1] - anchorLocalM[1], + eyeNewM[2] - anchorLocalM[2], + ], + }; +} diff --git a/src/utils/camera/blendedEnuAt.ts b/src/utils/camera/blendedEnuAt.ts new file mode 100644 index 0000000000..468a4badac --- /dev/null +++ b/src/utils/camera/blendedEnuAt.ts @@ -0,0 +1,29 @@ +/** + * blendedEnuAt — east/north at `localUp` for the band-blended reference up: + * the horizontal-plane reading of `blendedUpDir` (ruling 10's ONE field — + * weight, blend, and hold-and-transport all live there). At `w = 1` it is the + * pure body ENU. The engaged settle and the camera debug readout both read THIS. + */ + +import type { Vec3 } from '../../@types/math/Vec3'; +import { blendedUpDir } from './blendedUpDir'; +import { cross3 } from '../math/cross3'; +import { BODY_LOCAL_FRAME } from '../../data/camera/bodyLocalFrame'; + +export function blendedEnuAt( + localUp: Readonly, + blendW: number, + sceneUpLocal: Readonly, + carryUp: Readonly | null, +): { readonly east: Vec3; readonly north: Vec3 } { + const north = blendedUpDir(localUp, BODY_LOCAL_FRAME.pole, blendW, sceneUpLocal, carryUp); + if (north !== null) return { east: cross3(north, localUp), north }; + // Degenerate with nothing to carry (a polar standpoint's vanished + // horizontals): the classic pole-ENU fallback, load-bearing for every polar + // fixture. + const eastRaw = cross3(BODY_LOCAL_FRAME.pole, localUp); + const eastLen = Math.hypot(...eastRaw); + const east: Vec3 = + eastLen > 1e-9 ? [eastRaw[0] / eastLen, eastRaw[1] / eastLen, eastRaw[2] / eastLen] : [1, 0, 0]; + return { east, north: cross3(localUp, east) }; +} diff --git a/src/utils/camera/blendedUpDir.ts b/src/utils/camera/blendedUpDir.ts new file mode 100644 index 0000000000..cfd0e8326a --- /dev/null +++ b/src/utils/camera/blendedUpDir.ts @@ -0,0 +1,48 @@ +/** + * blendedUpDir — THE reference-up field (ruling 10, one home): the normalized + * `w·pole_⊥ + (1−w)·sceneUp_⊥` in the given plane. Any continuous field between + * two fixed axes has a singular locus (topology, not construction); where the + * terms CANCEL, `carryUp` — the pose's own screen-up — stands in, so the field + * stays continuous along the path (hold-and-transport, round 7). `null` = + * degenerate with nothing to carry; each caller owns its fallback. Both arms + * read THIS, so the engaged settle and the world-arm roll cannot diverge. + */ + +import type { Vec3 } from '../../@types/math/Vec3'; +import { normalize3 } from '../math/normalize3'; + +/** + * `|blend| / (w·|pole_⊥| + (1−w)·|sceneUp_⊥|)` below this ⇒ the terms are + * cancelling and the direction is a coin flip — hold the carry instead. + * Structural, not feel-tunable: it marks where the maths stops meaning. + */ +const HOLD_CONDITIONING = 0.3; + +function planePart(v: Readonly, normal: Readonly): Vec3 { + const vert = v[0] * normal[0] + v[1] * normal[1] + v[2] * normal[2]; + return [v[0] - normal[0] * vert, v[1] - normal[1] * vert, v[2] - normal[2] * vert]; +} + +export function blendedUpDir( + planeNormal: Readonly, + poleAxis: Readonly, + blendW: number, + sceneUp: Readonly, + carryUp: Readonly | null, +): Vec3 | null { + const p = planePart(poleAxis, planeNormal); + const s = planePart(sceneUp, planeNormal); + const raw: Vec3 = [ + blendW * p[0] + (1 - blendW) * s[0], + blendW * p[1] + (1 - blendW) * s[1], + blendW * p[2] + (1 - blendW) * s[2], + ]; + const termMag = blendW * Math.hypot(...p) + (1 - blendW) * Math.hypot(...s); + if (termMag <= 1e-9) return null; + if (Math.hypot(...raw) >= HOLD_CONDITIONING * termMag) return normalize3(raw); + if (carryUp !== null) { + const carried = planePart(carryUp, planeNormal); + if (Math.hypot(...carried) > 1e-9) return normalize3(carried); + } + return null; +} diff --git a/src/utils/camera/bodyFixedEyeM.ts b/src/utils/camera/bodyFixedEyeM.ts new file mode 100644 index 0000000000..e497407f9f --- /dev/null +++ b/src/utils/camera/bodyFixedEyeM.ts @@ -0,0 +1,7 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { Vec3 } from '../../@types/math/Vec3'; + +export function bodyFixedEyeM(pose: BodyFixedPose): Vec3 { + const { anchorLocalM: a, eyeRelAnchorM: e } = pose; + return [a[0] + e[0], a[1] + e[1], a[2] + e[2]]; +} diff --git a/src/utils/camera/bodyUpWeight.ts b/src/utils/camera/bodyUpWeight.ts new file mode 100644 index 0000000000..cd553edbbc --- /dev/null +++ b/src/utils/camera/bodyUpWeight.ts @@ -0,0 +1,15 @@ +/** bodyUpWeight — BOTH arms' reference-up blend (rulings 8 + 12): 1 = body ENU + * at `tiltFullHR`, 0 = scene up at `tiltZeroHR`, and display tilt is + * `remembered × this` — so tilt reaching 0 IS the blend reaching scene up. */ + +import type { CameraTuning } from '../../@types/camera/CameraTuning'; +import { smoothstep } from '../math/smoothstep'; + +export function bodyUpWeight(hOverR: number, tuning: CameraTuning): number { + // edge0 > edge1 is deliberate: the weight opens as h/R FALLS; max() guards log(0). + const { tiltFullHR, tiltZeroHR } = tuning; + if (tuning.blendSpace === 'log') { + return smoothstep(Math.log(tiltZeroHR), Math.log(tiltFullHR), Math.log(Math.max(hOverR, 1e-9))); + } + return smoothstep(tiltZeroHR, tiltFullHR, hOverR); +} diff --git a/src/utils/camera/cameraDebugSnapshotOf.ts b/src/utils/camera/cameraDebugSnapshotOf.ts new file mode 100644 index 0000000000..3ce9e1220e --- /dev/null +++ b/src/utils/camera/cameraDebugSnapshotOf.ts @@ -0,0 +1,112 @@ +/** + * cameraDebugSnapshotOf — pure projection for the DebugPanel's "Camera" section. + * Takes the primitives `runFrame`'s fold already resolved (never recomputes the + * regime) and gets the orientation DOFs from `cameraDofAnglesOf`, the same home + * `runFrame` feeds the per-frame delta record from — so a Δ is always a Δ of the + * number on the row. The epoch-mismatch floor is in `liveSimDays`'s own + * currency: `deriveSimDays` is affine in `nowMs` for a fixed `time`, so its + * slope over two seconds of poll jitter is the floor (I4). + */ + +import type { BodyId } from '../../@types/data/body/BodyId'; +import type { BodyState } from '../../@types/scene/BodyState'; +import type { CameraDebugSnapshot } from '../../@types/camera/CameraDebugSnapshot'; +import type { CameraPose } from '../../@types/camera/CameraPose'; +import type { CameraTuning } from '../../@types/camera/CameraTuning'; +import type { FramedCameraPose } from '../../@types/camera/FramedCameraPose'; +import type { Mat3 } from '../../@types/math/Mat3'; +import type { OrientDeltas } from '../../@types/camera/OrientDeltas'; +import type { PoseFrame } from '../../@types/camera/PoseFrame'; +import type { SurfaceGesture } from '../../@types/camera/SurfaceGesture'; +import type { TimeState } from '../../@types/time/TimeState'; +import { SCENE_BODIES } from '../../data/bodies/sceneBodies'; +import { deriveSimDays } from '../time/deriveSimDays'; +import { cameraDofAnglesOf } from './cameraDofAnglesOf'; +import { bodyUpWeight } from './bodyUpWeight'; + +const EPOCH_DELTA_TOLERANCE_MS = 2_000; + +function sameFrame(a: PoseFrame, b: PoseFrame): boolean { + if (a === 'absolute' || b === 'absolute') return a === b; + return a.body === b.body; +} + +export function cameraDebugSnapshotOf(input: { + readonly storedFrame: PoseFrame; + readonly renderedPose: FramedCameraPose; + readonly worldPose: CameraPose; + readonly poseBasis: Readonly; + readonly upBasis: Readonly; + readonly orientationFrame: string; + readonly bodyStates: ReadonlyMap; + readonly lastRenderedSimDays: number; + readonly liveSimDays: number; + readonly time: TimeState; + readonly activeDriverId: string; + readonly gesture: SurfaceGesture | 'down' | null; + readonly rememberedTiltRad: number; + /** Input only: the panel reads the live value through the selector, not off this snapshot. */ + readonly tuning: CameraTuning; + /** `readOrientDeltas()` — measured in the frame loop, never re-derived here. */ + readonly deltas: OrientDeltas; +}): CameraDebugSnapshot { + const { + storedFrame, + renderedPose, + worldPose, + poseBasis, + upBasis, + orientationFrame, + bodyStates, + lastRenderedSimDays, + liveSimDays, + time, + activeDriverId, + gesture, + rememberedTiltRad, + tuning, + deltas, + } = input; + const renderedFrame = renderedPose.frame; + const dofs = cameraDofAnglesOf({ + storedFrame, + worldPose, + poseBasis, + upBasis, + bodyStates, + rememberedTiltRad, + tuning, + }); + const { bodyId, hOverR: hr } = dofs; + const radiusM = + bodyId !== null ? SCENE_BODIES.find((row) => row.id === bodyId)?.radiusM : undefined; + + const engagedPose = renderedFrame !== 'absolute' ? renderedPose.pose : null; + const epochDeltaDays = liveSimDays - lastRenderedSimDays; + const epochDeltaEpsDays = Math.abs( + deriveSimDays(time, EPOCH_DELTA_TOLERANCE_MS) - deriveSimDays(time, 0), + ); + + return { + storedFrame, + renderedFrame, + armMismatch: !sameFrame(storedFrame, renderedFrame), + hOverR: hr, + altitudeM: hr !== null && radiusM !== undefined ? hr * radiusM : null, + distanceMpc: worldPose.distance, + orientationFrame, + bandUpWeight: hr !== null ? bodyUpWeight(hr, tuning) : null, + rememberedTiltRad, + dofs, + deltas, + lastRenderedSimDays, + liveSimDays, + epochDeltaDays, + epochMismatch: Math.abs(epochDeltaDays) > epochDeltaEpsDays, + anchorLocalM: engagedPose !== null ? [...engagedPose.anchorLocalM] : null, + eyeRelAnchorMagM: engagedPose !== null ? Math.hypot(...engagedPose.eyeRelAnchorM) : null, + activeDriverId, + gestureMode: gesture === null ? null : gesture === 'down' ? 'down (unlatched)' : gesture.mode, + gestureCursorHit: gesture === null || gesture === 'down' ? null : gesture.anchorLocalM !== null, + }; +} diff --git a/src/utils/camera/cameraDofAnglesOf.ts b/src/utils/camera/cameraDofAnglesOf.ts new file mode 100644 index 0000000000..935a736094 --- /dev/null +++ b/src/utils/camera/cameraDofAnglesOf.ts @@ -0,0 +1,128 @@ +/** + * cameraDofAnglesOf — heading / tilt / roll as current-target-residual rows, + * derived through the SAME helpers the live settle uses so the readout cannot + * drift from the mechanism: heading in the band-blended reference the engaged + * settle converges against (its target is 0 — north), tilt against ruling 12's + * `remembered × w(h/R)` mapping, roll against the band's ride target. Targets + * are field properties and stay derived while `tuning.northUp` is off: a target + * moving under a still pose is the reference-frame bug. ONE derivation home — + * the 4 Hz debug snapshot and `runFrame`'s delta record both read THIS. + */ + +import type { BodyId } from '../../@types/data/body/BodyId'; +import type { BodyState } from '../../@types/scene/BodyState'; +import type { CameraDofAngles } from '../../@types/camera/CameraDofAngles'; +import type { CameraDofRow } from '../../@types/camera/CameraDofRow'; +import type { CameraPose } from '../../@types/camera/CameraPose'; +import type { CameraTuning } from '../../@types/camera/CameraTuning'; +import type { Mat3 } from '../../@types/math/Mat3'; +import type { PoseFrame } from '../../@types/camera/PoseFrame'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { SCENE_BODIES } from '../../data/bodies/sceneBodies'; +import { hOverR } from '../../services/engine/camera/hOverR'; +import { nearestBodyHR } from '../../services/engine/camera/nearestBodyHR'; +import { bandRollTarget } from '../../services/engine/camera/frameAlignedRoll'; +import { bodyRelativePose } from '../../services/engine/camera/bodyRelativePose'; +import { blendedEnuAt } from './blendedEnuAt'; +import { bodyUpWeight } from './bodyUpWeight'; +import { eyeMpcOf } from './eyeMpcOf'; +import { frameUp } from './frameUp'; +import { imagePlaneBasis } from './imagePlaneBasis'; +import { mappedTiltRad } from './mappedTiltRad'; +import { refAzimuthOf } from './refAzimuthOf'; +import { tiltFromNadirRad } from './tiltFromNadirRad'; +import { mat3FromColumns } from '../math/mat3FromColumns'; +import { normalize3 } from '../math/normalize3'; +import { rotateVec3ByTightMat3T } from '../math/rotateVec3ByTightMat3T'; +import { wrapRad } from '../math/wrapRad'; + +const ABSENT: CameraDofRow = { currentRad: null, targetRad: null, residualRad: null }; + +function rowOf(currentRad: number | null, targetRad: number | null): CameraDofRow { + if (currentRad === null || targetRad === null) + return { currentRad, targetRad, residualRad: null }; + return { currentRad, targetRad, residualRad: wrapRad(currentRad - targetRad) }; +} + +export function cameraDofAnglesOf(input: { + readonly storedFrame: PoseFrame; + readonly worldPose: CameraPose; + readonly poseBasis: Readonly; + readonly upBasis: Readonly; + readonly bodyStates: ReadonlyMap; + readonly rememberedTiltRad: number; + readonly tuning: CameraTuning; +}): CameraDofAngles { + const { storedFrame, worldPose, poseBasis, upBasis, bodyStates, rememberedTiltRad, tuning } = + input; + const eyeMpc = eyeMpcOf(worldPose, poseBasis); + + // Engaged body wins outright (spec's own regime predicate: `storedFrame` IS + // the regime); the roster-wide nearest is only a stand-in for the "where's + // the hysteresis band?" question while flying free in the absolute arm. + let bodyId: BodyId | null = storedFrame !== 'absolute' ? storedFrame.body : null; + let hr: number | null = null; + if (bodyId !== null) { + const engaged = bodyStates.get(bodyId); + const body = SCENE_BODIES.find((row) => row.id === bodyId); + if (engaged !== undefined && body !== undefined) hr = hOverR(eyeMpc, engaged, body.radiusM); + } else { + const nearest = nearestBodyHR(eyeMpc, bodyStates); + if (nearest !== null) { + bodyId = nearest.bodyId; + hr = nearest.hr; + } + } + + const rollRad = worldPose.roll ?? 0; + const forwardRaw: Vec3 = [ + worldPose.target[0] - eyeMpc[0], + worldPose.target[1] - eyeMpc[1], + worldPose.target[2] - eyeMpc[2], + ]; + const degenerate = Math.hypot(...forwardRaw) === 0; + const bodyState = bodyId !== null ? bodyStates.get(bodyId) : undefined; + + let heading = ABSENT; + let tilt = ABSENT; + if (!degenerate && bodyState !== undefined) { + const forward = normalize3(forwardRaw); + const upRef = frameUp(upBasis); + const { right, up } = imagePlaneBasis(forward, rollRad, upRef); + const { eyeRelBodyM, basisM } = bodyRelativePose({ + camPosMpc: eyeMpc, + camBasisWorld: mat3FromColumns(right, up, forward), + bodyState, + }); + const localUp = normalize3(eyeRelBodyM); + // A pole-frame heading here showed non-zero for a camera converged inside + // the window, misleading the capture: measure in the SAME band-blended + // reference the engaged settle converges against, with the pose's own up as + // the carry, exactly as `eyeFrameOf` passes it. + const sceneUpLocalBody = rotateVec3ByTightMat3T(upRef, bodyState.orientation); + const forwardLocal: Vec3 = [basisM[6], basisM[7], basisM[8]]; + const upLocal: Vec3 = [basisM[3], basisM[4], basisM[5]]; + const { east, north } = blendedEnuAt( + localUp, + hr !== null ? bodyUpWeight(hr, tuning) : 1, + sceneUpLocalBody, + upLocal, + ); + heading = rowOf(refAzimuthOf(localUp, forwardLocal, upLocal, east, north), 0); + tilt = rowOf( + tiltFromNadirRad(forwardLocal, eyeRelBodyM), + hr !== null ? mappedTiltRad(rememberedTiltRad, hr, tuning) : null, + ); + } + + return { + bodyId, + hOverR: hr, + heading, + tilt, + roll: rowOf( + rollRad, + degenerate ? null : bandRollTarget(worldPose, bodyStates, poseBasis, upBasis, tuning), + ), + }; +} diff --git a/src/utils/camera/canonicalBasisAt.ts b/src/utils/camera/canonicalBasisAt.ts new file mode 100644 index 0000000000..fd3d1996f4 --- /dev/null +++ b/src/utils/camera/canonicalBasisAt.ts @@ -0,0 +1,40 @@ +import type { EyeFrame } from '../../@types/camera/EyeFrame'; +import type { Mat3 } from '../../@types/math/Mat3'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { cross3 } from '../math/cross3'; + +/** The roll-free basis at `frame`'s standpoint with this azimuth and tilt. */ +export function canonicalBasisAt(frame: EyeFrame, azimuthRad: number, tiltRad: number): Mat3 { + const { localUp, east, north } = frame; + const ch = Math.cos(azimuthRad); + const sh = Math.sin(azimuthRad); + const ct = Math.cos(tiltRad); + const st = Math.sin(tiltRad); + const horiz: Vec3 = [ + north[0] * ch + east[0] * sh, + north[1] * ch + east[1] * sh, + north[2] * ch + east[2] * sh, + ]; + const forward: Vec3 = [ + horiz[0] * st - localUp[0] * ct, + horiz[1] * st - localUp[1] * ct, + horiz[2] * st - localUp[2] * ct, + ]; + const up: Vec3 = [ + horiz[0] * ct + localUp[0] * st, + horiz[1] * ct + localUp[1] * st, + horiz[2] * ct + localUp[2] * st, + ]; + const right = cross3(forward, up); + return [ + right[0], + right[1], + right[2], + up[0], + up[1], + up[2], + forward[0], + forward[1], + forward[2], + ] as Mat3; +} diff --git a/src/utils/camera/cappedRotationToward.ts b/src/utils/camera/cappedRotationToward.ts new file mode 100644 index 0000000000..45182ab7e1 --- /dev/null +++ b/src/utils/camera/cappedRotationToward.ts @@ -0,0 +1,63 @@ +/** + * cappedRotationToward — the quaternion turning basis `from` toward basis `to`, + * with its angle clamped to `capRad`; `null` when they already agree (callers + * keep the pose by reference, so a no-op write stays bit-identical). + * + * The rotation between two orthonormal bases is intrinsically shortest-path, + * which is why the level/transport settles work on bases rather than on + * wrapped angle differences. Bases are column-major tight `Mat3`s + * (right | up | forward), the `BodyFixedPose.basisLocal` layout. + */ + +import type { Mat3 } from '../../@types/math/Mat3'; +import type { Vec4 } from '../../@types/math/Vec4'; +import { quatFromAxisAngle } from '../math/quatFromAxisAngle'; + +/** Below this angle the bases agree to float noise and no correction is due. */ +const AGREE_RAD = 1e-12; + +export function cappedRotationToward( + from: Readonly, + to: Readonly, + capRad: number, +): Vec4 | null { + // R = to · fromᵀ; cell (r, c) of a tight Mat3 lives at [c*3 + r]. + const r: number[] = new Array(9); + for (let c = 0; c < 3; c += 1) { + for (let row = 0; row < 3; row += 1) { + r[c * 3 + row] = + to[0 * 3 + row]! * from[0 * 3 + c]! + + to[1 * 3 + row]! * from[1 * 3 + c]! + + to[2 * 3 + row]! * from[2 * 3 + c]!; + } + } + const vx = r[5]! - r[7]!; + const vy = r[6]! - r[2]!; + const vz = r[1]! - r[3]!; + const sin2 = Math.hypot(vx, vy, vz); // 2·sinθ + const cos2 = r[0]! + r[4]! + r[8]! - 1; // 2·cosθ + const angleRad = Math.atan2(sin2, cos2); + if (angleRad < AGREE_RAD) return null; + + let axis: [number, number, number]; + if (sin2 > 1e-9) { + axis = [vx / sin2, vy / sin2, vz / sin2]; + } else { + // θ ≈ π: the skew part vanishes and R = 2·aaᵀ − I, so the axis is read off + // the largest diagonal instead (signs from the symmetric off-diagonals). + const dx = (r[0]! + 1) / 2; + const dy = (r[4]! + 1) / 2; + const dz = (r[8]! + 1) / 2; + if (dx >= dy && dx >= dz) { + const ax = Math.sqrt(Math.max(0, dx)); + axis = [ax, r[3]! / (2 * ax), r[6]! / (2 * ax)]; + } else if (dy >= dz) { + const ay = Math.sqrt(Math.max(0, dy)); + axis = [r[3]! / (2 * ay), ay, r[7]! / (2 * ay)]; + } else { + const az = Math.sqrt(Math.max(0, dz)); + axis = [r[6]! / (2 * az), r[7]! / (2 * az), az]; + } + } + return quatFromAxisAngle(axis, Math.min(angleRad, capRad)); +} diff --git a/src/utils/camera/clampCameraTuning.ts b/src/utils/camera/clampCameraTuning.ts new file mode 100644 index 0000000000..b74b64af27 --- /dev/null +++ b/src/utils/camera/clampCameraTuning.ts @@ -0,0 +1,59 @@ +/** + * The ONE producer of a legal `CameraTuning`: range-clamp each patched knob, + * then settle the three cross-edge invariants — disengageHR ≥ engageHR × 1.1, + * tiltZeroHR ≥ tiltFullHR × 1.1, tiltZeroHR ≤ disengageHR. The knob the caller + * moved wins and the other yields, EXCEPT against that last cap: the arm flips + * at disengage, so the blend must already be at scene up there. + */ + +import type { CameraTuning } from '../../@types/camera/CameraTuning'; +import { CAMERA_TUNING_LIMITS } from '../../data/camera/cameraTuning'; + +export function clampCameraTuning(patch: Partial, prev: CameraTuning): CameraTuning { + const L = CAMERA_TUNING_LIMITS; + + let engageHR = + patch.engageHR === undefined + ? prev.engageHR + : Math.min(L.engageMax, Math.max(L.engageMin, patch.engageHR)); + let disengageHR = + patch.disengageHR === undefined + ? prev.disengageHR + : Math.min(L.disengageMax, Math.max(L.disengageMin, patch.disengageHR)); + if (disengageHR < engageHR * L.minRatio) { + if (patch.disengageHR !== undefined && patch.engageHR === undefined) { + engageHR = Math.max(L.engageMin, disengageHR / L.minRatio); + } else { + disengageHR = Math.min(L.disengageMax, engageHR * L.minRatio); + } + } + + let tiltFullHR = + patch.tiltFullHR === undefined + ? prev.tiltFullHR + : Math.min(L.tiltFullMax, Math.max(L.tiltFullMin, patch.tiltFullHR)); + const cap = Math.min(L.tiltZeroMax, disengageHR); + let tiltZeroHR = Math.min( + cap, + patch.tiltZeroHR === undefined + ? prev.tiltZeroHR + : Math.min(L.tiltZeroMax, Math.max(L.tiltZeroMin, patch.tiltZeroHR)), + ); + if (tiltZeroHR < tiltFullHR * L.minRatio) { + const raised = tiltFullHR * L.minRatio; + if (patch.tiltFullHR !== undefined && raised <= cap) { + tiltZeroHR = raised; + } else { + tiltFullHR = Math.max(L.tiltFullMin, tiltZeroHR / L.minRatio); + } + } + + return { + engageHR, + disengageHR, + tiltFullHR, + tiltZeroHR, + blendSpace: patch.blendSpace ?? prev.blendSpace, + northUp: patch.northUp ?? prev.northUp, + }; +} diff --git a/src/utils/camera/cursorRayBodyLocal.ts b/src/utils/camera/cursorRayBodyLocal.ts new file mode 100644 index 0000000000..922b076daf --- /dev/null +++ b/src/utils/camera/cursorRayBodyLocal.ts @@ -0,0 +1,40 @@ +/** + * cursorRayBodyLocal — the pick ray through a CSS pixel, in body-fixed + * metres (spec §6). Built from `basisLocal`'s columns and the FOV directly, + * mirroring `fragment.wesl`'s `dir = normalize(forward + right·ndc.x·tanHalf· + * aspect + up·ndc.y·tanHalf)` — so this ray can never drift from the slab's + * view-projection the way re-deriving it from an inverted vp matrix could. + * + * `pixel`/`viewportPx` are CSS pixels, origin top-left (y-down); NDC y is + * flipped to match the camera's y-up `up` column. + */ + +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { BodyLocalRay } from '../../@types/camera/BodyLocalRay'; +import type { Vec2 } from '../../@types/math/Vec2'; +import { bodyFixedEyeM } from './bodyFixedEyeM'; + +export function cursorRayBodyLocal( + pose: BodyFixedPose, + pixel: Readonly, + viewportPx: Readonly, + fovYRad: number, +): BodyLocalRay { + const originM = bodyFixedEyeM(pose); + const { basisLocal } = pose; + + const ndcX = (pixel[0] / viewportPx[0]) * 2 - 1; + const ndcY = -((pixel[1] / viewportPx[1]) * 2 - 1); + const aspect = viewportPx[0] / viewportPx[1]; + const tanHalf = Math.tan(fovYRad / 2); + const rs = ndcX * tanHalf * aspect; + const us = ndcY * tanHalf; + + // Columns: right = [0,1,2], up = [3,4,5], forward = [6,7,8] (BodyFixedPose doc). + const dx = basisLocal[6] + basisLocal[0] * rs + basisLocal[3] * us; + const dy = basisLocal[7] + basisLocal[1] * rs + basisLocal[4] * us; + const dz = basisLocal[8] + basisLocal[2] * rs + basisLocal[5] * us; + const len = Math.hypot(dx, dy, dz); + + return { originM, dir: [dx / len, dy / len, dz / len] }; +} diff --git a/src/utils/camera/decodeBodyFixedChannels.ts b/src/utils/camera/decodeBodyFixedChannels.ts new file mode 100644 index 0000000000..689f359ac7 --- /dev/null +++ b/src/utils/camera/decodeBodyFixedChannels.ts @@ -0,0 +1,40 @@ +/** + * decodeBodyFixedChannels — the four animation channels read in one body's FIXED + * axes. `target` is a body-fixed point in METRES, `distance` a range in metres, + * `yaw`/`pitch` the orbit convention about the body's own axes: `yawPitchToDir` + * points from the target TOWARD the eye, so the aim is its negation (the sign + * flip `reencodePose` documents). + * + * DECODED, never accumulated (spec §8). A body-framed keyframe cannot express + * roll — there is no fifth channel to carry it. + */ + +import type { BodyId } from '../../@types/data/body/BodyId'; +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { CameraPose } from '../../@types/camera/CameraPose'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { BODY_LOCAL_FRAME } from '../../data/camera/bodyLocalFrame'; +import { imagePlaneBasis } from './imagePlaneBasis'; +import { mat3FromColumns } from '../math/mat3FromColumns'; +import { yawPitchToDir } from './yawPitchToDir'; + +export function decodeBodyFixedChannels(channels: CameraPose, bodyId: BodyId): BodyFixedPose { + const { target, distance } = channels; + const arm = yawPitchToDir(channels.yaw, channels.pitch); + const eyeLocalM: Vec3 = [ + target[0] + arm[0] * distance, + target[1] + arm[1] * distance, + target[2] + arm[2] * distance, + ]; + const forward: Vec3 = [-arm[0], -arm[1], -arm[2]]; + // Roll 0: the four channels carry no screen-up residual, so the basis is + // levelled against the body's pole. + const { right, up } = imagePlaneBasis(forward, 0, BODY_LOCAL_FRAME.pole); + + return { + bodyId, + anchorLocalM: [0, 0, 0], + eyeRelAnchorM: eyeLocalM, + basisLocal: mat3FromColumns(right, up, forward), + }; +} diff --git a/src/utils/camera/draggedSurfacePose.ts b/src/utils/camera/draggedSurfacePose.ts new file mode 100644 index 0000000000..0c913e6c50 --- /dev/null +++ b/src/utils/camera/draggedSurfacePose.ts @@ -0,0 +1,136 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { DragStep } from '../../@types/camera/DragStep'; +import type { DraggedSurfacePose } from '../../@types/camera/DraggedSurfacePose'; +import type { SurfaceGesture } from '../../@types/camera/SurfaceGesture'; +import type { Vec2 } from '../../@types/math/Vec2'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { BODY_LOCAL_FRAME } from '../../data/camera/bodyLocalFrame'; +import { TILT_GAIN } from '../../data/camera/tiltGain'; +import { anchoredDragRotation, MIN_INCIDENCE_COS } from './anchoredDragRotation'; +import { bodyFixedEyeM } from './bodyFixedEyeM'; +import { cursorRayBodyLocal } from './cursorRayBodyLocal'; +import { pickOnBody } from './pickOnBody'; +import { rotateBasisByQuat } from './rotateBasisByQuat'; +import { rotatedAboutPoint } from './rotatedAboutPoint'; +import { tiltFloorBudgetRad } from './tiltFloorBudgetRad'; +import { dot3 } from '../math/dot3'; +import { multiplyQuat } from '../math/multiplyQuat'; +import { normalize3 } from '../math/normalize3'; +import { quatFromAxisAngle } from '../math/quatFromAxisAngle'; +import { rotateVec3ByQuat } from '../math/rotateVec3ByQuat'; + +export function draggedSurfacePose( + arm: BodyFixedPose, + gesture: SurfaceGesture, + step: DragStep, + viewportPx: Readonly, + fovYRad: number, +): DraggedSurfacePose { + const currRay = cursorRayBodyLocal(arm, step.endPx, viewportPx, fovYRad); + let mode = gesture.mode; + + if (mode === 'pan') { + const prevRay = cursorRayBodyLocal(arm, gesture.prevPixel, viewportPx, fovYRad); + const rotated = anchoredDragRotation(arm, prevRay, currRay, gesture.anchorRadiusM); + if (rotated !== null) return { pose: rotated, mode }; + // A null answer covers a miss AND a grazing hit, and the two degrade + // differently (C §2.6 / §6.4), so the incidence is re-measured here against + // the frozen sphere, never the display readout. Sticky either way. The + // CURRENT ray decides alone: when it was the previous one that grazed, the + // orbit is the honest answer for a gesture already at the limb. + const graze = pickOnBody(currRay, gesture.anchorRadiusM); + mode = graze !== null && Math.abs(graze.incidence) < MIN_INCIDENCE_COS ? 'strafe' : 'orbit'; + } + + // One rate law for every screen-mapped mode: the angle the pixel delta + // subtends at the lens, so a drag of one screen height is one FOV of turn at + // every altitude and no tuning constant exists to be wrong. Height scales + // both axes, so x and y move at the same rate. + const yawRad = ((step.endPx[0] - gesture.prevPixel[0]) / viewportPx[1]) * fovYRad; + const pitchRad = ((step.endPx[1] - gesture.prevPixel[1]) / viewportPx[1]) * fovYRad; + const b = arm.basisLocal; + const right: Vec3 = [b[0], b[1], b[2]]; + + if (mode === 'orbit') { + // The pan continued past the limb on the frozen sphere: the pose orbits + // the centre AGAINST the drag, which is what carries the grabbed limb + // along with the cursor. The level settle in `apply` holds the entry + // heading, so this is the north-locked orbit, not a free trackball. + const up: Vec3 = [b[3], b[4], b[5]]; + const q = multiplyQuat(quatFromAxisAngle(right, -pitchRad), quatFromAxisAngle(up, -yawRad)); + return { pose: rotatedAboutPoint(arm, q, BODY_LOCAL_FRAME.centreM), mode }; + } + + if (mode === 'look') { + // Yaw about the LOCAL vertical rather than the camera's own up: that is + // what keeps the horizon level at every latitude and azimuth (probe + // defect 3). The eye is not touched — this is the only route to the sky. + const q = multiplyQuat( + quatFromAxisAngle(right, pitchRad), + quatFromAxisAngle(normalize3(bodyFixedEyeM(arm)), yawRad), + ); + return { pose: { ...arm, basisLocal: rotateBasisByQuat(q, arm.basisLocal) }, mode }; + } + + const anchorM = gesture.anchorLocalM; + // Both modes below latch an anchor, so this only keeps the arm total. + if (anchorM === null) return { pose: arm, mode }; + + if (mode === 'strafe') { + // The plane through the anchor with the view axis as its normal (C §2.8): + // translate by anchor − (this pixel's hit on it). Absolute against the + // latched anchor, not incremental, so a long gesture accumulates no drift. + const n: Vec3 = [b[6], b[7], b[8]]; + const denom = dot3(currRay.dir, n); + if (denom === 0) return { pose: arm, mode }; + const t = (dot3(anchorM, n) - dot3(currRay.originM, n)) / denom; + const e = arm.eyeRelAnchorM; + return { + pose: { + ...arm, + eyeRelAnchorM: [ + e[0] + anchorM[0] - (currRay.originM[0] + currRay.dir[0] * t), + e[1] + anchorM[1] - (currRay.originM[1] + currRay.dir[1] * t), + e[2] + anchorM[2] - (currRay.originM[2] + currRay.dir[2] * t), + ], + }, + mode, + }; + } + + // Tilt, in the intrinsic Z-X-Z order KML specifies: heading about the + // anchor's local up, THEN tilt about the ALREADY-YAWED east. Tilting about a + // fixed screen axis instead drags ~10° of unwanted heading per 60 px (probe). + const upLocal = normalize3(anchorM); + // NEGATED heading on this handle (user feel ruling 15): a right-drag turns + // the view the OTHER way from the orbit drag — deliberate, do not "fix" the + // sign back to match the pan convention. + const heading = quatFromAxisAngle(upLocal, -yawRad); + const radial = dot3(right, upLocal); + const eastM = normalize3([ + right[0] - upLocal[0] * radial, + right[1] - upLocal[1] * radial, + right[2] - upLocal[2] * radial, + ]); + // Google-MAPS pitch mapping (user ruling 17): drag UP/away tilts up toward + // the horizon, so `pitchRad`'s screen-space down-is-positive sign is NEGATED + // here. Do not "fix" this sign either way without a new ruling. + // + // Ruling 14: the tilt FLOOR at 0 is a dead stop, clamped at the gesture. Only + // the lowering side (negative request) is bounded, and by the exact + // through-zero rotation about this axis rather than by the tilt readout — an + // unsigned acos cannot say which way is down and once bound the wrong side + // entirely (R13-1). The raising side is unbounded here; the heading factor + // is untouched, so a mixed drag keeps its yaw live. + const tiltRequest = -pitchRad * TILT_GAIN; + const fwdArm: Vec3 = [arm.basisLocal[6], arm.basisLocal[7], arm.basisLocal[8]]; + const tiltAngle = + tiltRequest >= 0 + ? tiltRequest + : Math.max(tiltRequest, -tiltFloorBudgetRad(fwdArm, bodyFixedEyeM(arm), anchorM, eastM)); + // Excess-only input is identity BY REFERENCE, not by arithmetic — the quat + // path leaves −0 crumbs that would fail the ruled full-pose byte bar. + if (tiltAngle === 0 && yawRad === 0) return { pose: arm, mode }; + const q = multiplyQuat(quatFromAxisAngle(rotateVec3ByQuat(heading, eastM), tiltAngle), heading); + return { pose: rotatedAboutPoint(arm, q, anchorM), mode }; +} diff --git a/src/utils/camera/eyeFrameOf.ts b/src/utils/camera/eyeFrameOf.ts new file mode 100644 index 0000000000..4bac3dbf9e --- /dev/null +++ b/src/utils/camera/eyeFrameOf.ts @@ -0,0 +1,40 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { EyeFrame } from '../../@types/camera/EyeFrame'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { blendedEnuAt } from './blendedEnuAt'; +import { bodyFixedEyeM } from './bodyFixedEyeM'; +import { refAzimuthOf } from './refAzimuthOf'; +import { tiltFromNadirRad } from './tiltFromNadirRad'; +import { normalize3 } from '../math/normalize3'; + +/** + * The pose's orientation readout in the band-blended reference ENU + * (`blendedEnuAt` — one home, shared with the debug readout): `blendW = 1` is + * the pure body ENU (every drag site), lower weights swing north toward the + * scene up across the hysteresis window (the zoom settle, round 5/6). + */ +export function eyeFrameOf( + pose: BodyFixedPose, + blendW: number, + sceneUpLocal: Readonly, +): EyeFrame | null { + const eyeM = bodyFixedEyeM(pose); + if (Math.hypot(...eyeM) === 0) return null; // no ENU exists at the centre + const localUp = normalize3(eyeM); + const b = pose.basisLocal; + const forward: Vec3 = [b[6], b[7], b[8]]; + const up: Vec3 = [b[3], b[4], b[5]]; + // The pose's own screen-up is the hold-and-transport carry: inside the + // blend's singular neighbourhood the reference is wherever the settle + // already put the view (round 7) — stateless, and consistent between the + // pre-notch and post-notch measures because both read their own pose. + const { east, north } = blendedEnuAt(localUp, blendW, sceneUpLocal, up); + const tiltRad = tiltFromNadirRad(forward, eyeM); + return { + localUp, + tiltRad, + east, + north, + azimuthRad: refAzimuthOf(localUp, forward, up, east, north), + }; +} diff --git a/src/utils/camera/eyeMpcOf.ts b/src/utils/camera/eyeMpcOf.ts new file mode 100644 index 0000000000..a75bbf72cc --- /dev/null +++ b/src/utils/camera/eyeMpcOf.ts @@ -0,0 +1,30 @@ +/** eyeMpcOf — the world eye of an orbit pose, Mpc: `target + distance · dir`, + * `dir` being the frame-local `(yaw, pitch)` decode rotated by the STEADY + * `poseBasis` (never `upBasis` — see `OrbitCameraInit.d.ts`). One derivation for + * `updatePosition` and the regime predicate (spec §4); `undefined` = identity. */ + +import type { CameraPose } from '../../@types/camera/CameraPose'; +import type { Mat3 } from '../../@types/math/Mat3'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { yawPitchToDir } from './yawPitchToDir'; +import { rotateVec3ByTightMat3 } from '../math/rotateVec3ByTightMat3'; + +// Module scratch so the per-frame path never allocates. TWO buffers: the +// matrix-vector product reads all three inputs while writing its output. +const scratchDir: Vec3 = [0, 0, 0]; +const scratchWorld: Vec3 = [0, 0, 0]; + +/** `out` is written in place and returned; omitted ⇒ a fresh `Vec3`. */ +export function eyeMpcOf( + pose: CameraPose, + poseBasis: Readonly | undefined, + out?: Vec3, +): Vec3 { + const dir = yawPitchToDir(pose.yaw, pose.pitch, scratchDir); + const world = rotateVec3ByTightMat3(dir, poseBasis, scratchWorld); + const dst = out ?? ([0, 0, 0] as Vec3); + dst[0] = pose.target[0] + world[0] * pose.distance; + dst[1] = pose.target[1] + world[1] * pose.distance; + dst[2] = pose.target[2] + world[2] * pose.distance; + return dst; +} diff --git a/src/utils/camera/flooredBodyPose.ts b/src/utils/camera/flooredBodyPose.ts new file mode 100644 index 0000000000..c00bbd2df8 --- /dev/null +++ b/src/utils/camera/flooredBodyPose.ts @@ -0,0 +1,24 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import { bodyFixedEyeM } from './bodyFixedEyeM'; +import { surfaceFloorM } from './surfaceFloorM'; + +/** + * The descent floor, unconditional and resampled after the last position write + * (spec §6, O §4). The push is radial, so it moves the eye without turning it. + * A tilt about a surface anchor holds `|eye − anchor|`, not `|eye|`, so without + * this a long tilt drag walks the eye straight through the ground. + */ +export function flooredBodyPose(pose: BodyFixedPose, bodyRadiusM: number): BodyFixedPose { + const eyeM = bodyFixedEyeM(pose); + const magM = Math.hypot(...eyeM); + const floorM = surfaceFloorM(bodyRadiusM); + // An eye exactly at the centre has no push direction; no bounded step reaches + // it from a floored pose, and leaving it beats returning NaN. + if (magM >= floorM || magM === 0) return pose; + const scale = floorM / magM; + const { anchorLocalM: a } = pose; + return { + ...pose, + eyeRelAnchorM: [eyeM[0] * scale - a[0], eyeM[1] * scale - a[1], eyeM[2] * scale - a[2]], + }; +} diff --git a/src/utils/camera/isFollowDriverId.ts b/src/utils/camera/isFollowDriverId.ts new file mode 100644 index 0000000000..e3bac73c9d --- /dev/null +++ b/src/utils/camera/isFollowDriverId.ts @@ -0,0 +1,8 @@ +/** The follow pair shares a produce, a memory and the `follow` epoch row, so + * every downstream "was follow driving?" asks this rather than one id. */ + +import type { DriverId } from '../../@types/engine/camera/DriverId'; + +export function isFollowDriverId(id: DriverId): boolean { + return id === 'followApproach' || id === 'followHold'; +} diff --git a/src/utils/camera/latchSurfaceGesture.ts b/src/utils/camera/latchSurfaceGesture.ts new file mode 100644 index 0000000000..b75c28fae3 --- /dev/null +++ b/src/utils/camera/latchSurfaceGesture.ts @@ -0,0 +1,47 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { DragStep } from '../../@types/camera/DragStep'; +import type { SurfaceGesture } from '../../@types/camera/SurfaceGesture'; +import type { Vec2 } from '../../@types/math/Vec2'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { MIN_INCIDENCE_COS } from './anchoredDragRotation'; +import { bodyFixedEyeM } from './bodyFixedEyeM'; +import { cursorRayBodyLocal } from './cursorRayBodyLocal'; +import { pickOnBody } from './pickOnBody'; +import { normalize3 } from '../math/normalize3'; + +export function latchSurfaceGesture( + arm: BodyFixedPose, + step: DragStep, + viewportPx: Readonly, + fovYRad: number, + bodyRadiusM: number, +): SurfaceGesture { + const prevPixel = step.startPx; + const pick = pickOnBody(cursorRayBodyLocal(arm, prevPixel, viewportPx, fovYRad), bodyRadiusM); + + // The secondary drag (right / middle button) is the tilt handle. Its anchor + // falls back to the nadir footprint, so tilt always has a ground point to + // orbit whatever the cursor is over. + if (step.mode === 'pan') { + const nadir = normalize3(bodyFixedEyeM(arm)); + const anchorLocalM: Vec3 = pick?.pointM ?? [ + nadir[0] * bodyRadiusM, + nadir[1] * bodyRadiusM, + nadir[2] * bodyRadiusM, + ]; + return { mode: 'tilt', anchorLocalM, anchorRadiusM: Math.hypot(...anchorLocalM), prevPixel }; + } + + // A miss is sky: free look (R1). A pan that LEAVES the disc mid-gesture + // degrades to the north-locked orbit instead, not to look. + if (pick === null) return { mode: 'look', anchorLocalM: null, anchorRadiusM: 0, prevPixel }; + + return { + // At grazing incidence the rotation that satisfies the drag is a teleport, + // so the gesture strafes in the anchor's plane instead (C §6.4). + mode: Math.abs(pick.incidence) < MIN_INCIDENCE_COS ? 'strafe' : 'pan', + anchorLocalM: pick.pointM, + anchorRadiusM: Math.hypot(...pick.pointM), + prevPixel, + }; +} diff --git a/src/utils/camera/levelledPose.ts b/src/utils/camera/levelledPose.ts new file mode 100644 index 0000000000..694d7ef14d --- /dev/null +++ b/src/utils/camera/levelledPose.ts @@ -0,0 +1,39 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { canonicalBasisAt } from './canonicalBasisAt'; +import { cappedRotationToward } from './cappedRotationToward'; +import { eyeFrameOf } from './eyeFrameOf'; +import { turnedPose } from './turnedPose'; + +/** + * The one level settle: rotate the basis toward the roll-free pose at its own + * standpoint and tilt, capped — FULL below the cap, which is what makes + * gesture-created roll unrepresentable rather than merely damped (R1: no + * gesture may introduce roll), while an arriving roll eases out over a few + * inputs. Drags level in the pure body ENU (`blendW` 1) and hold the heading + * they entered with (`heldAzimuthRad` — a curved pan cannot rotate the + * image); zoom notches level in the band-blended frame at the pose's own + * azimuth, about the dive anchor or the eye. The caller prices `capRad` in its + * own currency (`ORIENT_DECAY`): a notch per log-zoom spent, a drag per step. + */ +export function levelledPose( + pose: BodyFixedPose, + args: { + readonly blendW: number; + readonly sceneUpLocal: Readonly; + readonly heldAzimuthRad: number | null; + readonly pivotM: Readonly | null; + readonly capRad: number; + }, +): BodyFixedPose { + // A zero cap must return BY REFERENCE, which it does not deliver by itself: + // `cappedRotationToward` still returns a quat wherever the bases differ, and + // `turnedPose`'s eye − pivot … + pivot round trip re-adds float noise. + if (args.capRad === 0) return pose; + const frame = eyeFrameOf(pose, args.blendW, args.sceneUpLocal); + if (frame === null) return pose; + const target = canonicalBasisAt(frame, args.heldAzimuthRad ?? frame.azimuthRad, frame.tiltRad); + const q = cappedRotationToward(pose.basisLocal, target, args.capRad); + if (q === null) return pose; + return turnedPose(pose, q, args.pivotM); +} diff --git a/src/utils/camera/lonLatFocusPose.ts b/src/utils/camera/lonLatFocusPose.ts index 20bf35f459..3d4f62c4fa 100644 --- a/src/utils/camera/lonLatFocusPose.ts +++ b/src/utils/camera/lonLatFocusPose.ts @@ -1,41 +1,40 @@ -import type { CameraPose } from '../../@types/camera/CameraPose'; +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { BodyId } from '../../@types/data/body/BodyId'; import type { LonLatDeg } from '../../@types/scene/LonLatDeg'; -import type { Mat3 } from '../../@types/math/Mat3'; import type { Vec3 } from '../../@types/math/Vec3'; +import { BODY_LOCAL_FRAME } from '../../data/camera/bodyLocalFrame'; +import { blendedEnuAt } from './blendedEnuAt'; +import { canonicalBasisAt } from './canonicalBasisAt'; import { lonLatDegToDirection } from '../scene/lonLatDegToDirection'; -import { rotateVec3ByTightMat3 } from '../math/rotateVec3ByTightMat3'; -import { orbitAnglesLookingAlong } from './orbitAnglesLookingAlong'; /** - * lonLatFocusPose — the CameraPose that puts a body's given geodetic point - * exactly under the camera (sub-camera point = `point`), at `distance`, with - * the target at the body's centre. - * - * The exact inverse of the sub-camera readout `earthTileSubsystem.getDebugSnapshot` - * computes: that reads `dirLocal = bodyOrientationᵀ · normalize(camPos − bodyPos)` - * then `directionToLonLatDeg(dirLocal)`. Here we go the other way — - * `lonLatDegToDirection` → rotate by `bodyOrientation` (untransposed, local→world) - * to get the world direction from the body centre toward the camera — then hand - * that to `orbitAnglesLookingAlong` (the aim is the opposite direction, back - * toward the body) to recover the (yaw, pitch) the SAME `frameBasis` decodes - * back to that exact world direction. + * lonLatFocusPose — the body arm that puts a geodetic point under the camera: + * standpoint from the lon/lat, `rangeM` the straight-down sightline range to + * the surface (so |eye| = R + rangeM), tilt 0, heading held. No Mpc in it — a + * pose that lands outside the band is the frame fold's business, not the + * caller's (spec §9). */ export function lonLatFocusPose( point: LonLatDeg, - targetMpc: Readonly, - distance: number, - bodyOrientation: Readonly, - frameBasis: Mat3, -): CameraPose { - const dirLocal = lonLatDegToDirection(point); - // local→world: bodyOrientation's columns are the body-local axes in world - // space (the same convention camPosLocal's header derives its transpose - // from), so the untransposed product carries a local direction OUT to world. - const dirWorld = rotateVec3ByTightMat3(dirLocal, bodyOrientation); - // orbitAnglesLookingAlong wants the AIM (camera → target); the eye sits on - // the OPPOSITE side of the target from the sub-camera point, so the aim is - // the negated direction-toward-camera. - const forward: Vec3 = [-dirWorld[0], -dirWorld[1], -dirWorld[2]]; - const { yaw, pitch } = orbitAnglesLookingAlong(forward, frameBasis); - return { target: [targetMpc[0], targetMpc[1], targetMpc[2]], yaw, pitch, distance }; + bodyId: BodyId, + bodyRadiusM: number, + rangeM: number, + headingRad: number, +): BodyFixedPose { + const localUp = lonLatDegToDirection(point); + const eyeMagM = bodyRadiusM + rangeM; + const eyeRelAnchorM: Vec3 = [localUp[0] * eyeMagM, localUp[1] * eyeMagM, localUp[2] * eyeMagM]; + // Pure body ENU (`blendW` 1 — the scene up carries no weight there), the same + // reference `eyeFrameOf` reads a heading back against. + const { east, north } = blendedEnuAt(localUp, 1, BODY_LOCAL_FRAME.pole, null); + return { + bodyId, + anchorLocalM: [0, 0, 0], + eyeRelAnchorM, + basisLocal: canonicalBasisAt( + { localUp, east, north, tiltRad: 0, azimuthRad: headingRad }, + headingRad, + 0, + ), + }; } diff --git a/src/utils/camera/mappedTiltRad.ts b/src/utils/camera/mappedTiltRad.ts new file mode 100644 index 0000000000..6b3ee99480 --- /dev/null +++ b/src/utils/camera/mappedTiltRad.ts @@ -0,0 +1,13 @@ +/** THE display-tilt mapping (rulings 12 + 13, one home), read by BOTH arms so + * the engage edge changes ownership of the tilt but never the image. */ + +import type { CameraTuning } from '../../@types/camera/CameraTuning'; +import { bodyUpWeight } from './bodyUpWeight'; + +export function mappedTiltRad( + rememberedTiltRad: number, + hOverR: number, + tuning: CameraTuning, +): number { + return rememberedTiltRad * bodyUpWeight(hOverR, tuning); +} diff --git a/src/utils/camera/orbitAnglesLookingAlong.ts b/src/utils/camera/orbitAnglesLookingAlong.ts index ab1cb1349c..178493d4fe 100644 --- a/src/utils/camera/orbitAnglesLookingAlong.ts +++ b/src/utils/camera/orbitAnglesLookingAlong.ts @@ -54,7 +54,7 @@ import type { Mat3 } from '../../@types/math/Mat3'; export function orbitAnglesLookingAlong( forward: Vec3, - frameBasis?: Mat3, + frameBasis?: Readonly, ): { yaw: number; pitch: number } { const m = Math.hypot(forward[0], forward[1], forward[2]) || 1; // dir = -forward (normalised): the frame-agnostic world direction from target diff --git a/src/utils/camera/orientStepRad.ts b/src/utils/camera/orientStepRad.ts new file mode 100644 index 0000000000..4f5eff2507 --- /dev/null +++ b/src/utils/camera/orientStepRad.ts @@ -0,0 +1,9 @@ +/** One step of the bounded orientation decay, priced in spent log-zoom (`ORIENT_DECAY`). */ + +import { ORIENT_DECAY } from '../../data/camera/orientDecay'; + +export function orientStepRad(residualRad: number, logZoom: number): number { + const u = Math.abs(logZoom); + const spent = residualRad * (1 - Math.exp(-ORIENT_DECAY.perLogZoom * u)); + return Math.sign(spent) * Math.min(Math.abs(spent), ORIENT_DECAY.capRadPerLogZoom * u); +} diff --git a/src/utils/camera/pickOnBody.ts b/src/utils/camera/pickOnBody.ts new file mode 100644 index 0000000000..643bec8c3a --- /dev/null +++ b/src/utils/camera/pickOnBody.ts @@ -0,0 +1,19 @@ +import type { BodyLocalRay } from '../../@types/camera/BodyLocalRay'; +import type { SurfacePick } from '../../@types/camera/SurfacePick'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { BODY_LOCAL_FRAME } from '../../data/camera/bodyLocalFrame'; +import { dot3 } from '../math/dot3'; +import { raySphereRoots } from '../math/raySphereRoots'; + +/** The nearest hit AHEAD of the eye; a hit behind it would grab the far side. */ +export function pickOnBody(ray: BodyLocalRay, radiusM: number): SurfacePick | null { + const roots = raySphereRoots(ray.originM, ray.dir, BODY_LOCAL_FRAME.centreM, radiusM); + if (roots === null || roots[0] <= 0) return null; + const t = roots[0]; + const pointM: Vec3 = [ + ray.originM[0] + ray.dir[0] * t, + ray.originM[1] + ray.dir[1] * t, + ray.originM[2] + ray.dir[2] * t, + ]; + return { pointM, incidence: dot3(ray.dir, pointM) / radiusM }; +} diff --git a/src/utils/camera/poseFromBodyArm.ts b/src/utils/camera/poseFromBodyArm.ts new file mode 100644 index 0000000000..12051aad86 --- /dev/null +++ b/src/utils/camera/poseFromBodyArm.ts @@ -0,0 +1,11 @@ +/** The engaged body's `BodyRelativePose` straight from the stored pose: the + * anchor fold `eyeRelBodyM = anchorLocalM + eyeRelAnchorM` is the WHOLE + * conversion — no Mpc, no rotation, no cancellation (spec §5.3). */ + +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { BodyRelativePose } from '../../@types/engine/camera/BodyRelativePose'; +import { bodyFixedEyeM } from './bodyFixedEyeM'; + +export function poseFromBodyArm(pose: BodyFixedPose): BodyRelativePose { + return { eyeRelBodyM: bodyFixedEyeM(pose), basisM: pose.basisLocal }; +} diff --git a/src/utils/camera/poseWithBasisTurn.ts b/src/utils/camera/poseWithBasisTurn.ts new file mode 100644 index 0000000000..1b219d0787 --- /dev/null +++ b/src/utils/camera/poseWithBasisTurn.ts @@ -0,0 +1,8 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { Vec4 } from '../../@types/math/Vec4'; +import { rotateBasisByQuat } from './rotateBasisByQuat'; + +/** Turn the basis in place — the eye-pivot form of a settle rotation. */ +export function poseWithBasisTurn(pose: BodyFixedPose, q: Readonly): BodyFixedPose { + return { ...pose, basisLocal: rotateBasisByQuat(q, pose.basisLocal) }; +} diff --git a/src/utils/camera/reencodePose.ts b/src/utils/camera/reencodePose.ts index 0a56b77fce..094ba630d4 100644 --- a/src/utils/camera/reencodePose.ts +++ b/src/utils/camera/reencodePose.ts @@ -18,8 +18,8 @@ import { rotateVec3ByTightMat3 } from '../math/rotateVec3ByTightMat3'; export function reencodePose( pose: CameraPose, - from: Mat3 | undefined, - to: Mat3 | undefined, + from: Readonly | undefined, + to: Readonly | undefined, ): CameraPose { // Identity case dominates call volume (most frame switches don't touch every // in-flight pose); returning by reference here keeps it allocation-free. diff --git a/src/utils/camera/refAzimuthOf.ts b/src/utils/camera/refAzimuthOf.ts new file mode 100644 index 0000000000..75d4826a25 --- /dev/null +++ b/src/utils/camera/refAzimuthOf.ts @@ -0,0 +1,28 @@ +/** + * refAzimuthOf — the user-visible azimuth of a view against a reference ENU: + * read off screen-up below 45° tilt and off forward above — their horizontal + * parts are cos(tilt) and sin(tilt) long, so they trade places there. For a + * roll-free pose the two agree; while a roll is still bleeding out the chosen + * one is what the user means by "north is up". ONE home for the source rule — + * the engaged settle and the camera debug readout both call THIS. + */ + +import type { Vec3 } from '../../@types/math/Vec3'; +import { dot3 } from '../math/dot3'; + +export function refAzimuthOf( + localUp: Readonly, + forward: Readonly, + up: Readonly, + east: Readonly, + north: Readonly, +): number { + const source = dot3(forward, localUp) < -Math.SQRT1_2 ? up : forward; + const vert = dot3(source, localUp); + const horiz: Vec3 = [ + source[0] - localUp[0] * vert, + source[1] - localUp[1] * vert, + source[2] - localUp[2] * vert, + ]; + return Math.atan2(dot3(horiz, east), dot3(horiz, north)); +} diff --git a/src/utils/camera/riddenOrientStepRad.ts b/src/utils/camera/riddenOrientStepRad.ts new file mode 100644 index 0000000000..1ac527df8b --- /dev/null +++ b/src/utils/camera/riddenOrientStepRad.ts @@ -0,0 +1,21 @@ +/** + * ONE settle discipline (ruling 10), both arms: the deviation's notch-authored + * move rides in full up to `rideBoundRad`; the pre-notch deviation decays by + * the capped share of `logZoom` — only the DECAY half is priced in zoom, the + * ride being notch-authored already. The bound is per-DOF policy: heading/roll + * pass `ORIENT_DECAY.rideBoundRad` (a bigger move is an unauthored blend + * flip); tilt passes `Infinity`, or it would cross disengage with tilt (12). + */ + +import { orientStepRad } from './orientStepRad'; + +export function riddenOrientStepRad( + deviationPreRad: number, + deviationMoveRawRad: number, + rideBoundRad: number, + logZoom: number, +): number { + const move = + Math.sign(deviationMoveRawRad) * Math.min(Math.abs(deviationMoveRawRad), rideBoundRad); + return move + orientStepRad(deviationPreRad, logZoom); +} diff --git a/src/utils/camera/rollFromScreenUp.ts b/src/utils/camera/rollFromScreenUp.ts new file mode 100644 index 0000000000..d4120f4506 --- /dev/null +++ b/src/utils/camera/rollFromScreenUp.ts @@ -0,0 +1,19 @@ +/** The roll that reproduces `screenUp` through `imagePlaneBasis`, which rotates + * the pole into `up(θ) = e2·cosθ − e1·sinθ` about `e1 = normalize(forward × + * upRef)`, `e2 = e1 × forward` — so θ is just the two projections. `forward ∥ + * upRef` leaves `e1 ≈ 0` and returns 0, that function's own degeneracy. */ + +import type { Vec3 } from '../../@types/math/Vec3'; +import { normalize3 } from '../math/normalize3'; +import { cross3 } from '../math/cross3'; +import { dot3 } from '../math/dot3'; + +export function rollFromScreenUp( + forward: Readonly, + screenUp: Readonly, + upRef: Readonly, +): number { + const e1 = normalize3(cross3(forward, upRef)); + const e2 = cross3(e1, forward); + return Math.atan2(-dot3(screenUp, e1), dot3(screenUp, e2)); +} diff --git a/src/utils/camera/rotateBasisByQuat.ts b/src/utils/camera/rotateBasisByQuat.ts new file mode 100644 index 0000000000..d916869b11 --- /dev/null +++ b/src/utils/camera/rotateBasisByQuat.ts @@ -0,0 +1,20 @@ +/** + * rotateBasisByQuat — turn a `BodyFixedPose.basisLocal` rigidly by `q`. + * `reorthonormalise` rebuilds its third column as `c0 × c1`, but this is the + * image-plane basis, where `right × up = −forward`: passing the columns as + * (forward, up, right) lands the right axis there and leaves forward exact. + */ + +import type { Mat3 } from '../../@types/math/Mat3'; +import type { Vec4 } from '../../@types/math/Vec4'; +import { rotateVec3ByQuat } from '../math/rotateVec3ByQuat'; +import { reorthonormalise } from '../math/reorthonormalise'; + +export function rotateBasisByQuat(q: Readonly, basisLocal: Readonly): Mat3 { + const b = basisLocal; + const right = rotateVec3ByQuat(q, [b[0], b[1], b[2]]); + const up = rotateVec3ByQuat(q, [b[3], b[4], b[5]]); + const forward = rotateVec3ByQuat(q, [b[6], b[7], b[8]]); + const [fx, fy, fz, ux, uy, uz, rx, ry, rz] = reorthonormalise([...forward, ...up, ...right]); + return [rx, ry, rz, ux, uy, uz, fx, fy, fz]; +} diff --git a/src/utils/camera/rotatedAboutPoint.ts b/src/utils/camera/rotatedAboutPoint.ts new file mode 100644 index 0000000000..c01d8a3b33 --- /dev/null +++ b/src/utils/camera/rotatedAboutPoint.ts @@ -0,0 +1,29 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { Vec3 } from '../../@types/math/Vec3'; +import type { Vec4 } from '../../@types/math/Vec4'; +import { bodyFixedEyeM } from './bodyFixedEyeM'; +import { rotateBasisByQuat } from './rotateBasisByQuat'; +import { rotateVec3ByQuat } from '../math/rotateVec3ByQuat'; + +/** + * Turn the eye and the basis about a body-fixed point. The anchor stays where + * it is, so `eyeRelAnchorM` absorbs the eye's move. + */ +export function rotatedAboutPoint( + pose: BodyFixedPose, + q: Readonly, + pivotM: Readonly, +): BodyFixedPose { + const eyeM = bodyFixedEyeM(pose); + const relM = rotateVec3ByQuat(q, [eyeM[0] - pivotM[0], eyeM[1] - pivotM[1], eyeM[2] - pivotM[2]]); + const { anchorLocalM: a } = pose; + return { + ...pose, + eyeRelAnchorM: [ + pivotM[0] + relM[0] - a[0], + pivotM[1] + relM[1] - a[1], + pivotM[2] + relM[2] - a[2], + ], + basisLocal: rotateBasisByQuat(q, pose.basisLocal), + }; +} diff --git a/src/utils/camera/settledZoomPose.ts b/src/utils/camera/settledZoomPose.ts new file mode 100644 index 0000000000..8ae6c42028 --- /dev/null +++ b/src/utils/camera/settledZoomPose.ts @@ -0,0 +1,108 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { CameraTuning } from '../../@types/camera/CameraTuning'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { BODY_LOCAL_FRAME } from '../../data/camera/bodyLocalFrame'; +import { ORIENT_DECAY } from '../../data/camera/orientDecay'; +import { bodyFixedEyeM } from './bodyFixedEyeM'; +import { bodyUpWeight } from './bodyUpWeight'; +import { eyeFrameOf } from './eyeFrameOf'; +import { flooredBodyPose } from './flooredBodyPose'; +import { levelledPose } from './levelledPose'; +import { mappedTiltRad } from './mappedTiltRad'; +import { orientStepRad } from './orientStepRad'; +import { riddenOrientStepRad } from './riddenOrientStepRad'; +import { tiltFromNadirRad } from './tiltFromNadirRad'; +import { tiltTurnedPose } from './tiltTurnedPose'; +import { turnedPose } from './turnedPose'; +import { normalize3 } from '../math/normalize3'; +import { quatFromAxisAngle } from '../math/quatFromAxisAngle'; +import { wrapRad } from '../math/wrapRad'; + +/** + * The zoom path's orientation settle (R1 + rulings 5-12): every notch, both + * directions — heading → north of the band-blended reference, tilt → the + * remembered mapping, roll → level. `diveAnchorM !== null` IS the dive: its + * turns pivot about the anchor so the dived-on point stays pixel-locked + * (Q4c); a recession turns about the eye (anchor-pivoting there cancels + * ~h/(R+h) of every correction, measured). `northUp` (ruling 11) gates + * heading + roll only: the tilt term's 0-at-disengage is what keeps the + * fold's retarget view-exact, toggle or no toggle. Every settle amount here is + * priced in `logZoom` (`|ln factor|`), so a trackpad twitch and a mouse notch + * of equal total zoom converge on the same orientation (`ORIENT_DECAY`). + */ +export function settledZoomPose( + pose: BodyFixedPose, + diveAnchorM: Readonly | null, + bodyRadiusM: number, + preTiltDevRad: number | null, + sceneUpLocal: Readonly, + preBlendAzimuthRad: number | null, + rememberedTiltRad: number, + logZoom: number, + tuning: CameraTuning, +): BodyFixedPose { + let out = pose; + const eyeM0 = bodyFixedEyeM(out); + if (Math.hypot(...eyeM0) === 0) return pose; + // The reference is the band blend (round 5): body pole deep in, scene up at + // disengage, so an engaged recession hands the fold a scene-aligned bake. + const blendW = bodyUpWeight(Math.hypot(...eyeM0) / bodyRadiusM - 1, tuning); + const f0 = eyeFrameOf(out, blendW, sceneUpLocal); + if (f0 === null) return pose; + + if (tuning.northUp) { + // Dive: pure decay (rulings 5/7) about the anchor's radial through the + // body centre — altitude untouched. Recession: the one discipline — the + // reference's own band swing is notch-authored and rides; only deviation + // the zoom did not author (`preBlendAzimuthRad`, measured at the + // pre-notch pose against ITS reference) decays. + const dPre = preBlendAzimuthRad ?? f0.azimuthRad; + const dPsi = diveAnchorM + ? orientStepRad(f0.azimuthRad, logZoom) + : riddenOrientStepRad( + dPre, + wrapRad(f0.azimuthRad - dPre), + ORIENT_DECAY.rideBoundRad, + logZoom, + ); + if (dPsi !== 0) { + out = diveAnchorM + ? turnedPose( + out, + quatFromAxisAngle(normalize3(diveAnchorM), dPsi), + BODY_LOCAL_FRAME.centreM, + ) + : turnedPose(out, quatFromAxisAngle(f0.localUp, dPsi), null); + } + } + + // Ruling 12: display tilt is the pure function `remembered × w(h/R)` — a + // notch never authors tilt, so the target's own move rides unbounded and + // only an arrival deviation decays, capped (the 113°-snap protection). + const eyeM1 = bodyFixedEyeM(out); + const eyeMag1 = Math.hypot(...eyeM1); + if (eyeMag1 !== 0) { + const b = out.basisLocal; + const devNew = + tiltFromNadirRad([b[6], b[7], b[8]], eyeM1) - + mappedTiltRad(rememberedTiltRad, eyeMag1 / bodyRadiusM - 1, tuning); + const devPre = preTiltDevRad ?? devNew; + out = tiltTurnedPose( + out, + -riddenOrientStepRad(devPre, devNew - devPre, Infinity, logZoom), + diveAnchorM, + ); + } + + if (tuning.northUp) { + out = levelledPose(out, { + blendW, + sceneUpLocal, + heldAzimuthRad: null, + pivotM: diveAnchorM, + capRad: ORIENT_DECAY.capRadPerLogZoom * Math.abs(logZoom), + }); + } + // A tilt about a surface anchor holds |eye − anchor|, not |eye|. + return flooredBodyPose(out, bodyRadiusM); +} diff --git a/src/utils/camera/spentZoomFactor.ts b/src/utils/camera/spentZoomFactor.ts new file mode 100644 index 0000000000..0b53af72d9 --- /dev/null +++ b/src/utils/camera/spentZoomFactor.ts @@ -0,0 +1,9 @@ +/** The zoom a folded notch may SPEND on the body arm. `inputAggregator` multiplies a + * frame's wheel events, so the settle prices off this, never off the unbounded fold. */ + +const MIN_FACTOR = 0.5; +const MAX_FACTOR = 2.0; + +export function spentZoomFactor(factor: number): number { + return Math.min(MAX_FACTOR, Math.max(MIN_FACTOR, factor)); +} diff --git a/src/utils/camera/surfaceFloorM.ts b/src/utils/camera/surfaceFloorM.ts new file mode 100644 index 0000000000..b1f48b6b6c --- /dev/null +++ b/src/utils/camera/surfaceFloorM.ts @@ -0,0 +1,8 @@ +/** The metre-space descent floor above a body's surface, off the ratio's one + * Mpc-space home (`clampDistance`) so the two cannot disagree (spec §10). */ + +import { SURFACE_STANDOFF_RADII } from './clampDistance'; + +export function surfaceFloorM(bodyRadiusM: number): number { + return bodyRadiusM * SURFACE_STANDOFF_RADII; +} diff --git a/src/utils/camera/surfaceGestureEdge.ts b/src/utils/camera/surfaceGestureEdge.ts new file mode 100644 index 0000000000..36185800fc --- /dev/null +++ b/src/utils/camera/surfaceGestureEdge.ts @@ -0,0 +1,8 @@ +/** The body arm's gesture boundary on its memory: the pointer edge is the ONLY + * thing that latches or drops a surface gesture, so this is its one spelling. */ + +import type { SurfaceMemory } from '../../@types/camera/SurfaceMemory'; + +export function surfaceGestureEdge(prev: SurfaceMemory, down: boolean): SurfaceMemory { + return { ...prev, gesture: down ? 'down' : null }; +} diff --git a/src/utils/camera/surfaceZoomStep.ts b/src/utils/camera/surfaceZoomStep.ts new file mode 100644 index 0000000000..aa267843ba --- /dev/null +++ b/src/utils/camera/surfaceZoomStep.ts @@ -0,0 +1,79 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { CameraTuning } from '../../@types/camera/CameraTuning'; +import type { SurfaceGesture } from '../../@types/camera/SurfaceGesture'; +import type { Vec2 } from '../../@types/math/Vec2'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { anchoredZoomStep } from './anchoredZoomStep'; +import { bodyFixedEyeM } from './bodyFixedEyeM'; +import { bodyUpWeight } from './bodyUpWeight'; +import { cursorRayBodyLocal } from './cursorRayBodyLocal'; +import { eyeFrameOf } from './eyeFrameOf'; +import { mappedTiltRad } from './mappedTiltRad'; +import { pickOnBody } from './pickOnBody'; +import { settledZoomPose } from './settledZoomPose'; +import { spentZoomFactor } from './spentZoomFactor'; +import { dot3 } from '../math/dot3'; +import { normalize3 } from '../math/normalize3'; + +export function surfaceZoomStep( + arm: BodyFixedPose, + gesture: SurfaceGesture | null, + factor: number, + cursorPx: Readonly | null, + viewportPx: Readonly, + fovYRad: number, + bodyRadiusM: number, + sceneUpLocal: Readonly, + rememberedTiltRad: number, + tuning: CameraTuning, +): BodyFixedPose { + const latched = gesture?.anchorLocalM ?? null; + // Past the anchor's own tangent plane the anchor is behind the horizon and + // zooming toward it is a teleport, so the pick is taken fresh (C §6.7). The + // test runs every tick rather than latching a flag — the zoom owns no state + // of its own (FW-B). + const stale = + latched !== null && dot3(bodyFixedEyeM(arm), normalize3(latched)) < Math.hypot(...latched); + // At rest the wheel's own cursor pixel is the pick (user ruling, §12-R4); + // during a gesture the drag's last pixel is the more current one. Only a + // pinch supplies neither, and its screen-centre pick is the point C §3.1 + // measures the zoom distance from. Note the two owners differ in anchor + // LIFETIME by construction: a gesture holds its latch until it goes stale, + // while at rest every tick re-picks through the same pixel — which converges + // on the pointed-at ground point rather than drifting off it. + const pixel: Readonly = gesture?.prevPixel ?? + cursorPx ?? [viewportPx[0] / 2, viewportPx[1] / 2]; + const cursorAnchorM = + latched !== null && !stale + ? latched + : (pickOnBody(cursorRayBodyLocal(arm, pixel, viewportPx, fovYRad), bodyRadiusM)?.pointM ?? + null); + const stepped = anchoredZoomStep(arm, factor, cursorAnchorM, bodyRadiusM); + // A dive at the sky has no ground point to converge over and keeps its + // framing; a dive with one settles about it (pixel-locked). A recession + // settles about the eye and needs no anchor at all. + if (factor < 1 && cursorAnchorM === null) return stepped; + // The pre-notch readout, against the pre-notch reference: the azimuth + // deviation the recession ride preserves rather than re-authoring, and the + // tilt deviation from the band mapping (ruling 12) — what the zoom did NOT + // author, for the capped decay to spend. + const hrPre = Math.hypot(...bodyFixedEyeM(arm)) / bodyRadiusM - 1; + const preInBlendFrame = eyeFrameOf(arm, bodyUpWeight(hrPre, tuning), sceneUpLocal); + const preTiltDevRad = + preInBlendFrame === null + ? null + : preInBlendFrame.tiltRad - mappedTiltRad(rememberedTiltRad, hrPre, tuning); + return settledZoomPose( + stepped, + factor < 1 ? cursorAnchorM : null, + bodyRadiusM, + preTiltDevRad, + sceneUpLocal, + preInBlendFrame?.azimuthRad ?? null, + rememberedTiltRad, + // Priced in the zoom the step is allowed to SPEND, not the folded notch: + // `anchoredZoomStep` moves the eye by the same clamped factor. + Math.abs(Math.log(spentZoomFactor(factor))), + tuning, + ); +} diff --git a/src/utils/camera/tiltFloorBudgetRad.ts b/src/utils/camera/tiltFloorBudgetRad.ts new file mode 100644 index 0000000000..d9adc73336 --- /dev/null +++ b/src/utils/camera/tiltFloorBudgetRad.ts @@ -0,0 +1,42 @@ +/** + * tiltFloorBudgetRad — how much tilt-lowering rotation (about `eastAxis`, + * through `anchorM`) a pose can spend before its displayed tilt crosses 0: + * ruling 14's floor. NOT the tilt itself — the anchor pivot drags the local + * up along, so the through-zero rotation is larger by ~1 + h/R (R13-1). + * Closed form: tilt 0 ⇔ the east component of forward′ × eye′ vanishes, and + * under a rotation by t that component is exactly `P·cos t − Q·sin t + K`. + * Returns 0 when the pose is already at/past the floor, Infinity when no + * rotation about this axis reaches it (no crossing possible, nothing to cap). + */ + +import type { Vec3 } from '../../@types/math/Vec3'; +import { cross3 } from '../math/cross3'; + +function dotWith(v: Readonly, w: Readonly): number { + return v[0] * w[0] + v[1] * w[1] + v[2] * w[2]; +} + +export function tiltFloorBudgetRad( + forward: Readonly, + eyeM: Readonly, + anchorM: Readonly, + eastAxis: Readonly, +): number { + const rel: Vec3 = [eyeM[0] - anchorM[0], eyeM[1] - anchorM[1], eyeM[2] - anchorM[2]]; + const p = dotWith(cross3(forward, anchorM), eastAxis); + const k = dotWith(cross3(forward, rel), eastAxis); + if (p + k <= 0) return 0; // p + k is the measure at t = 0: at/past the floor + const q = dotWith(forward, anchorM); + const m = Math.hypot(p, q); + const c = -k / m; + // No rotation about this axis reaches nadir (|K| > √(P²+Q²)): the lowering + // drag is deliberately uncapped — no crossing exists to guard (R13b-1). + if (Math.abs(c) > 1) return Infinity; + const a = Math.acos(c); + const phi = Math.atan2(q, p); + // Two root families per turn; the budget is the one nearest below 0. + const wrap = (t: number): number => (t > 0 ? t - 2 * Math.PI : t); + const root = Math.max(wrap(a - phi), wrap(-a - phi)); + // A sub-fp-noise root is the just-landed pose re-read: the budget is spent. + return root > -1e-12 ? 0 : -root; +} diff --git a/src/utils/camera/tiltFromNadirRad.ts b/src/utils/camera/tiltFromNadirRad.ts new file mode 100644 index 0000000000..59cef6061f --- /dev/null +++ b/src/utils/camera/tiltFromNadirRad.ts @@ -0,0 +1,12 @@ +/** The view's polar angle off straight-down, [0, π]. Deliberately UNSIGNED: a + * sign needs a rotation axis, which is `tiltFloorBudgetRad`'s question (R13-1). + * One home for `eyeFrameOf` and the two arms' settles. */ + +import type { Vec3 } from '../../@types/math/Vec3'; + +export function tiltFromNadirRad(forward: Readonly, eyeM: Readonly): number { + const mag = Math.hypot(eyeM[0], eyeM[1], eyeM[2]); + if (mag === 0) return 0; // no nadir exists at the centre + const vert = (forward[0] * eyeM[0] + forward[1] * eyeM[1] + forward[2] * eyeM[2]) / mag; + return Math.acos(Math.max(-1, Math.min(1, -vert))); +} diff --git a/src/utils/camera/tiltTurnedPose.ts b/src/utils/camera/tiltTurnedPose.ts new file mode 100644 index 0000000000..dba5e0c2e0 --- /dev/null +++ b/src/utils/camera/tiltTurnedPose.ts @@ -0,0 +1,35 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { bodyFixedEyeM } from './bodyFixedEyeM'; +import { turnedPose } from './turnedPose'; +import { cross3 } from '../math/cross3'; +import { normalize3 } from '../math/normalize3'; +import { quatFromAxisAngle } from '../math/quatFromAxisAngle'; + +/** + * The pose tilted by `dTiltRad` (+ raises the view toward the horizon) about + * the east of forward's own heading — `forward × up̂`, the axis the tilt + * handle drags about, so heading and roll are untouched. The one tilt + * geometry the zoom settle turns by. At exact nadir the axis + * vanishes: a raising turn tips about screen-right (the lerp-in toward a + * remembered tilt); anything else returns the pose BY REFERENCE, which the + * full-pose byte bars pin. + */ +export function tiltTurnedPose( + pose: BodyFixedPose, + dTiltRad: number, + pivotM: Readonly | null, +): BodyFixedPose { + if (dTiltRad === 0) return pose; + const b = pose.basisLocal; + const axisRaw = cross3([b[6], b[7], b[8]], normalize3(bodyFixedEyeM(pose))); + const axisLen = Math.hypot(...axisRaw); + const axis: Vec3 | null = + axisLen > 1e-12 + ? [axisRaw[0] / axisLen, axisRaw[1] / axisLen, axisRaw[2] / axisLen] + : dTiltRad > 0 + ? [b[0], b[1], b[2]] + : null; + if (axis === null) return pose; + return turnedPose(pose, quatFromAxisAngle(axis, dTiltRad), pivotM); +} diff --git a/src/utils/camera/turnedPose.ts b/src/utils/camera/turnedPose.ts new file mode 100644 index 0000000000..7b7d3b58cd --- /dev/null +++ b/src/utils/camera/turnedPose.ts @@ -0,0 +1,18 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { Vec3 } from '../../@types/math/Vec3'; +import type { Vec4 } from '../../@types/math/Vec4'; +import { poseWithBasisTurn } from './poseWithBasisTurn'; +import { rotatedAboutPoint } from './rotatedAboutPoint'; + +/** + * A settle rotation with its pivot as data: about a body-fixed point (the + * whole pose turns — a dive's anchor, the body centre) or, with `null`, about + * the eye (basis only — enforcement and recessions never move the eye, T17). + */ +export function turnedPose( + pose: BodyFixedPose, + q: Readonly, + pivotM: Readonly | null, +): BodyFixedPose { + return pivotM === null ? poseWithBasisTurn(pose, q) : rotatedAboutPoint(pose, q, pivotM); +} diff --git a/src/utils/camera/unmappedTiltRad.ts b/src/utils/camera/unmappedTiltRad.ts new file mode 100644 index 0000000000..96dcfd05e5 --- /dev/null +++ b/src/utils/camera/unmappedTiltRad.ts @@ -0,0 +1,13 @@ +/** Inverse of `mappedTiltRad`, beside it so map and un-map cannot diverge + * (R12-2) — this one writes the MEMORY. Callers guard w → 0, where it blows up. */ + +import type { CameraTuning } from '../../@types/camera/CameraTuning'; +import { bodyUpWeight } from './bodyUpWeight'; + +export function unmappedTiltRad( + displayTiltRad: number, + hOverR: number, + tuning: CameraTuning, +): number { + return displayTiltRad / bodyUpWeight(hOverR, tuning); +} diff --git a/src/utils/camera/updatePosition.ts b/src/utils/camera/updatePosition.ts index 725efd99da..867a006c67 100644 --- a/src/utils/camera/updatePosition.ts +++ b/src/utils/camera/updatePosition.ts @@ -1,62 +1,12 @@ -/** - * updatePosition — derive an orbit camera's world-space position from its - * spherical state (yaw, pitch, distance, target). - * - * This is the heart of the orbit-camera math: a right-handed, Y-up - * spherical-to-Cartesian conversion. It is intentionally free of any browser - * or WebGPU dependency so it can run in a plain Node/Vitest environment. - */ - -import { vec3 } from 'wgpu-matrix'; import type { OrbitCamera } from '../../@types/camera/OrbitCamera'; -import type { Vec3 } from '../../@types/math/Vec3'; -import { yawPitchToDir } from './yawPitchToDir'; -import { rotateVec3ByTightMat3 } from '../math/rotateVec3ByTightMat3'; - -// Module-scope scratch reused every call so the per-frame path never allocates. -// `scratchDir` holds the frame-local decode; `scratchWorld` holds it rotated -// into world by `poseBasis` (a separate buffer because the matrix–vector -// product reads all three input components while writing the output). -const scratchDir: Vec3 = [0, 0, 0]; -const scratchWorld: Vec3 = [0, 0, 0]; +import { eyeMpcOf } from './eyeMpcOf'; /** - * Recompute `cam.position` from the current yaw, pitch, distance, and target. - * - * Call this every time you mutate `cam.yaw`, `cam.pitch`, `cam.distance`, or - * `cam.target`. Typically the controls module calls this after processing a - * mouse or touch event. - * - * ### The math - * - * The (yaw, pitch) → unit direction decode lives in `yawPitchToDir` (the shared - * spherical-to-Cartesian core). Here we simply place the eye along that - * direction: position = target + distance · dir. - * - * (This is `vec3.addScaled`: dst = a + b*scale.) - * - * ### Orientation frame - * - * `yawPitchToDir` decodes into the camera's *frame-local* space, whose zenith is - * local +Y. `rotateVec3ByTightMat3` rotates that direction into world by - * `cam.poseBasis` (dir_world = poseBasis · dir_local), or passes it through - * unchanged when no basis is set — see that module for why the registry's - * TIGHT 9-float `Mat3` can't go through wgpu-matrix's `vec3.transformMat3`. - * Absent a basis the path stays exactly the pre-feature `yawPitchToDir` → - * `addScaled` two-liner (byte-identical for every caller that never sets a - * frame). Deliberately `poseBasis`, not `upBasis`: this decode must stay - * pinned to the steady committed frame even while `upBasis` mid-slerps during - * an orientation switch (see `OrbitCameraInit.d.ts`). - * - * @param cam The camera to update in-place. + * Recompute `cam.position` from the current yaw, pitch, distance and target — + * call after mutating any of them. `eyeMpcOf` owns the whole derivation, so the + * regime predicate and this camera read one eye, not two; `cam.position` is + * passed as its `out` to keep this per-frame path allocation-free. */ export function updatePosition(cam: OrbitCamera): void { - // Unit direction from target toward camera in frame-local space, then - // rotated into world — both written into module scratch so the per-frame - // path stays allocation-free. - const dir = yawPitchToDir(cam.yaw, cam.pitch, scratchDir); - const world = rotateVec3ByTightMat3(dir, cam.poseBasis, scratchWorld); - // position = target + distance*world. vec3.addScaled(a, b, scale, dst) - // computes dst = a + b*scale. - vec3.addScaled(cam.target, world, cam.distance, cam.position); + eyeMpcOf(cam, cam.poseBasis, cam.position); } diff --git a/src/utils/camera/zoomedDistance.ts b/src/utils/camera/zoomedDistance.ts index c4645b0b49..29b18e88fa 100644 --- a/src/utils/camera/zoomedDistance.ts +++ b/src/utils/camera/zoomedDistance.ts @@ -1,25 +1,14 @@ /** - * zoomedDistance — apply a wheel/pinch zoom factor as a step in ALTITUDE above - * a pivot's surface, not in raw distance from its centre. - * - * Scaling `cam.distance` (to the orbit TARGET) by a constant factor per notch - * is right in deep space, but a focused body's target is its CENTRE, so near - * the surface `distance` is dominated by the body's own radius — a 10% notch - * at Earth is ~637 km, several times the whole usable altitude band above the - * standoff floor (`SURFACE_STANDOFF_RADII`). - * - * The fix scales ALTITUDE geometrically instead: with `h = distance - - * pivot.radiusMpc`, `distance' = pivot.radiusMpc + h * factor`. As `h → 0` the - * steps shrink without bound (the Google Earth model). For `h >> - * pivot.radiusMpc` (every astronomical viewing distance) this degenerates to - * the old model exactly — `pivot.radiusMpc + h * factor ≈ distance * factor` — - * so deep-space feel is unchanged and the taper only engages on final - * approach. `pivot.radiusMpc === null` (no surface — empty space, a galaxy, a - * structure, the Milky Way) degenerates to it exactly too. - * - * `clampDistance` is called from inside here, not by the caller, so the - * envelope stays enforced in exactly one place — `pivot.floorMpc` is already - * the standoff-and-MIN-adjusted floor, so it forwards straight through. + * Apply a wheel/pinch zoom factor as a step in ALTITUDE above a pivot's + * surface, not in raw distance from its centre: with `h = distance − + * pivot.radiusMpc`, `distance′ = radiusMpc + h · factor`, so steps shrink + * without bound as `h → 0`. Scaling `distance` directly is right in deep space, + * but a focused body's target is its CENTRE, so near the surface `distance` is + * dominated by the body's radius — a 10% notch at Earth is ~637 km, several + * times the whole usable band above the standoff floor. For `h ≫ radiusMpc`, + * and for `radiusMpc === null` (no surface), this degenerates to plain scaling + * exactly. `clampDistance` is called from in here, not by the caller, so the + * envelope is enforced in one place. */ import { clampDistance } from './clampDistance'; @@ -33,10 +22,14 @@ export function zoomedDistance(distance: number, factor: number, pivot: PivotFra const h = distance - radiusMpc; if (h <= 0) { - // Degenerate: `clampDistance` should prevent the camera reaching the - // surface at all. Fall back to plain proportional scaling rather than - // invent a geometric taper for a zero/negative altitude. - return clampDistance(distance * factor, floorMpc); + // The pose is not orbiting this pivot's centre: a pose that left the body + // arm carries a range along its VIEW RAY (`poseFrameConversion.ts: + // toWorldArm`), which a bigger body's floor can exceed. No altitude exists + // to taper, so scale plainly — and floor at the range we were GIVEN, + // because clamping UP to `floorMpc` teleports the eye outward by ~a body + // radius on the first notch. Never ratcheting outward keeps the floor's + // intent (never get closer inside the envelope) without the jump. + return clampDistance(distance * factor, Math.min(floorMpc, distance)); } return clampDistance(radiusMpc + h * factor, floorMpc); diff --git a/src/utils/math/dot3.ts b/src/utils/math/dot3.ts new file mode 100644 index 0000000000..6d245f9218 --- /dev/null +++ b/src/utils/math/dot3.ts @@ -0,0 +1,5 @@ +import type { Vec3 } from '../../@types/math/Vec3'; + +export function dot3(a: Readonly, b: Readonly): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} diff --git a/src/utils/math/reorthonormalise.ts b/src/utils/math/reorthonormalise.ts index e15a6ea18c..418c780595 100644 --- a/src/utils/math/reorthonormalise.ts +++ b/src/utils/math/reorthonormalise.ts @@ -1,21 +1,19 @@ /** - * reorthonormalise — pull a nearly-orthonormal column-major `Mat3` back - * onto the rotation manifold with a single Gram-Schmidt pass over its - * columns. + * Pull a nearly-orthonormal column-major `Mat3` back onto the rotation manifold + * with a single Gram-Schmidt pass over its columns. * - * Successive matrix builds and multiplications each accumulate ~1e-16 FP - * error per element; left unchecked the column dot products can drift to - * ~1.4e-6 — just outside the 5e-7 bound rotation tests enforce. One pass - * pulls it back below 1e-15. + * Successive builds and multiplications each accumulate ~1e-16 FP error per + * element; left unchecked the column dot products drift to ~1.4e-6, just + * outside the 5e-7 bound rotation tests enforce. One pass pulls it below 1e-15. * - * Columns are the natural Gram-Schmidt target here because each column is - * contiguous in memory and represents one image axis of the rotation. + * Column 2 is rebuilt as `c0 × c1`, so the input triple must be RIGHT-handed: + * an `imagePlaneBasis` (right, up, forward) triple is left-handed and comes + * back silently mirrored — pass that one as (forward, up, right). */ import type { Mat3 } from '../../@types/math/Mat3'; export function reorthonormalise(m: Mat3): Mat3 { - // Column 0: normalise as-is. let c0x = m[0]!, c0y = m[1]!, c0z = m[2]!; @@ -24,7 +22,6 @@ export function reorthonormalise(m: Mat3): Mat3 { c0y /= n0; c0z /= n0; - // Column 1: subtract projection onto column 0, then normalise. let c1x = m[3]!, c1y = m[4]!, c1z = m[5]!; @@ -37,7 +34,6 @@ export function reorthonormalise(m: Mat3): Mat3 { c1y /= n1; c1z /= n1; - // Column 2: recompute as c0 × c1 (avoids accumulated error). const c2x = c0y * c1z - c0z * c1y; const c2y = c0z * c1x - c0x * c1z; const c2z = c0x * c1y - c0y * c1x; diff --git a/src/utils/math/rotateVec3ByTightMat3T.ts b/src/utils/math/rotateVec3ByTightMat3T.ts new file mode 100644 index 0000000000..e18a127f79 --- /dev/null +++ b/src/utils/math/rotateVec3ByTightMat3T.ts @@ -0,0 +1,16 @@ +/** + * `Mᵀ·v` for a TIGHT column-major 3×3 rotation — the world→local inverse of + * `rotateVec3ByTightMat3` (orthonormal, so the transpose is the inverse). Each + * output component is the dot of a COLUMN with `v`. + */ + +import type { Mat3 } from '../../@types/math/Mat3'; +import type { Vec3 } from '../../@types/math/Vec3'; + +export function rotateVec3ByTightMat3T(v: Readonly, m: Readonly): Vec3 { + return [ + m[0] * v[0] + m[1] * v[1] + m[2] * v[2], + m[3] * v[0] + m[4] * v[1] + m[5] * v[2], + m[6] * v[0] + m[7] * v[1] + m[8] * v[2], + ]; +} diff --git a/src/utils/math/wrapRad.ts b/src/utils/math/wrapRad.ts new file mode 100644 index 0000000000..ee53e7eba3 --- /dev/null +++ b/src/utils/math/wrapRad.ts @@ -0,0 +1,4 @@ +/** The angle wrapped to (−π, π] — `atan2(sin, cos)`, so the seam needs no branch. */ +export function wrapRad(rad: number): number { + return Math.atan2(Math.sin(rad), Math.cos(rad)); +} diff --git a/tests/components/DebugPanel/CameraBandBar.test.ts b/tests/components/DebugPanel/CameraBandBar.test.ts new file mode 100644 index 0000000000..ab0f06cc39 --- /dev/null +++ b/tests/components/DebugPanel/CameraBandBar.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment jsdom + +/** + * CameraBandBar — the ruler is LOG, and a marker off the domain pins to the end + * rather than escaping the strip: linear placement would bunch the whole band + * against the left edge, and an unclamped marker would render outside the box + * with no hint that it is off-scale. + */ + +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { createElement } from 'react'; + +import CameraBandBar from '../../../src/components/DebugPanel/CameraBandBar'; + +function leftPercents(container: HTMLElement): number[] { + return [...container.querySelectorAll('[style*="left"]')].map((el) => + Number.parseFloat(el.style.left), + ); +} + +describe('CameraBandBar', () => { + it('spaces equal h/R ratios equally', () => { + const { container } = render( + createElement(CameraBandBar, { + ticks: [ + { label: 'a', hOverR: 0.1 }, + { label: 'b', hOverR: 1 }, + { label: 'c', hOverR: 10 }, + ], + hOverR: null, + markerLabel: '', + }), + ); + const [a, b, c] = leftPercents(container); + expect(b! - a!).toBeCloseTo(c! - b!, 6); + }); + + it('pins an off-domain marker to the end and flags it', () => { + const { container } = render( + createElement(CameraBandBar, { + ticks: [ + { label: 'a', hOverR: 0.1 }, + { label: 'b', hOverR: 1 }, + ], + hOverR: 1e6, + markerLabel: 'far', + }), + ); + expect(leftPercents(container).at(-1)).toBe(100); + expect(container.textContent).toContain('› far'); + }); +}); diff --git a/tests/components/DebugPanel/CameraStateSection.test.ts b/tests/components/DebugPanel/CameraStateSection.test.ts new file mode 100644 index 0000000000..9b595b57ab --- /dev/null +++ b/tests/components/DebugPanel/CameraStateSection.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment jsdom + +/** + * CameraStateSection — the two contracts the rebuild is built on: degrees on + * screen but full-precision RADIANS in `copy all` off the SAME model (a paste + * that disagrees with the screen is worse than no paste), and grill Q8's + * north-up-off rule — the field's target stays on the row, marked, rather than + * blanking to an em-dash. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, fireEvent } from '@testing-library/react'; +import { configureStore } from '@reduxjs/toolkit'; +import { createElement, type ReactNode } from 'react'; +import { Provider } from 'react-redux'; + +import CameraStateSection from '../../../src/components/DebugPanel/CameraStateSection'; +import { rootReducer } from '../../../src/store/rootReducer'; +import { setCameraTuning } from '../../../src/state/camera/cameraSlice'; +import type { CameraDebugSnapshot } from '../../../src/@types/camera/CameraDebugSnapshot'; +import { QUIET_CAMERA_DEBUG_SNAPSHOT } from '../../fixtures/camera/quietCameraDebugSnapshot'; + +afterEach(() => { + delete (navigator as { clipboard?: unknown }).clipboard; +}); + +/** 0.5235987755982988 rad = 30.0°: the degrees column and the radian dump differ visibly. */ +const HEADING_RAD = Math.PI / 6; + +const SNAP: CameraDebugSnapshot = { + ...QUIET_CAMERA_DEBUG_SNAPSHOT, + hOverR: 0.3, + altitudeM: 1_911_300, + bandUpWeight: 0.5, + dofs: { + bodyId: null, + hOverR: 0.3, + heading: { currentRad: HEADING_RAD, targetRad: 0, residualRad: HEADING_RAD }, + tilt: { currentRad: 0, targetRad: 0, residualRad: 0 }, + // Roll's target is independent of its current, so a blanked target cell shows. + roll: { currentRad: 0.1, targetRad: -0.2, residualRad: 0.3 }, + }, + deltas: { + ...QUIET_CAMERA_DEBUG_SNAPSHOT.deltas, + heading: { deltaRad: 0, peakAbsRad: HEADING_RAD }, + }, +}; + +function renderSection(northUp = true) { + const store = configureStore({ reducer: rootReducer }); + store.dispatch(setCameraTuning({ northUp })); + return render(createElement(CameraStateSection, { cameraDebug: () => SNAP }), { + wrapper: ({ children }: { children: ReactNode }) => + createElement(Provider, { store, children }), + }); +} + +describe('CameraStateSection', () => { + it('shows degrees on screen and dumps the same numbers as full-precision radians', async () => { + const writeText = vi.fn((_text: string) => Promise.resolve()); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + + const { getByText, container } = renderSection(); + expect(container.textContent).toContain('30.0°'); + expect(container.textContent).not.toContain(String(HEADING_RAD)); + + fireEvent.click(getByText('copy all')); + const dump = writeText.mock.calls[0]![0]; + expect(dump).toContain(`heading: current=${HEADING_RAD}`); + expect(dump).toContain(`peak=${HEADING_RAD}`); + // Degrees are a screen affordance only — a pasted report must not carry them. + expect(dump).not.toContain('30.0°'); + }); + + it('keeps the heading/roll targets on the row, marked, while north-up is off', () => { + const { container } = renderSection(false); + expect(container.textContent).toContain('(off)'); + // Q8: the target is a property of the field, not of whether it is applied — + // the roll row keeps both its target and its residual, not an em-dash. + expect(container.textContent).toContain('-11.5°'); + expect(container.textContent).toContain('17.2°'); + }); +}); diff --git a/tests/components/DebugPanel/DebugPanel.test.ts b/tests/components/DebugPanel/DebugPanel.test.ts index 154ab83894..53ef3eb1fd 100644 --- a/tests/components/DebugPanel/DebugPanel.test.ts +++ b/tests/components/DebugPanel/DebugPanel.test.ts @@ -1,22 +1,9 @@ // @vitest-environment jsdom /** - * DebugPanel — store-backed integration test. - * - * Verifies that the sections DebugPanel mounts (each via its own container) - * round-trip through the store: - * - reads the `pick-buffer` overlay out of the store and reflects it on the matching checkbox; - * - dispatches `setDebugOverlay({ key: 'pick-buffer', enabled })` when the checkbox is toggled; - * - routes a RenderTogglesSection checkbox click through `onTogglePass` → `setPassDisabled`; - * - routes the galaxy-provenance table's highlight checkbox and cull `