diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index dd2ecfb7e4..8c59b661fb 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -35,6 +35,9 @@ Items with a **→ details** link have a full write-up in [`backlog/`](backlog/) ## Engine & State +- [ ] **`DragMode` consumers test `=== 'pan'`, a new variant falls silently into orbit** `ready` — five sites hand-pick one string literal instead of an exhaustive switch, and `orbitControls.ts` keeps its own local `DragMode` type distinct from the shared one. → [details](backlog/2026-09-29-dragmode-binary-pan-tests.md) +- [ ] **Takeover's camera-cluster merge wipes any field it doesn't set** `ready` — `runTakeoverSaga` merges a partial `camera` cluster, and `mergeSettingsSnapshot` replaces whole clusters rather than fields, so any `CameraSettings` field added later is dropped for the duration of every takeover. → [details](backlog/2026-09-29-takeover-replaces-camera-cluster.md) +- [ ] **`DEFAULT_VOLUME_PALETTE_ID`'s comment claims persistence that doesn't exist** `ready` — the comment says the palette choice is persisted to localStorage by the App shell via `setVolumePalette`; neither the storage call nor that symbol exists anywhere in `src`. → [details](backlog/2026-09-29-stale-palette-persisted-comment.md) - [ ] **Derive `SettingsSnapshot` from the Layer registry** `needs-design` — the takeover's captured-cluster list is a hand-written `Pick<>` a new Layer must remember to join; two Layers didn't and were restored-over until #793 patched them by hand. → [details](backlog/2026-09-21-derive-settings-snapshot.md) - [ ] **`SCENE_ANCHORS`/`AnchorBody` is the place table, filed under a body name** `needs-design` — the black-hole prep's first non-body row (`GALACTIC_CENTRE_ANCHOR`, a `PlaceId`) exposed that this table was never body-only, just never used for anything else. → [details](backlog/2026-09-22-scene-anchors-are-places.md) - [ ] **The solar system hangs off the Sun; it could hang off a place** `deferred` — a `'solar-system'` `PlaceId` at the heliocentre would re-key the eleven `focusId: 'sun'` element rows and both Sun-anchored regions the way R15 did for the Galactic Centre; no reader needs it until a barycentric ephemeris, a moving render origin, or the seeded stars leaving the body tables. → [details](backlog/2026-09-23-solar-system-place.md) diff --git a/docs/backlog/2026-09-29-dragmode-binary-pan-tests.md b/docs/backlog/2026-09-29-dragmode-binary-pan-tests.md new file mode 100644 index 0000000000..cd7d41273d --- /dev/null +++ b/docs/backlog/2026-09-29-dragmode-binary-pan-tests.md @@ -0,0 +1,41 @@ +# `DragMode` consumers test `=== 'pan'`, a new variant falls silently into orbit + +`ready`. + +## What is true today + +- `src/@types/camera/DragMode.d.ts` — the shared type is `'orbit' | 'pan'`. +- `src/services/camera/orbitControls.ts:21` declares a second, local + `DragMode = 'orbit' | 'pan' | 'pinch'` — the recognizer's own type, not an + import of the shared one. Line 60 sets `dragMode` to `'pan'` or `'orbit'` + from button state; line 71 sets it to `'pinch'` for a second contact; line + 90 gates click-to-pick on `endedMode === 'orbit'` specifically, so pan and + pinch are both (correctly, today) excluded — but only because someone + enumerated `'orbit'` by hand, not because the switch is exhaustive. +- `src/services/camera/applyInputToCamera.ts:49` — `if (step.mode === 'pan') { ... }` + with no accompanying `else if (step.mode === 'orbit')`; anything that is not + `'pan'` falls through to the orbit math after the block. +- `src/utils/camera/latchSurfaceGesture.ts:39` — same shape: + `if (step.mode === 'pan') { ... }`, tilt-handle logic follows unconditionally + for every other mode. +- `src/services/engine/camera/replayInput.ts:140` — + `step.kind === 'drag' && step.mode === 'pan' && bodyMovesThisFrame(focus)` + gates the followed-body strafe path; any other mode skips it silently. +- `src/utils/camera/steppedSitePose.ts` — `InputStep`'s `drag` variant carries + a `mode: DragMode` field (`src/@types/camera/InputStep.d.ts`), but this file + never reads `.mode` at all: every `kind === 'drag'` step drives the + turntable orbit math, so a `'pan'` (or future) mode on the site rung has no + distinct effect here — it is not tested and not routed, just absorbed. + +None of these five sites use a switch or `satisfies never`; each hand-picks +one string literal (`'pan'` or `'orbit'`) and treats the rest of the type as +the other case by omission. Adding a third member to the shared `DragMode` +compiles cleanly and silently reuses the orbit (or no-op) branch everywhere. + +## What would fix it + +Make the shared `DragMode` the only `DragMode` — drop the local redeclaration +in `orbitControls.ts` — and replace each `if (step.mode === 'pan')` with an +exhaustive `switch (step.mode)` (or an equivalent `satisfies never` default) +so a new variant is a compile error at every consumer, including +`steppedSitePose.ts`, until each site has decided what it does. diff --git a/docs/backlog/2026-09-29-stale-palette-persisted-comment.md b/docs/backlog/2026-09-29-stale-palette-persisted-comment.md new file mode 100644 index 0000000000..7b91373760 --- /dev/null +++ b/docs/backlog/2026-09-29-stale-palette-persisted-comment.md @@ -0,0 +1,34 @@ +# `DEFAULT_VOLUME_PALETTE_ID`'s comment claims persistence that doesn't exist + +`ready`. + +## What is true today + +- `src/data/defaults.ts:114-119` — + ``` + /** + * Default renderer-wide palette LUT for the scalar-volume overlay. + * 'viridis' is matplotlib's perceptually-uniform default — neutral + * blue-green-yellow ramp that reads as "scientific" without leaning + * warm or cool. Mutated at runtime via `setVolumePalette`; persisted + * to localStorage by the App shell so reloads keep the user's choice. + */ + export const DEFAULT_VOLUME_PALETTE_ID = 'viridis' as const; + ``` +- `grep -rn "localStorage" src` restricted to palette-related files finds + nothing; there is no `localStorage` call anywhere near `volumePalette`, + `setVolumePalette`, or `DEFAULT_VOLUME_PALETTE_ID`. +- `grep -rln "volumePalette" src` returns only `src/data/defaults.ts` itself + (the comment's own line) — no consumer under `src/components/` or an App + shell file reads or writes a persisted palette choice. + +The "Mutated at runtime via `setVolumePalette`" half also does not resolve: +there is no `setVolumePalette` symbol anywhere in `src`. The palette choice +resets to `DEFAULT_VOLUME_PALETTE_ID` on every reload; nothing persists it. + +## What would fix it + +Delete the "persisted to localStorage by the App shell" sentence (and the +`setVolumePalette` reference, since that symbol doesn't exist) from the +comment, or — if persistence is actually wanted — add it and let the comment +describe real behavior. diff --git a/docs/backlog/2026-09-29-takeover-replaces-camera-cluster.md b/docs/backlog/2026-09-29-takeover-replaces-camera-cluster.md new file mode 100644 index 0000000000..889e6c2634 --- /dev/null +++ b/docs/backlog/2026-09-29-takeover-replaces-camera-cluster.md @@ -0,0 +1,40 @@ +# Takeover's camera-cluster merge wipes any field it doesn't set + +`ready`. + +## What is true today + +- `src/state/takeover/runTakeoverSaga.ts:29` — + `yield* put(mergeSnapshot({ camera: { fovDeg: DEFAULT_FOV_DEG } }));` + is the only field the takeover bracket sets on `camera` before running the + body. +- `src/state/settings/mergeSettingsSnapshot.ts:38-42` — + `mergeSettingsSnapshot` does + `return { ...state, ...(structuredClone(patch) as Partial) };`. + The spread is top-level only: a cluster present on the patch (`camera`) + replaces the entire cluster object wholesale, it does not merge field by + field into the existing cluster. The file's own header calls this out as + intentional ("replace each cluster the snapshot carries, leave the rest + untouched") for the tour-restore use case, where the snapshot always carries + every field of a cluster it touches. +- `runTakeoverSaga`'s patch is `{ camera: { fovDeg: DEFAULT_FOV_DEG } }` — + a partial `camera` cluster with only `fovDeg` set. Given the merge + semantics above, this call replaces `settings.camera` with an object that + has only `fovDeg`; every other current field of `CameraSettings` is + dropped, not preserved. + +So any field added to `CameraSettings` after this call was written is wiped +to `undefined` for the duration of every takeover (tour or exhibit), unless +that field happens to also be reasserted somewhere else in the same body. +The comment in `runTakeoverSaga.ts` describes this as merging the FOV back +"after this" for a body with different needs, but doesn't address that the +call already destroyed the rest of `camera` before the body runs. + +## What would fix it + +Either read the current `camera` cluster and spread it before overriding +`fovDeg` (`mergeSnapshot({ camera: { ...currentCamera, fovDeg: DEFAULT_FOV_DEG } })`), +or change `mergeSettingsSnapshot` to merge per-field within a cluster instead +of replacing the cluster object — the latter is the more general fix given +the header already documents cluster-replace as a deliberate, narrow choice +for the snapshot-restore path specifically. diff --git a/docs/superpowers/plans/completed/2026-09-29-openspace-camera-prep.ledger.md b/docs/superpowers/plans/completed/2026-09-29-openspace-camera-prep.ledger.md new file mode 100644 index 0000000000..d08133fc8f --- /dev/null +++ b/docs/superpowers/plans/completed/2026-09-29-openspace-camera-prep.ledger.md @@ -0,0 +1,34 @@ +# SDD ledger — plan: docs/superpowers/plans/2026-09-29-openspace-camera-prep.md + +Spec: docs/superpowers/specs/2026-09-29-openspace-camera-mode-design.md. Draft PR #830. Branch worktree-openspace-camera-mode, BASE f30ef5d57. +Asks (AqJE): approved; dispatches = camera chain D1 (T1+T2) → D2 (T3) → D3 (T4+T5), D4 (T6) in parallel with the chain; no perf gate. +Pre-flight: T1 & T3 both edit cameraDrivers.ts (disjoint lines, serial chain). T6 files are disjoint from T1–T5. No conflicts. + +D1 (T1+T2) opus, BASE f30ef5d57 — dispatched +D4 (T6) opus, BASE f30ef5d57 — dispatched in parallel (file-fenced) +D4 (T6) done 8b8dd0fbe, opus 35 tool uses; deviations: methods-not-arrows in PersistedValue (variance), splashStorage.test deleted (moved). Review dispatched. +Task 6: complete (8b8dd0fbe, review clean). IDE togglePalette error was stale. +Task 6: minor (deferred): persistValues.test.ts:73-76 'skips null' test near-restates code — final review to triage +Task 6: minor (deferred): PersistedValue methods drop readonly (variance, commented) +D1 done: T1 d32c5302e, T2 e3624415f (opus, 21 tool uses); deviation: commitOnEdge.test.ts compile fallout. Typecheck clean at e3624415f. +Task 2: complete (e3624415f, not review-tagged) +Task 1: complete (d32c5302e, review clean; sonnet reviewer) +D2 (T3) done 0cc45ee66 opus. Deviations: ':180 tween' is actually followApproach ease; tweenToClip/evaluateClip DROP offset (focus tween snaps?); shell renderers switched to orbitForwardOf (f32 round-off at zero offset); approachTiltedPose carries (implementer ruling). +D3 (T4+T5) opus dispatched at 0cc45ee66, in parallel with the T3 review (fenced) +Task 3: review Spec ❌ — tween row snaps lookOffset (tweenToClip drops it); misnamed test. Fix round 1 → resumed T3 implementer. +Task 3: minor (deferred): shell renderers → orbitForwardOf is ulp-level, sound (old subtraction was the wrong one) +PR 2 note: nothing clamps orbit pitch + offset pitch past zenith (imagePlaneBasis flips); approachTiltedPose tilt reads τ+offset pitch then engage drops offset (one-time snap). +Task 3: fix round 1 → 6abea10ef (tween row eases offset by 1−easeOutCubic; real tween test). Task 3: complete (0cc45ee66..6abea10ef; lean protocol: one round, no re-review) +D3 done: T4 341a02f4e, T5 9be5211a4 (opus). Deviations a–g in agent reply: PITCH_LIMIT→data/camera/pitchLimit.ts; SurfaceStepCtx extracted, no NudgeCtx; world/site nudge synthesize pixel steps; body roll uses 'strafe' settle (roll beats look's tilt write); orbit [0,0] is non-identity. +Ruling: T4+T5 mid-branch review folded into the final whole-branch review (last tasks; one reviewer reads the same diff with both task contracts) — saves one review seat — cost if wrong: a T4/T5 defect gets one fix round instead of two. +PR 2 note: navigator must omit zero axes from ArmDelta (orbit [0,0] breaks by-reference identity). +CI run 36735151350 RED: 1/1425 files — noInlineTypes ratchet still allow-lists surfaceStep.ts (fixed by extraction). +Final review (opus): 1C 2I 7M → final-review.md. Fix wave dispatched (opus): C1 ratchet line, I1 world nudge zoom via absoluteRung zoom path (frameAlignedRoll), I2 body roll applied after settle (reject deviation d), M1, restating tests, spec drift. +Ruling: delete PersistedValue.skip + its test (M1) — no reducer writes null, dead guard carried from persistSplashVersion — cost if wrong: a null seenVersion would be written as 'null' and parse back to null (harmless). +Ruling: reject T5 deviation (d) roll-forces-strafe; roll applied after the orbit/look settle — per final review I2 — cost if wrong: none identified (roll moves neither eye nor forward). +Fix wave: 1130007e9 (code) + f957d03d1 (spec). Lean protocol: no re-review; CI is the gate. Pushed. +CI GREEN on f957d03d1 (run 36736620587). Diff vs f30ef5d57: src +673/−252 (50 files), tests +536/−137 (18), docs +11/−11. Awaiting user smoke → /feature-done → merge on word. +Dev server :5173 (shell bgsf23zx4) running from this wt for the smoke check. Pending dash ask onhC (smoke). Next on 'all unchanged': /feature-done (no deletion audit on prep PR) → squash-merge #830 on explicit word → PR 2 plan (writing-plans) from spec §4–§10 + the PR 2 notes above. +Task 4: complete (341a02f4e; reviewed in final review) +Task 5: complete (9be5211a4 + fix 1130007e9; reviewed in final review) +Smoke: user attested 2026-10-04 (all unchanged). /feature-done READY: 1422 files / 14891 tests pass, typecheck clean. Plan moved to completed/; spec stays in specs/ for PR 2. diff --git a/docs/superpowers/plans/completed/2026-09-29-openspace-camera-prep.md b/docs/superpowers/plans/completed/2026-09-29-openspace-camera-prep.md new file mode 100644 index 0000000000..eee22a6032 --- /dev/null +++ b/docs/superpowers/plans/completed/2026-09-29-openspace-camera-prep.md @@ -0,0 +1,241 @@ +# OpenSpace camera mode — PR 1 ground preparation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development under the lean protocol in `docs/superpowers/conventions/sdd-execution.md`. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Create the five joints the OpenSpace control scheme needs (P1–P5), all behaviour-neutral: the skymap scheme renders, drives and commits exactly as before. + +**Architecture:** +- The driver table's `isActive` gets an activity bag. +- The driver table moves behind a one-row control-scheme registry. +- `CameraPose` gains an optional `lookOffset` that turns only the view. +- The body arm's orbit/look solves and the post-drag settle come out of the pixel path. +- Every rung gets a `nudge` column that takes a pixel-free `ArmDelta`. +- A generic persisted-value table replaces the splash-only localStorage writer. + +**Tech stack:** TS, Vitest, RTK (no react-redux), wgpu-matrix. + +**Spec:** [`docs/superpowers/specs/2026-09-29-openspace-camera-mode-design.md`](../specs/2026-09-29-openspace-camera-mode-design.md), §3 (Ground preparation) and §8 (`lookOffset`). + +## Global constraints + +- Behaviour-neutral. No existing test changes its expected values. The camera goldens (`tests/**/golden*`) stay byte-identical. +- `type` aliases, never `interface`. One type per `@types/` file, one function per `utils/` file, with filename = symbol. +- Frame files (`src/services/engine/frame/**`) export only their named symbol. +- File moves use `npm run move-files -- `, never `git mv`. +- Comments explain why, never what: module header ≤ 10 lines, comment lines ≤ half the code lines. +- Each prep item is its own commit (or a pair, for P1). Format with `npx prettier --write `, never `npm run format`. +- No `Co-Authored-By` lines in commits. + +## Review focus + +1. **Nudge vs drag parity.** A `nudge` with the screen-angle equivalent of a pixel drag must produce the same pose as that drag, on all three arms. A unit slip (px vs rad, or a sign) shows only as a camera that moves too fast or backwards. Pinned by the parity tests in Task 5. +2. **`lookOffset = 0` must be exactly neutral.** An absent offset and `[0, 0]` must give byte-identical view matrices, or every golden drifts. Pinned in Task 3. +3. **Body-arm settle extraction.** Floor, level with held azimuth, and the tilt-memory write must run in the same order as today. The existing `surfaceStep.test.ts` and `rememberedTilt.test.ts` must pass untouched; they are the gate. +4. **Splash persistence regression.** A returning user whose splash is dismissed must not see it again, and a private-mode storage throw must not break boot. Pinned in Task 6. +5. **URL round-trip with no offset.** An existing `#pose=` link (without offset fields) must decode unchanged, and a pose with zero offset must encode to exactly today's string. Pinned in Task 3. + +--- + +### Task 1 (P2): Driver activity bag — `review: yes` + +**Files:** +- Create: `src/@types/engine/camera/DriverActivity.d.ts` +- Modify: `src/@types/engine/camera/CameraDriver.d.ts:28` +- Modify: `src/services/engine/camera/cameraDrivers.ts:46-58` (`pickWinner`) and `:275` (`followApproach.isActive`) +- Modify: `src/services/engine/camera/stepCameraRuntime.ts:163-167` +- Modify: `tests/services/engine/camera/cameraDrivers.test.ts`, `tests/services/engine/camera/stepCameraRuntime.test.ts` (call-site updates only) + +**Contract:** +```ts +export type DriverActivity = { readonly approachDone: boolean }; +isActive(s: RootState, activity: DriverActivity): boolean; // was (s, approachDone?) +pickWinner(drivers, s: RootState, activity: DriverActivity): CameraDriver; +``` +`stepCameraRuntime` builds `{ approachDone }` once, where today it computes `approachDone`, and passes it to `pickWinner`. The activity is required; no row defaults it. + +- [x] Change the type and every call site; `followApproach` reads `activity.approachDone`. +- [x] No new test: this is a signature change the compiler checks. The existing driver tests are the gate. +- [x] `npm run typecheck:fast && npm test -- camera` is green. Commit: `refactor(camera): driver activity bag replaces approachDone arg`. + +### Task 2 (P3): Control-scheme registry + +**Files:** +- Create: `src/@types/engine/camera/ControlSchemeId.d.ts` +- Create: `src/@types/engine/camera/ControlScheme.d.ts` +- Create: `src/services/engine/camera/controlSchemes.ts` +- Modify: `src/@types/engine/frame/RunFrameDeps.d.ts:40-45`, `src/services/engine/frame/runFrame.ts:134`, `src/services/engine/phases/startLoop.ts:47` +- Modify: `tests/helpers/camera/simulateCameraFrame.ts`, `tests/helpers/camera/makeCameraSimHarness.ts`, `tests/services/engine/frame/runFrame.test.ts` (fixture shape only) + +**Contract:** +```ts +export type ControlSchemeId = 'skymap'; // PR 2 widens it +export type ControlScheme = { readonly drivers: readonly CameraDriver[] }; +export const CONTROL_SCHEMES: Readonly> = + { skymap: { drivers: CAMERA_DRIVERS } }; +// RunFrameDeps: `drivers` → `controlSchemes: Readonly>` +``` +- `runFrame` resolves `deps.controlSchemes.skymap.drivers` into `StepInputs.drivers`, which is unchanged. PR 2 swaps the literal key for the settings read. +- A fixture overrides the table as `{ skymap: { drivers: [...] } }`. + +- [x] Implement. `runFrame.ts` still exports only `runFrame` (frame purity ratchet: `tests/services/engine/frame/frameFilePurity.test.ts`). +- [x] No new test: plumbing only. +- [x] Green. Commit: `refactor(camera): driver table behind a control-scheme registry`. + +### Task 3 (P5): `CameraPose.lookOffset` — `review: yes` + +**Files:** +- Modify: `src/@types/camera/CameraPose.d.ts`, `src/@types/camera/OrbitCamera.d.ts` (or its `OrbitCameraInit`) +- Modify: `src/utils/camera/orbitForwardOf.ts` (the single forward decode; readers: `computeViewProj`, `turnedOrbitCamera`, `slabs.ts`, `frameContext.ts`) +- Modify: `src/services/engine/camera/assembleOrbitCamera.ts:49` (copy the field like `roll`) +- Modify: `src/utils/url/encodeFramedPose.ts`, `src/utils/url/decodeFramedPose.ts:65` +- Modify: the tween row's lerp at `src/services/engine/camera/cameraDrivers.ts:180` and the carry at `:140` +- Audit, and modify as needed: every other `roll` reader. The current list is `reencodePose`, `poseFrameConversion`, `releasedWorldArm`, `tweenToClip`, `evaluateClip`, `approachTiltedPose`, `applyFocusedBodyPivot`, `frameAlignedRoll`, `cameraDofAnglesOf`, `replayInput`, `projectFramePose`, `horizonShellRenderer`, `zoneOfAvoidanceRenderer` (`grep -rlE "pose\.roll|\.roll \?\?" src`). +- Test: `tests/utils/camera/orbitForwardOf.test.ts` (create or extend), `tests/utils/url/encodeFramedPose.test.ts`, `tests/utils/url/decodeFramedPose.test.ts` + +**Contract:** +```ts +export type CameraPose = { target: Vec3; yaw: number; pitch: number; distance: number; + roll?: number; + /** [yaw, pitch] rad turning the VIEW about the eye after the orbit terms place it; absent ⇒ [0, 0]. */ + lookOffset?: Vec2 }; +``` +- **Semantics.** The eye position is untouched: `updatePosition` never reads `lookOffset`. `orbitForwardOf` turns the decoded forward first by `lookOffset[0]` about the frame up (`frameUp(upBasis)`), then by `lookOffset[1]` about the resulting right axis. `imagePlaneBasis` then re-derives up, so the horizon stays level. +- **Absent or `[0, 0]`.** This takes the exact existing code path. Short-circuit, so there is no floating-point drift. +- **Per-reader rule for the audit:** + - A site that builds a view reads the offset only through `orbitForwardOf`. + - A site that interpolates two poses lerps the offset toward the target's (the tween's target has none, so it eases to 0). + - A site that re-encodes between orientation frames carries it unchanged (it is view-local). + - A site that converts to a body-fixed or site frame drops it. This is spec §8: disengage zeroes, and PR 2 revisits it only if the eye-check objects. + - Clips author no offset, so a clip starting from an offset pose snaps to 0 at its start. That is accepted and needs no clip-channel change. +- **R12b-3 comment** at `src/state/camera/cameraSlice.ts:56`: reword to "every committed ABSOLUTE pose's orbit terms are centre-looking; `lookOffset` turns only the view." +- **URL.** Encode the two extra numbers only when the offset is non-zero, as trailing fields after `roll`. Decode accepts both lengths. + +- [x] Test `orbitForwardOf keeps the eye and turns forward by lookOffset`: with a non-zero offset, `position` is unchanged, and the angle between the new and old forward equals `hypot` of the offset (small-angle case) or the exact composed angle (test at [0.3, 0] and [0, 0.2]). +- [x] Test `orbitForwardOf with lookOffset [0,0] is bitwise identical to absent`: `Float64Array` equality of the forward and of the `computeViewProj` matrix. +- [x] Test `encodeFramedPose omits a zero lookOffset`: a pose with `[0, 0]` encodes to the same string as without the field. +- [x] Test `framed pose round-trips a non-zero lookOffset through the hash`: encode then decode returns the offset within 1e-12. +- [x] Test `tween eases lookOffset to zero`, in the existing tween-row test file: at t=0.5 it is half the start offset, and at t=1 it is 0. +- [x] Implement and run the audit. Every existing test and golden stays green, unchanged. +- [x] Commit: `feat(camera): CameraPose.lookOffset — view turn about the eye, neutral at zero`. + +### Task 4 (P1a): Extract the body arm's orbit, look and settle — `review: yes` + +**Files:** +- Create: `src/utils/camera/orbitedSurfacePose.ts`, `src/utils/camera/lookedSurfacePose.ts`, `src/utils/camera/settledDragPose.ts` +- Create (if not already present as types): `src/@types/camera/SettleCtx.d.ts` +- Modify: `src/utils/camera/draggedSurfacePose.ts:58-73`, `src/services/camera/surfaceStep.ts:114-157` + +**Contract:** +```ts +// The existing drag's rate law: yaw/pitch in SCREEN radians (px / cssHeight · fovY), signs as the drag's. +orbitedSurfacePose(arm: BodyFixedPose, yawRad: number, pitchRad: number): BodyFixedPose; // was draggedSurfacePose :58-64 +lookedSurfacePose(arm: BodyFixedPose, yawRad: number, pitchRad: number): BodyFixedPose; // was :66-73 +settledDragPose( + entry: BodyFixedPose, // the pose BEFORE the move (for the held entry azimuth) + moved: BodyFixedPose, + mode: SurfaceGestureMode, // the existing mode union, as surfaceStep uses it + tilt: TiltMemory, + ctx: SettleCtx, // groundRadiusAtM, standoffRadii, bodyRadiusM, tuning +): { pose: BodyFixedPose; tilt: TiltMemory }; // floor → level (held azimuth for pan|orbit, none for strafe) → tilt-memory write for tilt|look +``` +`draggedSurfacePose` and `surfaceStep` call these helpers, with no logic change. This is extraction only. + +- [x] No new test. `tests/services/camera/surfaceStep.test.ts`, `rememberedTilt.test.ts` and `singularLocusRecession.test.ts` are the parity gate and must stay green unchanged. +- [x] Commit: `refactor(camera): extract body-arm orbit/look solves and the drag settle`. + +### Task 5 (P1b): `ArmDelta` + `RungRow.nudge` — `review: yes` + +**Files:** +- Create: `src/@types/camera/ArmDelta.d.ts`, `src/@types/camera/NudgeCtx.d.ts` (if `RungCtx` doesn't suffice, reuse it) +- Create: `src/utils/camera/nudgedWorldPose.ts`, `src/utils/camera/nudgedSurfacePose.ts`, `src/utils/camera/nudgedSitePose.ts`, `src/utils/camera/rollBasisAboutView.ts` +- Modify: `src/@types/camera/RungRow.d.ts`, `src/services/engine/camera/rungs/{absoluteRung,bodyRung,siteRung}.ts` +- Test: `tests/utils/camera/nudgedWorldPose.test.ts`, `tests/utils/camera/nudgedSurfacePose.test.ts`, `tests/utils/camera/nudgedSitePose.test.ts` + +**Contract:** +```ts +/** One frame's pixel-free motion. orbit/look/roll are SCREEN radians (a drag of one CSS + * height = one fovY), signed as the pixel drag (+x = rightward, +y = downward); + * zoom is ln(distance factor), > 0 = farther. Absent axis = no motion. */ +export type ArmDelta = { readonly orbit?: Vec2; readonly look?: Vec2; readonly zoom?: number; readonly roll?: number }; + +// RungRow gains: +nudge(tilt: TiltMemory, framed: FramedPose, delta: ArmDelta, ctx: RungCtx): + { readonly pose: PoseOf[K]; readonly tilt: TiltMemory }; +// A delta the rung ignores entirely returns framed.pose BY REFERENCE (the same identity rule as step). +``` +**Per-rung semantics.** Carry these into the cells exactly. +- **absolute (`nudgedWorldPose`):** + - orbit: convert screen radians to that drag's pixels (`rad · cssHeight / fovY`), then apply `applyInputToCamera`'s orbit law (the `orbitRadPerPixel` altitude damping and `PITCH_LIMIT` clamp). + - zoom: `zoomedDistance(d, exp(zoom), pivot)`. + - roll: `roll + delta.roll`. + - look: `lookOffset + delta.look`, with pitch clamped to `±(π/2 − 0.01)`. +- **body (`nudgedSurfacePose`):** apply the axes in the order zoom → orbit → look → roll: + - zoom: `surfaceZoomStep(arm, null, exp(zoom), null, …)`, anchored at screen centre. + - orbit: `orbitedSurfacePose`. + - look: `lookedSurfacePose`. + - roll: `rollBasisAboutView(basisLocal, rad)`, which rotates right and up about forward and stays orthonormal. + + Then run **one** `settledDragPose`. The mode is `'orbit'` when orbit alone moved, `'look'` when look moved (it writes the tilt memory), and the floor-only branch when roll moved. Roll must skip the level step, because the level settle would undo it: the body arm rules that no *drag* may roll, and `nudge` is not a drag. +- **site (`nudgedSitePose`):** orbit uses `steppedSitePose`'s gain law (`steppedSitePose.ts:25-37`) with pixels = `rad · cssHeight / fovY`. Zoom multiplies range by `spentZoomFactor(exp(zoom))`, with the declined-notch identity. Look and roll are ignored. + +- [x] Test `world nudge orbit equals the equivalent pixel drag`: for a pose over Earth with a pivot radius, `nudgedWorldPose({orbit:[a,b]})` equals `applyInputToCamera` with a drag of `(a,b)·cssHeight/fovY` px, within 1e-12. +- [x] Test `world nudge zoom matches zoomedDistance` and `world nudge look accumulates lookOffset and clamps pitch`. +- [x] Test `body nudge orbit equals the equivalent orbit-mode drag`: the same pose as the drag path with the gesture latched to `'orbit'`, within 1e-9 m. Build the drag through `surfaceStep` with a pre-latched orbit gesture. +- [x] Test `body nudge roll keeps basisLocal orthonormal and survives the settle`: after `roll: 0.2`, the basis is orthonormal to 1e-12 and the image roll relative to the pre-pose is 0.2 ± 1e-9 (i.e. the level step didn't eat it). +- [x] Test `site nudge ignores look and roll by reference` and `site nudge orbit equals the equivalent drag`. +- [x] Implement. `step` is untouched. +- [x] Commit: `feat(camera): RungRow.nudge — pixel-free per-arm motion column`. + +### Task 6 (P4): Persisted-value table — `review: yes` + +**Files:** +- Create: `src/@types/state/PersistedValue.d.ts`, `src/utils/storage/readPersisted.ts`, `src/utils/storage/persistValues.ts`, `src/state/persistedValues.ts` +- Modify: `src/state/ui/splashStorage.ts`: remove `readSeenVersion`/`writeSeenVersion` and `SPLASH_STORAGE_KEY` (the key moves to its row); keep `CURRENT_SPLASH_VERSION` and `readUrlAtMount`. +- Modify: `src/state/ui/buildInitialUiState.ts`, `src/hooks/useSplash.ts`, `src/main.tsx:96` +- Delete: `src/state/ui/persistSplashVersion.ts`, `tests/state/ui/persistSplashVersion.test.ts` +- Test: `tests/utils/storage/readPersisted.test.ts`, `tests/utils/storage/persistValues.test.ts`; update `tests/state/ui/splashStorage.test.ts`, `tests/hooks/useSplash.test.ts`, `tests/state/ui/buildInitialUiState.test.ts` for the moved functions. + +**Contract:** +```ts +export type PersistedValue = { + readonly key: string; // never rename without a migration + readonly select: (s: RootState) => T; + readonly parse: (raw: string) => T | null; // null ⇒ caller's default + readonly serialize: (v: T) => string; + readonly skip?: (v: T) => boolean; // splash: null is not a dismissal +}; +readPersisted(row: PersistedValue): T | null; // SSR/private-mode/throw ⇒ null +persistValues(store: AppStore, rows: readonly PersistedValue[]): () => void; + // one subscribe; per row, a diff against the value snapshotted BEFORE subscribing; writes swallow errors +export const PERSISTED_VALUES = [SPLASH_SEEN_VERSION] as const; // src/state/persistedValues.ts; PR 2 appends cameraControls +``` +The splash row keeps key `'skymap.splash.seenVersion'` verbatim. + +- [x] Test `readPersisted returns null when storage throws` (stub `localStorage.getItem` to throw) and `readPersisted returns null for an unparsable value`. +- [x] Test `persistValues writes only on change and not at install`: seed a non-null value, install, and dispatch an unrelated action, so there is no write; change the value, so there is exactly one write. +- [x] Test `persistValues swallows a throwing setItem`. +- [x] Port the surviving assertions of `persistSplashVersion.test.ts` (no write on `reopenSplash`, no write for null) onto the splash row, then delete that file. +- [x] Commit: `refactor(state): generic persisted-value table; splash version moves onto it`. + +--- + +## Definition of Done + +- **Deliverables:** + - `DriverActivity`, `ControlScheme`/`ControlSchemeId`, `CONTROL_SCHEMES`; + - `CameraPose.lookOffset` read through `orbitForwardOf`; + - `ArmDelta` and `RungRow.nudge` on all three rungs; + - `orbitedSurfacePose`/`lookedSurfacePose`/`settledDragPose`; + - `PersistedValue`, `readPersisted`, `persistValues`, `PERSISTED_VALUES`; + - `persistSplashVersion.ts` deleted. +- **Manual smoke (skymap scheme, user):** + - world-arm orbit/pan/wheel; + - body-arm grab-pan, look to the sky, tilt, wheel-to-cursor; + - site-arm turntable; + - a focus tween and a clip; + - a `#pose=` deep link from before this branch opens the same view; + - the splash stays dismissed across a reload. +- **Out of scope (PR 2):** + - the `openspace` scheme id, `NavAxis`, navigator state, the `navDrag` input kind, `bindAxis` on orbitControls; + - the two driver rows, the `cameraControls` settings cluster and its persisted row, the `shift+c` shortcut, the settings UI, the wake term; + - the Q8 note in the grill file. diff --git a/docs/superpowers/specs/2026-09-29-openspace-camera-mode-design.md b/docs/superpowers/specs/2026-09-29-openspace-camera-mode-design.md new file mode 100644 index 0000000000..cbbd6b082a --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-openspace-camera-mode-design.md @@ -0,0 +1,221 @@ +# OpenSpace camera mode — design spec + +A second, user-selectable camera **control scheme** that drives like OpenSpace, so planetarium and OpenSpace operators feel at home. The existing scheme (`skymap`) stays behaviourally unchanged. + +Rulings came from the brainstorm of 2026-09-25 → 2026-09-29 (dash asks dHGq, PeRh, 6AXs, plus chat sign-off of design §1–§3 and the `lookOffset` follow-up). They are listed in §2 and are not re-litigated here. The OpenSpace facts are verified against the v0.22.0 source: `src/navigation/orbitalnavigator/{orbitalnavigator,orbitalinputhandler,mousecamerastates}.cpp` and `data/assets/default_keybindings.asset`. Background: [`docs/research/2026-08-24-camera-pivot/openspace-camera-notes.md`](../../research/2026-08-24-camera-pivot/openspace-camera-notes.md). + +## 1. What this is + +- A persisted setting chooses the scheme: `'skymap' | 'openspace'`. `shift+c` toggles it, and the settings panel has a select for it. +- In `openspace`, a mouse button plus modifier, **latched at press**, selects one of four axes: + + | Input | Axis | + |---|---| + | left | **orbit** around the focus | + | ctrl+left | **look** around in place | + | right, or alt+left | **zoom** (vertical delta only) | + | middle, or shift+left | **roll** | + +- Each axis has a **velocity**. While the button is held, the velocity tracks the mouse. After release it decays by OpenSpace's friction law, but only if that axis's friction is on; otherwise it continues forever. +- Velocities apply every frame to whichever camera arm is live (world, body, site), each through its own solve. +- The wheel, pinch, double-click, `f` focus, tours, clips and URL poses behave exactly as in `skymap`. + +Packaging: **two PRs**. PR 1 is the ground preparation (§3): five behaviour-neutral commits. PR 2 is the feature (§4–§10). Each PR gets its own plan. There is no deletion audit on PR 1, and one at PR 2's `/feature-done`. + +## 2. Rulings + +| # | Ruling | +|---|---| +| R1 | Purpose: familiarity for OpenSpace operators. Where skymap and OpenSpace differ, the openspace scheme follows OpenSpace unless another ruling says otherwise. | +| R2 | Architecture: a **separate navigator with its own velocity state**, authored through its own driver rows. The input-mapping-table plus flick-coast alternative was rejected. | +| R3 | Friction is **true OpenSpace**: `v *= 1 − min(dt / friction, 1)` per frame on axes with friction on. Friction off means the axis never decays and is stopped only by reversing it or by a preempting driver. This **revises grill Q8** ([`globe-camera-pivot-2026-08-24.md`](../../grill-sessions/globe-camera-pivot-2026-08-24.md)), which rejected velocity/friction models, for the openspace scheme only. | +| R4 | No WASD fly (OpenSpace ships none). No Z/X sensitivity ramp, no local roll, no `R` idle motion. | +| R5 | The wheel keeps skymap's zoom in both schemes (OpenSpace's wheel is unused). | +| R6 | `f` stays focus. Friction toggles live **only** in the settings panel; OpenSpace's F / Shift+F / Ctrl+F are not bound. | +| R7 | The scheme applies to **all arms**, each through its own solve. The site arm ignores look and roll. | +| R8 | The mode hotkey is `shift+c`. | +| R9 | World-arm look is **true OpenSpace**: the view turns off the anchor and stays turned while orbiting. It is carried by a new optional `CameraPose.lookOffset`, so `target` stays the pivot and R12b-3's no-teleport guarantee holds (§8). | +| R10 | A coasting camera is preempted by anything the user triggers (focus, home, fly-to, clip, tour). Preemption commits and zeroes every velocity, which matches OpenSpace resetting velocities on an anchor change. | +| R11 | During an endless (friction-off) spin, `camera.base` and the URL hash keep the last committed pose. There is no timed commit. | +| R12 | The scheme and friction settings persist in localStorage and sit outside `SettingsSnapshot`, so a tour or takeover never restores them away. | +| R13 | PR packaging: one prep PR (P1–P5), then the feature PR. The adjacent findings are backlogged (§3.4). | + +## 3. Ground preparation + +### 3.1 Ideal shape + +```ts +type ControlSchemeId = 'skymap' | 'openspace'; +type NavAxis = 'orbit' | 'look' | 'zoom' | 'roll'; +type FrictionGroup = 'rotational' | 'zoom' | 'roll'; // orbit+look share 'rotational' +type CameraControlsSettings = { // own cluster, not in SettingsSnapshot + scheme: ControlSchemeId; friction: number; frictionOn: Record; +}; +type InputStep = … | { kind: 'navDrag'; axis: NavAxis; deltaPx: Vec2 }; // new kind, not a DragMode variant +type ArmDelta = { orbit?: Vec2; look?: Vec2; zoom?: number; roll?: number }; // rad, rad, ln-factor, rad +type NavigatorState = { velocity: NavVelocity; held: NavAxis | null; lastNowMs: number | null }; + // CameraRuntime.navigator +type CameraPose = { …; roll?: number; lookOffset?: Vec2 }; // yaw, pitch of view about the orbit sightline + +RungRow.nudge(tilt, framed, delta: ArmDelta, ctx): { pose, tilt } // new column, one cell per rung +CONTROL_SCHEMES: Record // { drivers }; PR 2 adds bindAxis(button, mods) +CAMERA_DRIVERS += openSpaceHeld (80), openSpaceCoast (50) // both commitsOnEdge +KEYBOARD_SHORTCUTS += 'shift+c' +``` + +### 3.2 Verdicts + +| Touchpoint | Verdict | Why | +|---|---|---| +| New input kind `navDrag` | growth | Every rung declines unknown kinds by reference (`absoluteRung.ts:21`, `surfaceStep.ts:101`, `steppedSitePose.ts:53`). The aggregator (`inputAggregator.ts:53`) and `replayInput.ts:176` each gain one `case`. | +| Rung motion in radians/factors | **bolt-on → P1** | `RungRow.step` takes pixels only (`RungRow.d.ts:29`). Body-arm orbit and look sit inline in `draggedSurfacePose.ts:49-73`, behind a ray cast that picks the mode (`latchSurfaceGesture.ts:26`). | +| Driver activity from runtime state | **bolt-on → P2** | `isActive(s, approachDone?)` sees only the store plus one positional runtime fact (`CameraDriver.d.ts:28`, `stepCameraRuntime.ts:163`). A navigator flag would be a second special argument. | +| Scheme selection | **bolt-on → P3** | Otherwise `scheme ===` lands in orbitDrag, two new rows and replayInput routing: four sites for one fact. | +| Persisted setting | **second special case → P4** | The only persisted value is the splash version (`persistSplashVersion.ts:27`, installed `main.tsx:96`). | +| View off the pivot | **missing field → P5** | `CameraPose` can't express a look direction other than toward `target` (`CameraPose.d.ts`, R12b-3 at `cameraSlice.ts:56`). | +| `CameraRuntime.navigator` + seed | growth | `CameraRuntime.d.ts:17`, `seedCameraRuntime.ts:32`. It rides the hand-assembly in `stepCameraRuntime.ts:274` like the follow and tilt fields. | +| Commits | growth | `commitsOnEdge` (`commitOnEdge.ts:42`) already commits the departing row's register. At-rest, handoff and scheme toggle are all the row deactivating. | +| Wake while coasting | growth | Ride `stepCameraRuntime`'s `requestRender` return (`:294` → `runFrame.ts:140`), not a new OR term in `shouldKeepTicking.ts`. | +| Shortcut | growth | A data row in `keyboardShortcuts.ts:43`. | +| Settings cluster | growth | A new cluster in `coreSettingsSlices.ts`, kept out of `SettingsSnapshot` the way `orientation` is. | + +Greenfield cross-check: a fresh derivation from the requirements alone agreed on all of the following: +- a scheme registry; +- the axis latched into the event; +- a single "driver exits ⇒ commit" rule; +- per-arm `apply(delta)` with unsupported axes dropped; +- one writer for velocity. + +It placed velocity in module state; this spec keeps it in `CameraRuntime`, because `runFrame` is the single writer by convention (a real constraint, and it costs nothing). No compatibility tension was found, and nothing persisted or deployed changes shape except the optional `lookOffset`, which reads as absent ⇒ 0. + +### 3.3 Prep list (PR 1, one commit each, all behaviour-neutral) + +- **P1: the `nudge` rung column.** Add `nudge(tilt, framed, ArmDelta, ctx)` to `RungRow`, returning `{ pose, tilt }` like `step`, and implement it on all three rungs: + - **absolute:** orbit runs the drag's own pixel law (`applyInputToCamera`) at the equivalent pixel step; zoom IS `absoluteRung.step`'s zoom at factor `exp(zoom)`, `frameAlignedRoll` ride included; look adds to `lookOffset` with pitch clamped to `PITCH_LIMIT` (`src/data/camera/pitchLimit.ts`); roll adds to `pose.roll`. + - **body:** orbit and look are extracted from `draggedSurfacePose.ts:49-73` into pure functions of an angle pair, and both the old pixel path and `nudge` call them, followed by the drag's own settle (`settledDragPose`). Zoom goes through `surfaceZoomStep` with a null (screen-centre) anchor. Roll turns `basisLocal` about the view axis AFTER the settle, which would level it away. The cell reads `SurfaceStepCtx` (extracted to `@types/`, shared with `surfaceStep`); there is no separate `NudgeCtx`. + - **site:** orbit maps to heading/elevation and zoom to range. Look and roll return the pose by reference. + + Each arm's orbit **proximity scaling** (the world arm's altitude damping at `applyInputToCamera.ts:73`, and its body and site equivalents) moves inside the arm, so pixel `step` and `nudge` share it and `ArmDelta` stays arm-agnostic. Tests: each cell against the pixel path at the equivalent angle. +- **P2: driver activity bag.** Replace `isActive(s, approachDone?)` with `isActive(s, activity: DriverActivity)`, where `DriverActivity = { approachDone: boolean }` and `stepCameraRuntime` builds it once per frame. PR 2 adds `navHeld` and `navMoving`. +- **P3: scheme-selected driver table.** Add `CONTROL_SCHEMES: Record`, where `ControlScheme = { drivers: readonly CameraDriver[] }`. `skymap` is today's `CAMERA_DRIVERS`. PR 2 adds `bindAxis: (button, mods) => NavAxis | null` alongside its first reader. The loop picks the table from the scheme each frame (`startLoop.ts:47` injection becomes a registry lookup; a fixture override still works). Only `skymap` exists in PR 1, and the id type is the one-member union `'skymap'`. +- **P4: persisted-value table.** A `PersistedValue` row is `{ key, select, parse, serialize }`; `PERSISTED_VALUES` (`src/state/persistedValues.ts`) lists them, `persistValues(store, rows)` is one subscribe-diff writer with try/catch around storage, and `readPersisted(row)` is the boot read each consumer calls. The splash version migrates onto it as the `SPLASH_SEEN_VERSION` row, and `persistSplashVersion.ts` is deleted; `splashStorage.ts` stays for `CURRENT_SPLASH_VERSION` and `readUrlAtMount`. +- **P5: `CameraPose.lookOffset?: Vec2`.** Thread it through every reader of `roll`, about 20 files: + - `assembleOrbitCamera`/`computeViewProj` turn the view basis by it; + - the tween row eases it to 0 over the tween's own duration (`tweenToClip` drops it, so the row carries the ease), and `followApproach` eases it the same way; a clip authors no offset, so a clip started from an offset pose snaps it to 0 at its start; + - `reencodePose`, `poseFrameConversion`, `releasedWorldArm` carry or zero it; + - the `#pose=` hash encodes it when non-zero; + - the two renderers that rebuild the camera themselves (`horizonShellRenderer`, `zoneOfAvoidanceRenderer`) read it. + + It is always 0 in PR 1. Tests: the view matrix at a non-zero offset keeps the eye fixed and turns the forward vector, and a URL round-trip holds. + +### 3.4 Adjacent findings (backlogged, not in either PR) + +- `DragMode` consumers test `=== 'pan'`, so a new variant silently falls into orbit. +- `runTakeoverSaga.ts:29` replaces the whole `camera` settings cluster. +- `defaults.ts:117` says the palette is persisted, which is stale. + +## 4. Settings, persistence, hotkey, UI + +- **Cluster.** `settings.cameraControls: CameraControlsSettings`, with defaults `{ scheme: 'skymap', friction: 0.5, frictionOn: { rotational: true, zoom: true, roll: true } }` (OpenSpace's defaults). Constants live in `src/data/camera/openSpaceNavigation.ts`. +- **Persistence.** One `PersistedValue` row in `PERSISTED_VALUES`, key `skymap.cameraControls.v1`. `parse` validates each field and falls back to the default field by field, so a malformed entry never breaks boot. +- **Hotkey.** Shortcut row `shift+c` runs `toggleControlScheme()`. +- **UI.** A "Camera controls" section in the settings panel, built per `create-component`: + - a Skymap / OpenSpace select labelled with `shift+c`; + - when OpenSpace is selected, three friction checkboxes and a 0–1 friction slider. + + There is **no toast**: the app has no toast mechanism, and the select shows the live scheme. This departs from design §3 as approved in chat. + +## 5. Input capture + +- `ControlScheme` gains `bindAxis: (button, mods) => NavAxis | null` (PR 1 shipped it without), and `OrbitControlsOptions` gains the same field, which `wireInput.ts:241` reads from `CONTROL_SCHEMES[scheme]` at press. +- At pointer-down, a non-null axis latches for the gesture. The recognizer then emits `navDrag` events carrying that axis and each move's pixel delta, and never re-reads the modifiers mid-drag. +- A null axis (the `skymap` scheme) takes today's path untouched. +- A `navDrag` release never click-picks. The click test stays tied to the skymap `orbit` mode. +- `beginDrag`/`endDrag` and `cancelCameraTween` fire as for any gesture, so `camera.dragging` still means "a button is held". +- The aggregator folds consecutive `navDrag` moves of the same axis by summing `deltaPx`. +- `replayInput` hands a `navDrag` step to the navigator, not the rung. + +## 6. The navigator + +`CameraRuntime.navigator: NavigatorState`, advanced inside `stepCameraRuntime`, whose single writer is `runFrame`: + +1. `dt = clamp(nowMs − lastNowMs, 0, 100 ms)`. On the first frame (`lastNowMs === null`) `dt` is 0. +2. **Held axis:** the velocity approaches `deltaPx · gain[axis] / dt` with the same damping law as release, so a held but motionless mouse comes to rest (OpenSpace's `DampenedVelocity::set`; the plan verifies the exact form against the source before coding). +3. **Released axes:** `v *= 1 − min(dt / friction, 1)` when `frictionOn[group(axis)]` is on; unchanged otherwise. Anything below `NAV_REST_EPS` snaps to 0. +4. `ArmDelta = v · dt`, per axis. The zoom axis uses only the vertical delta, and its sign matches OpenSpace (verified in the plan). + +`gain` starts from OpenSpace's `MouseSensitivity` default (15 × 1e-4) and is tuned by feel side by side with OpenSpace. Velocities are arm-agnostic (§3.3 P1), so a coast that crosses an arm boundary (the regime fold runs during coast, `projectFramePose.ts:109`) carries on without a wipe or remap. + +## 7. Driver rows and commits + +| Row | Priority | Active when | Flags | +|---|---|---|---| +| `openSpaceHeld` | 80 | `activity.navHeld` | `pivotsOnFocusedBody`, `commitsOnEdge` | +| `openSpaceCoast` | 50 | `!activity.navHeld && activity.navMoving` | `pivotsOnFocusedBody`, `commitsOnEdge` | + +- Both rows sit only in `CONTROL_SCHEMES.openspace.drivers`, which is `skymap`'s table with `orbitDrag` replaced. Their `pose` is the register after `nudge(ArmDelta)` has been applied, and the navigator writes it before `pickWinner`. +- Priority 50 puts a coast **below** `followApproach` (55) and tween (60) and above `autoRotate` (20), so any focus, home or fly-to preempts it (R10). +- **Commit edges.** All three come free from `commitsOnEdge`: + - **at rest:** `navMoving` goes false; + - **handoff:** a higher row wins; + - **scheme toggle:** the table swaps and the row vanishes. +- On a handoff, and on a scheme toggle, `stepCameraRuntime` zeroes the navigator (sibling of the `followIn` reset at `:180`). +- **R11:** there is no other commit. + +## 8. `lookOffset` (world arm) + +- `lookOffset = [yaw, pitch]` turns the **view basis** about the eye after the orbit terms place it. `target`, `yaw`, `pitch` and `distance` keep their meaning, so the focus pin's re-centring (`applyFocusedBodyPivot`) and R12b-3's eye derivation are untouched. +- The R12b-3 comment is reworded to "every committed absolute pose's **orbit terms** are centre-looking; `lookOffset` turns only the view". +- The look axis on the world arm adds to `lookOffset`, with pitch clamped to ±(π/2 − 0.01). +- A tween or focus change eases `lookOffset` to 0 over its own duration (OpenSpace's retarget aim); a clip snaps it to 0 at its start. +- In the `skymap` scheme nothing writes it. After switching back, an existing offset stays until the next tween clears it. +- Body-arm look is native to `basisLocal` and needs no offset. Arm conversions carry or zero it (P5), and PR 2 keeps today's disengage retarget, which zeroes it. + +## 9. Wake + +`stepCameraRuntime` already returns `requestRender`. PR 2 ORs in `navigator.held !== null || navMoving`, so a coast keeps frames ticking and an at-rest navigator lets the loop sleep. + +## 10. Testing + +These are the tests that can catch a real bug nothing else catches ([`testing.md`](../conventions/testing.md)): + +- **Navigator (pure):** + - decay matches the law at several `dt`; + - an axis with friction off holds its velocity; + - the 100 ms `dt` cap holds; + - the ε snap makes `navMoving` fall exactly once; + - a held, motionless mouse comes to rest. +- **Latch:** the button+modifier table, and a modifier released mid-drag keeping the axis. +- **nudge (P1):** + - each rung matches its pixel path at the equivalent angle; + - site look/roll return by reference; + - body roll keeps `basisLocal` orthonormal. +- **Drivers:** + - held → coast → rest commits once; + - a tween during coast preempts, commits once and zeroes velocities; + - a scheme toggle mid-coast commits once; + - the skymap table is byte-identical to today's `CAMERA_DRIVERS`. +- **lookOffset (P5):** + - the eye is invariant under the offset; + - forward turns by the offset; + - the `#pose=` round-trip holds; + - a tween eases it to 0. +- **Persistence (P4):** + - round-trip; + - a throwing or malformed storage falls back to defaults; + - the splash version still persists. +- **Visual check (user):** drive both schemes on the dev server, ideally beside OpenSpace. Check orbit around Earth with look offset held, friction on/off per axis, preemption by `f` during a spin, and a coast across the body-arm band. + +## 11. Risks + +- **Held-velocity law.** OpenSpace's exact `DampenedVelocity::set` form isn't in our notes. The plan's first navigator task reads it from source; the feel depends on it. +- **Coast across the arm band.** The fold runs mid-coast, and disengage zeroes `lookOffset`, so a look-offset spin that sinks into the body arm snaps its view once. If the eye-check objects, the fix is to carry the offset into `basisLocal` at disengage. +- **Endless spin and stale URL (R11).** Accepted. +- **P5 blast radius.** About 20 `roll` readers. Two renderers rebuild the camera themselves (the backlog's off-axis fovY item names the same two), and a missed reader shows a view that disagrees with picking. Tests pin the view matrix, and the eye-check covers labels and picks with an offset. + +## 12. Definition of done + +- PR 1 is green, with a behaviour-neutral eye-check in the `skymap` scheme. +- PR 2 is green, the §10 visual check is passed by the user, and there is a deletion audit at `/feature-done`. +- `docs/RENDERER.md` or the camera docs name the scheme registry and the `nudge` column. +- The Q8 revision is noted in the grill file, pointing here. diff --git a/src/@types/camera/ArmDelta.d.ts b/src/@types/camera/ArmDelta.d.ts new file mode 100644 index 0000000000..1e85888794 --- /dev/null +++ b/src/@types/camera/ArmDelta.d.ts @@ -0,0 +1,13 @@ +import type { Vec2 } from '../math/Vec2'; + +/** + * One frame's pixel-free motion. orbit/look/roll are SCREEN radians (a drag of + * one CSS height = one fovY), signed as the pixel drag (+x = rightward, +y = + * downward); zoom is ln(distance factor), > 0 = farther. Absent axis = no motion. + */ +export type ArmDelta = { + readonly orbit?: Vec2; + readonly look?: Vec2; + readonly zoom?: number; + readonly roll?: number; +}; diff --git a/src/@types/camera/CameraPose.d.ts b/src/@types/camera/CameraPose.d.ts index 10b6ed1ede..a192ac5951 100644 --- a/src/@types/camera/CameraPose.d.ts +++ b/src/@types/camera/CameraPose.d.ts @@ -5,6 +5,7 @@ * All drivers (mouse input, tour storyboard, animated tweens) converge to this shape. */ +import type { Vec2 } from '../math/Vec2'; import type { Vec3 } from '../math/Vec3'; export type CameraPose = { @@ -19,4 +20,11 @@ export type CameraPose = { * before that fold lands. */ roll?: number; + /** + * [yaw, pitch] radians turning the VIEW about the eye after the orbit terms + * place it; absent ⇒ [0, 0]. The eye never reads it, so the orbit terms stay + * centre-looking; it is view-local, so a frame re-encode carries it and a + * conversion into a body-fixed or site arm drops it. + */ + lookOffset?: Vec2; }; diff --git a/src/@types/camera/OrbitCameraInit.d.ts b/src/@types/camera/OrbitCameraInit.d.ts index 566cb034dd..525e4255ca 100644 --- a/src/@types/camera/OrbitCameraInit.d.ts +++ b/src/@types/camera/OrbitCameraInit.d.ts @@ -3,6 +3,7 @@ * live state lets `createOrbitCamera` take a plain object literal and derive * the rest (e.g. `position`) from it. */ +import type { Vec2 } from '../math/Vec2'; import type { Vec3 } from '../math/Vec3'; import type { Mat3 } from '../math/Mat3'; @@ -32,6 +33,12 @@ export type OrbitCameraInit = { */ roll?: number; + /** + * [yaw, pitch] radians turning the view about the eye (`CameraPose.lookOffset`). + * Only `orbitForwardOf` reads it; `updatePosition` never does. Absent ⇒ [0, 0]. + */ + lookOffset?: Vec2; + /** * Frame-local → world basis the (yaw, pitch) DECODE runs through: * `dir_world = poseBasis · dir_local`, frame-local zenith is local +Y. diff --git a/src/@types/camera/RungRow.d.ts b/src/@types/camera/RungRow.d.ts index b81b010452..baa239ffd2 100644 --- a/src/@types/camera/RungRow.d.ts +++ b/src/@types/camera/RungRow.d.ts @@ -1,3 +1,4 @@ +import type { ArmDelta } from './ArmDelta'; import type { RungKind } from './RungKind'; import type { FrameOf } from './FrameOf'; import type { FramedPose } from './FramedPose'; @@ -10,7 +11,7 @@ import type { RungCtx } from './RungCtx'; import type { HostBody } from './HostBody'; import type { TiltMemory } from './TiltMemory'; -/** One rung's table row: its kind, how to resolve its current host body, its empty memory, its channel pair, its input step. */ +/** One rung's table row: its kind, how to resolve its current host body, its empty memory, its channel pair, its input step and its nudge. */ export type RungRow = { readonly kind: K; host(frame: FrameOf[K], ctx: RungBasisCtx): HostBody | null; @@ -33,4 +34,15 @@ export type RungRow = { input: InputStep, ctx: RungCtx, ): { readonly pose: PoseOf[K]; readonly memory: MemOf[K]; readonly tilt: TiltMemory }; + /** + * One frame's pixel-free motion in this rung's own frame, at the equivalent + * drag's rate. A delta the rung ignores entirely returns `framed.pose` BY + * REFERENCE, the same identity rule as `step`. + */ + nudge( + tilt: TiltMemory, + framed: FramedPose, + delta: ArmDelta, + ctx: RungCtx, + ): { readonly pose: PoseOf[K]; readonly tilt: TiltMemory }; }; diff --git a/src/@types/camera/SettleCtx.d.ts b/src/@types/camera/SettleCtx.d.ts new file mode 100644 index 0000000000..675f253834 --- /dev/null +++ b/src/@types/camera/SettleCtx.d.ts @@ -0,0 +1,10 @@ +import type { CameraTuning } from './CameraTuning'; +import type { GroundRadiusLookup } from './GroundRadiusLookup'; + +/** What the post-move settle reads: the floor's ground and standoff, and the tilt band's datum and tuning. */ +export type SettleCtx = { + readonly groundRadiusAtM: GroundRadiusLookup; + readonly standoffRadii: number; + readonly bodyRadiusM: number; + readonly tuning: CameraTuning; +}; diff --git a/src/@types/camera/SurfaceStepCtx.d.ts b/src/@types/camera/SurfaceStepCtx.d.ts new file mode 100644 index 0000000000..c84dc9b155 --- /dev/null +++ b/src/@types/camera/SurfaceStepCtx.d.ts @@ -0,0 +1,24 @@ +import type { CameraTuning } from './CameraTuning'; +import type { GroundRadiusLookup } from './GroundRadiusLookup'; +import type { Vec2 } from '../math/Vec2'; +import type { Vec3 } from '../math/Vec3'; + +/** Everything the body arm's input and nudge cells read about the viewport and the host. */ +export type SurfaceStepCtx = { + readonly viewportPx: Readonly; + readonly fovYRad: number; + readonly bodyRadiusM: number; + /** Descent-floor multiple of the datum (`bodyStandoffRadii`); a body may override the global. */ + readonly standoffRadii: number; + /** What the floor stands off from, per direction; `bodyRadiusM` keeps the band arithmetic. */ + readonly groundRadiusAtM: GroundRadiusLookup; + /** Relief shells the gesture/zoom pick marches between (spec §8.1); the datum-sphere + * fallback on a miss is the call sites' own policy, not this ctx's. */ + readonly innerBoundRadiusM: number; + readonly outerBoundRadiusM: number; + /** Scene-frame up in BODY-FIXED axes (unit); the body rotates under it, so resample per drain. */ + readonly sceneUpLocal: Readonly; + /** A focus HOSTED on this body, body-fixed metres — it owns the zoom's pivot. */ + readonly focusPivotM: Readonly | null; + readonly tuning: CameraTuning; +}; diff --git a/src/@types/engine/camera/CameraDriver.d.ts b/src/@types/engine/camera/CameraDriver.d.ts index 8810c97d11..4c5d9652b6 100644 --- a/src/@types/engine/camera/CameraDriver.d.ts +++ b/src/@types/engine/camera/CameraDriver.d.ts @@ -1,5 +1,6 @@ /** CameraDriver — one precedence-table row; the ranking and its why live with the table. */ +import type { DriverActivity } from './DriverActivity'; import type { DriverCtx } from './DriverCtx'; import type { DriverId } from './DriverId'; import type { EpochRow } from './EpochRow'; @@ -24,8 +25,7 @@ export type CameraDriver = { // pose), so it settles the follow approach's debt instead of leaving it owed // for the approach to undo at the row's exit. readonly deliversFraming?: boolean; - // `approachDone` = the follow memory saturated last frame; only `followApproach` reads it. - isActive(s: RootState, approachDone?: boolean): boolean; + isActive(s: RootState, activity: DriverActivity): boolean; // A row that owns no memory hands `mem` back, so the winner's adoption needs no branch. pose( ctx: DriverCtx, diff --git a/src/@types/engine/camera/ControlScheme.d.ts b/src/@types/engine/camera/ControlScheme.d.ts new file mode 100644 index 0000000000..38822881f1 --- /dev/null +++ b/src/@types/engine/camera/ControlScheme.d.ts @@ -0,0 +1,8 @@ +/** + * ControlScheme — one row of the `CONTROL_SCHEMES` registry: the driver table + * the frame loop picks its single camera author from. + */ + +import type { CameraDriver } from './CameraDriver'; + +export type ControlScheme = { readonly drivers: readonly CameraDriver[] }; diff --git a/src/@types/engine/camera/ControlSchemeId.d.ts b/src/@types/engine/camera/ControlSchemeId.d.ts new file mode 100644 index 0000000000..85a9d3965e --- /dev/null +++ b/src/@types/engine/camera/ControlSchemeId.d.ts @@ -0,0 +1,3 @@ +/** ControlSchemeId — the keys of the `CONTROL_SCHEMES` registry. */ + +export type ControlSchemeId = 'skymap'; diff --git a/src/@types/engine/camera/DriverActivity.d.ts b/src/@types/engine/camera/DriverActivity.d.ts new file mode 100644 index 0000000000..8acd2d26df --- /dev/null +++ b/src/@types/engine/camera/DriverActivity.d.ts @@ -0,0 +1,9 @@ +/** + * DriverActivity — the per-frame facts a driver row's `isActive` reads beyond + * the store, built once by `stepCameraRuntime` so every row sees the same bag. + */ + +export type DriverActivity = { + // The follow memory saturated last frame; only `followApproach` reads it. + readonly approachDone: boolean; +}; diff --git a/src/@types/engine/frame/RunFrameDeps.d.ts b/src/@types/engine/frame/RunFrameDeps.d.ts index 261ec98066..2766f2e4b9 100644 --- a/src/@types/engine/frame/RunFrameDeps.d.ts +++ b/src/@types/engine/frame/RunFrameDeps.d.ts @@ -19,7 +19,8 @@ import type { EngineCallbacks } from '../EngineCallbacks'; import type { GpuTimingService } from '../../gpu/timing/GpuTimingService'; -import type { CameraDriver } from '../camera/CameraDriver'; +import type { ControlScheme } from '../camera/ControlScheme'; +import type { ControlSchemeId } from '../camera/ControlSchemeId'; export type RunFrameDeps = { /** createEngine arg — for resize + viewport reads. */ @@ -37,10 +38,10 @@ export type RunFrameDeps = { */ timingService: GpuTimingService; /** - * 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. + * Control-scheme registry (`CONTROL_SCHEMES`, overridable by a fixture); each + * scheme's driver table feeds `pickWinner`, which 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). */ - readonly drivers: readonly CameraDriver[]; + readonly controlSchemes: Readonly>; }; diff --git a/src/@types/state/PersistedValue.d.ts b/src/@types/state/PersistedValue.d.ts new file mode 100644 index 0000000000..2e1faa71a9 --- /dev/null +++ b/src/@types/state/PersistedValue.d.ts @@ -0,0 +1,17 @@ +/** + * PersistedValue — one store value mirrored to a localStorage key. + * + * Read once at boot (`readPersisted`) to seed the store, written back on change + * (`persistValues`). `parse` returning null means "use the caller's default". + * `serialize` uses method syntax on purpose: bivariant parameters let a + * `PersistedValue` sit in a `PersistedValue[]` table. + */ +import type { RootState } from '../../store/types'; + +export type PersistedValue = { + /** Never rename without a migration: returning users' values live under it. */ + readonly key: string; + readonly select: (s: RootState) => T; + readonly parse: (raw: string) => T | null; + serialize(v: T): string; +}; diff --git a/src/data/camera/pitchLimit.ts b/src/data/camera/pitchLimit.ts new file mode 100644 index 0000000000..5e91b9830c --- /dev/null +++ b/src/data/camera/pitchLimit.ts @@ -0,0 +1,6 @@ +/** + * 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. + */ +export const PITCH_LIMIT = Math.PI / 2 - 0.01; diff --git a/src/hooks/useSplash.ts b/src/hooks/useSplash.ts index dd44fd5573..f3ddfcb772 100644 --- a/src/hooks/useSplash.ts +++ b/src/hooks/useSplash.ts @@ -22,7 +22,7 @@ * The first-visit / deep-link / seen-version decision is seeded once into the * slice by `buildInitialUiState` at store construction. localStorage * persistence (writing `seenVersion` on dismiss) is handled by the - * `persistSplashVersion` store effect, not here. + * `SPLASH_SEEN_VERSION` persisted-value row, not here. * * ### Readiness signal * @@ -126,8 +126,8 @@ export function useSplash(): UseSplashReturn { // // Both dismiss paths dispatch the same action — the version stamp is the // only thing that varies, and both CTAs stamp CURRENT_SPLASH_VERSION. - // localStorage persistence is handled by the persistSplashVersion store - // effect, not here. + // localStorage persistence is handled by the SPLASH_SEEN_VERSION + // persisted-value row, not here. const dismissExplore = useCallback( () => dispatch(dismissSplash(CURRENT_SPLASH_VERSION)), diff --git a/src/layers/zoneOfAvoidance/render/zoneOfAvoidanceRenderer.ts b/src/layers/zoneOfAvoidance/render/zoneOfAvoidanceRenderer.ts index e40493e19d..f1dd542077 100644 --- a/src/layers/zoneOfAvoidance/render/zoneOfAvoidanceRenderer.ts +++ b/src/layers/zoneOfAvoidance/render/zoneOfAvoidanceRenderer.ts @@ -9,11 +9,11 @@ * `shaders/zoneOfAvoidance/io.wesl` is authoritative. */ -import { vec3 } from 'wgpu-matrix'; import type { Vec3 } from '../../../@types/math/Vec3'; import type { ImagePlaneBasis } from '../../../@types/camera/ImagePlaneBasis'; import { imagePlaneBasis } from '../../../utils/camera/imagePlaneBasis'; import { frameUp } from '../../../utils/camera/frameUp'; +import { orbitForwardOf } from '../../../utils/camera/orbitForwardOf'; import vsCode from '../../../services/gpu/shaders/zoneOfAvoidance/vertex.wesl?static'; import fsCode from '../../../services/gpu/shaders/zoneOfAvoidance/fragment.wesl?static'; import fsPickCode from '../../../services/gpu/shaders/zoneOfAvoidance/fragmentPick.wesl?static'; @@ -125,9 +125,8 @@ export function createZoneOfAvoidanceRenderer( // packedId (float-index 25, u32) is written ONCE below and never touched // by writeUniforms — the packed identity never changes across frames. new Uint32Array(uniforms)[25] = packedId; - // Plain Vec3 tuples (not vec3.create's Float32Array) so the per-component - // reads below index cleanly under noUncheckedIndexedAccess. wgpu-matrix - // writes into them in place via the `dst` arg just the same. + // Plain Vec3 tuples so the per-component reads below index cleanly under + // noUncheckedIndexedAccess; `orbitForwardOf` writes into them in place. const fwd: Vec3 = [0, 0, 0]; // Frame-pole reference up, allocated once and rewritten in place each frame. const upRefScratch: Vec3 = [0, 0, 0]; @@ -151,8 +150,7 @@ export function createZoneOfAvoidanceRenderer( // Same derivation as horizonShellRenderer.draw — see that file's // header for the full rationale (rolled frame-pole basis so this // shell's rays agree with computeViewProj's). - vec3.subtract(cam.target, cam.position, fwd); - vec3.normalize(fwd, fwd); + orbitForwardOf(cam, fwd); imagePlaneBasis(fwd, cam.roll ?? 0, frameUp(cam.upBasis, upRefScratch), basis); const right = basis.right; const up = basis.up; diff --git a/src/main.tsx b/src/main.tsx index d0bfc99227..423dba9fbd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -53,7 +53,8 @@ import { RunSagaProvider } from './store/RunSagaProvider'; import { settingsRoute, tierRoute, uiRoute } from './store/constants'; import { INITIAL_SETTINGS } from './state/settings/initialSettings'; import { buildInitialUiState } from './state/ui/buildInitialUiState'; -import { persistSplashVersion } from './state/ui/persistSplashVersion'; +import { PERSISTED_VALUES } from './state/persistedValues'; +import { persistValues } from './utils/storage/persistValues'; import { installRecorderHook } from './state/recorder/installRecorderHook'; import { initialTierFromViewport } from './utils/initialTierFromViewport'; import { injectAnalytics } from './utils/analytics/injectAnalytics'; @@ -93,7 +94,7 @@ if (typeof navigator === 'undefined' || typeof navigator.gpu === 'undefined') { [uiRoute]: buildInitialUiState(), }); // Store lives for the page lifetime; unsubscribe is intentionally not held. - persistSplashVersion(store); + persistValues(store, PERSISTED_VALUES); // Recorder seam (`window.__skymapRecorder`) — gated on `?cinema` INSIDE the // installer, so this call stays unconditional. No-op on a normal visit. installRecorderHook(store); diff --git a/src/services/camera/applyInputToCamera.ts b/src/services/camera/applyInputToCamera.ts index b8c859e3f4..6b44cfc2cf 100644 --- a/src/services/camera/applyInputToCamera.ts +++ b/src/services/camera/applyInputToCamera.ts @@ -7,6 +7,7 @@ import { vec3 } from 'wgpu-matrix'; +import { PITCH_LIMIT } from '../../data/camera/pitchLimit'; import { zoomedDistance } from '../../utils/camera/zoomedDistance'; import { orbitRadPerPixel } from '../../utils/camera/orbitRadPerPixel'; import { imagePlaneBasis } from '../../utils/camera/imagePlaneBasis'; @@ -19,11 +20,6 @@ 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. -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 diff --git a/src/services/camera/surfaceStep.ts b/src/services/camera/surfaceStep.ts index 2daa456795..122ad7e471 100644 --- a/src/services/camera/surfaceStep.ts +++ b/src/services/camera/surfaceStep.ts @@ -8,44 +8,14 @@ */ import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; -import type { CameraTuning } from '../../@types/camera/CameraTuning'; -import type { GroundRadiusLookup } from '../../@types/camera/GroundRadiusLookup'; import type { InputStep } from '../../@types/camera/InputStep'; +import type { SurfaceStepCtx } from '../../@types/camera/SurfaceStepCtx'; import type { SurfaceGestureMemory } from '../../@types/camera/SurfaceGestureMemory'; import type { TiltMemory } from '../../@types/camera/TiltMemory'; -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 { settledDragPose } from '../../utils/camera/settledDragPose'; import { surfaceZoomStep } from '../../utils/camera/surfaceZoomStep'; -import { unmappedTiltRad } from '../../utils/camera/unmappedTiltRad'; - -type SurfaceStepCtx = { - readonly viewportPx: Readonly; - readonly fovYRad: number; - readonly bodyRadiusM: number; - /** Descent-floor multiple of the datum (`bodyStandoffRadii`); a body may override the global. */ - readonly standoffRadii: number; - /** What the floor stands off from, per direction; `bodyRadiusM` keeps the band arithmetic. */ - readonly groundRadiusAtM: GroundRadiusLookup; - /** Relief shells the gesture/zoom pick marches between (spec §8.1); the datum-sphere - * fallback on a miss is the call sites' own policy, not this ctx's. */ - readonly innerBoundRadiusM: number; - readonly outerBoundRadiusM: number; - /** Scene-frame up in BODY-FIXED axes (unit); the body rotates under it, so resample per drain. */ - readonly sceneUpLocal: Readonly; - /** A focus HOSTED on this body, body-fixed metres — it owns the zoom's pivot. */ - readonly focusPivotM: Readonly | null; - readonly tuning: CameraTuning; -}; /** The body rung's empty memory. */ export const EMPTY_SURFACE_GESTURE_MEMORY: SurfaceGestureMemory = { gesture: null }; @@ -112,52 +82,18 @@ export function surfaceStep( outerBoundRadiusM, ) : 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. - // No pivot: a drag serves its own gesture anchor, not the focus, and the - // tilt handle already spends its floor budget before reaching here. - const floored = flooredBodyPose(pose, groundRadiusAtM, standoffRadii, null); - // 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 = tilt.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); - } - } + // One floor site, after every position write — `anchoredZoomStep` owns its + // own, so the zoom arm above is already floored. + const settled = settledDragPose(arm, pose, mode, tilt, { + groundRadiusAtM, + standoffRadii, + bodyRadiusM, + tuning, + }); return { - pose: final, + pose: settled.pose, gesture: { gesture: { ...gesture, mode, prevPixel: step.endPx } }, - tilt: { ...tilt, rememberedTiltRad }, + tilt: settled.tilt, }; } diff --git a/src/services/engine/camera/applyFocusedBodyPivot.ts b/src/services/engine/camera/applyFocusedBodyPivot.ts index 1a08427da2..64ee6b84a1 100644 --- a/src/services/engine/camera/applyFocusedBodyPivot.ts +++ b/src/services/engine/camera/applyFocusedBodyPivot.ts @@ -41,5 +41,6 @@ export function applyFocusedBodyPivot( pitch: pose.pitch, distance: pose.distance, roll: pose.roll, + lookOffset: pose.lookOffset, }); } diff --git a/src/services/engine/camera/approachTiltedPose.ts b/src/services/engine/camera/approachTiltedPose.ts index 543e853ced..0368a6fcd1 100644 --- a/src/services/engine/camera/approachTiltedPose.ts +++ b/src/services/engine/camera/approachTiltedPose.ts @@ -95,5 +95,6 @@ export function approachTiltedPose( pitch, distance: pose.distance, roll: pose.roll, + lookOffset: pose.lookOffset, }); } diff --git a/src/services/engine/camera/assembleOrbitCamera.ts b/src/services/engine/camera/assembleOrbitCamera.ts index f57ea3086b..5604f40d4c 100644 --- a/src/services/engine/camera/assembleOrbitCamera.ts +++ b/src/services/engine/camera/assembleOrbitCamera.ts @@ -47,6 +47,7 @@ export function assembleOrbitCamera( pitch: pose.pitch, distance: pose.distance, roll: pose.roll, + lookOffset: pose.lookOffset, fovYRad: projection.fovYRad, aspect: projection.aspect, near: projection.near, diff --git a/src/services/engine/camera/cameraDrivers.ts b/src/services/engine/camera/cameraDrivers.ts index 207037b5bd..53197fb6e7 100644 --- a/src/services/engine/camera/cameraDrivers.ts +++ b/src/services/engine/camera/cameraDrivers.ts @@ -12,6 +12,7 @@ import type { BodyId } from '../../../@types/data/body/BodyId'; import type { BodyState } from '../../../@types/scene/BodyState'; import type { CameraDriver } from '../../../@types/engine/camera/CameraDriver'; +import type { DriverActivity } from '../../../@types/engine/camera/DriverActivity'; import type { DriverCtx } from '../../../@types/engine/camera/DriverCtx'; import type { FramedCameraPose } from '../../../@types/camera/FramedCameraPose'; import type { FramedClipPose } from '../../../@types/animation/FramedClipPose'; @@ -19,7 +20,9 @@ import type { Mat3 } from '../../../@types/math/Mat3'; import type { RootState } from '../../../store/types'; import type { CameraEpochs } from '../../../@types/engine/camera/CameraEpochs'; import type { FollowMemory } from '../../../@types/engine/camera/FollowMemory'; +import type { Vec2 } from '../../../@types/math/Vec2'; import type { Vec3 } from '../../../@types/math/Vec3'; +import type { CameraPose } from '../../../@types/camera/CameraPose'; import { absoluteArm } from '../../../utils/camera/absoluteArm'; import { eyeMpcOf } from '../../../utils/camera/eyeMpcOf'; import { orbitAnglesLookingAlong } from '../../../utils/camera/orbitAnglesLookingAlong'; @@ -46,11 +49,11 @@ import { selectionDriver } from '../../../utils/selection/selectionDriver'; export function pickWinner( drivers: readonly CameraDriver[], s: RootState, - approachDone = false, + activity: DriverActivity, ): CameraDriver { let winner: CameraDriver | null = null; for (const d of drivers) { - if (!d.isActive(s, approachDone)) continue; + if (!d.isActive(s, activity)) continue; if (winner === null || d.priority > winner.priority) winner = d; } // Only an empty table reaches the fallback; `resting` is always active. @@ -138,6 +141,7 @@ function followPose( pitch: ang.pitch, distance: Math.hypot(rel[0], rel[1], rel[2]), roll: cur.roll, + lookOffset: cur.lookOffset, }; } @@ -178,11 +182,20 @@ function followPose( // lands per wheel notch, and dropping it pinned a followed approach // to scene-frame up until the engage edge. roll: lerp(from.roll ?? 0, committed.roll ?? 0, t), + lookOffset: easedLookOffset(from, committed, t), }), memory: { from, distanceTarget, panOffset: memory.panOffset, saturated: t >= 1 }, }; } +/** Absent on both ends stays absent, so a pose without an offset keeps its shape. */ +function easedLookOffset(from: CameraPose, to: CameraPose, t: number): Vec2 | undefined { + if (from.lookOffset === undefined && to.lookOffset === undefined) return undefined; + const [fy, fp] = from.lookOffset ?? [0, 0]; + const [ty, tp] = to.lookOffset ?? [0, 0]; + return [lerp(fy, ty, t), lerp(fp, tp, t)]; +} + /** * The exit both keyframe rows share: the rung's own `decode` reads the channels * (spec §8), then an absolute reading is re-encoded from the clip's pinned basis @@ -272,7 +285,7 @@ export const CAMERA_DRIVERS: readonly CameraDriver[] = [ // focus's framing pose — is stated in world terms and the fold refolds it // into whatever arm geometry picks, so an approach owed from inside an arm // the focus HOSTS (standing at a rover, focusing its planet) can fly. - isActive: (s, approachDone = false) => followsFocus(s) && !approachDone, + isActive: (s, activity) => followsFocus(s) && !activity.approachDone, pose: followPose, }, { @@ -311,8 +324,16 @@ export const CAMERA_DRIVERS: readonly CameraDriver[] = [ bodies: ctx.bodies, playback: tween, }); + const framed = framedClipArm(evaluated, pinned, ctx.poseBasis, ctx.bodies); + const offset = tween.from.lookOffset; + if (offset === undefined || !isWorldArm(framed)) + return { pose: framed, memory: settledMemory(mem) }; + // Clips author no offset channel, so the start pose's eases out here, on + // the same easeOutCubic `tweenToClip` gives every other term. + const keep = 1 - easeOutCubic(ctx.elapsedMs / tween.durationMs); + const lookOffset: Vec2 = [offset[0] * keep, offset[1] * keep]; return { - pose: framedClipArm(evaluated, pinned, ctx.poseBasis, ctx.bodies), + pose: absoluteArm({ ...framed.pose, lookOffset }), memory: settledMemory(mem), }; }, diff --git a/src/services/engine/camera/controlSchemes.ts b/src/services/engine/camera/controlSchemes.ts new file mode 100644 index 0000000000..6abda00adb --- /dev/null +++ b/src/services/engine/camera/controlSchemes.ts @@ -0,0 +1,13 @@ +/** + * controlSchemes — the control-scheme registry. `runFrame` resolves the active + * scheme's driver table here, so a second scheme is a new row, not a branch in + * the frame loop. + */ + +import type { ControlScheme } from '../../../@types/engine/camera/ControlScheme'; +import type { ControlSchemeId } from '../../../@types/engine/camera/ControlSchemeId'; +import { CAMERA_DRIVERS } from './cameraDrivers'; + +export const CONTROL_SCHEMES: Readonly> = { + skymap: { drivers: CAMERA_DRIVERS }, +}; diff --git a/src/services/engine/camera/evaluateClip.ts b/src/services/engine/camera/evaluateClip.ts index 629ddaf48c..ce34a08167 100644 --- a/src/services/engine/camera/evaluateClip.ts +++ b/src/services/engine/camera/evaluateClip.ts @@ -596,13 +596,14 @@ function originOfLeg( * 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`). + * Clips author no `lookOffset`, so one starting from an offset pose snaps it to 0. */ function evaluateBaseAt( compiled: CompiledClip, t: number, origin: LegOrigin, beforeSec = Infinity, -): Required { +): Required> { const { baseTracks } = compiled; const { atSec, pose: start } = origin; const window: LegWindow = diff --git a/src/services/engine/camera/rungs/absoluteRung.ts b/src/services/engine/camera/rungs/absoluteRung.ts index 97ddffebb9..ed904fa0a8 100644 --- a/src/services/engine/camera/rungs/absoluteRung.ts +++ b/src/services/engine/camera/rungs/absoluteRung.ts @@ -1,6 +1,7 @@ /** The world arm's row — the ladder's floor: nothing to climb to, and no host body its numbers hang off. */ import type { RungRow } from '../../../../@types/camera/RungRow'; import { absoluteArm } from '../../../../utils/camera/absoluteArm'; +import { nudgedWorldPose } from '../../../../utils/camera/nudgedWorldPose'; import { applyInputToCamera } from '../../../camera/applyInputToCamera'; import { frameAlignedRoll } from '../frameAlignedRoll'; @@ -44,4 +45,30 @@ export const absoluteRung: RungRow<'absolute'> = { ); return { pose: { ...next, roll }, memory: null, tilt }; }, + nudge(tilt, framed, delta, ctx) { + const { zoom, ...rest } = delta; + // Zoom IS the wheel step, roll ride included, so a nudge cannot pop at the engage flip. + const zoomed = + zoom === undefined + ? framed.pose + : absoluteRung.step( + null, + tilt, + framed, + { kind: 'zoom', factor: Math.exp(zoom), duringGesture: false, cursorPx: null }, + ctx, + ).pose; + return { + pose: nudgedWorldPose( + zoomed, + rest, + ctx.viewportPx[1], + ctx.pivot, + ctx.fovYRad, + ctx.poseBasis, + ctx.upBasis, + ), + tilt, + }; + }, }; diff --git a/src/services/engine/camera/rungs/bodyRung.ts b/src/services/engine/camera/rungs/bodyRung.ts index 1c9cfeca48..21cfb141cf 100644 --- a/src/services/engine/camera/rungs/bodyRung.ts +++ b/src/services/engine/camera/rungs/bodyRung.ts @@ -9,6 +9,9 @@ */ import type { ClimbRow } from '../../../../@types/camera/ClimbRow'; +import type { HostBody } from '../../../../@types/camera/HostBody'; +import type { RungCtx } from '../../../../@types/camera/RungCtx'; +import type { SurfaceStepCtx } from '../../../../@types/camera/SurfaceStepCtx'; import { EMPTY_SURFACE_GESTURE_MEMORY, surfaceStep } from '../../../camera/surfaceStep'; import { SCENE_CELESTIAL_BODIES } from '../../../../data/bodies/sceneCelestialBodies'; import { bodyFixedEyeM } from '../../../../utils/camera/bodyFixedEyeM'; @@ -18,6 +21,7 @@ import { focusInSubtree } from '../../../../utils/camera/focusInSubtree'; import { frameUp } from '../../../../utils/camera/frameUp'; import { hostedFocusOverHorizon } from '../../../../utils/camera/hostedFocusOverHorizon'; import { hostedFocusPivotM } from '../../../../utils/camera/hostedFocusPivotM'; +import { nudgedSurfacePose } from '../../../../utils/camera/nudgedSurfacePose'; import { toBodyFixedChannels } from '../toBodyFixedChannels'; import { rotateVec3ByTightMat3T } from '../../../../utils/math/rotateVec3ByTightMat3T'; import { surfaceGestureEdge } from '../../../../utils/camera/surfaceGestureEdge'; @@ -30,6 +34,24 @@ import { toBodyArm, toWorldArm } from '../poseFrameConversion'; import { hostOf } from './hostOf'; import { hostOrThrow } from './hostOrThrow'; +function surfaceStepCtxOf(host: HostBody, ctx: RungCtx): SurfaceStepCtx { + return { + viewportPx: ctx.viewportPx, + fovYRad: ctx.fovYRad, + bodyRadiusM: host.radiusM, + standoffRadii: host.standoffRadii, + groundRadiusAtM: host.groundRadiusAtM, + innerBoundRadiusM: host.innerBoundRadiusM, + outerBoundRadiusM: host.outerBoundRadiusM, + // The body rotates under the scene frame, so this is resampled per drain. + sceneUpLocal: rotateVec3ByTightMat3T(frameUp(ctx.upBasis), host.state.orientation), + // Derived from the FOCUS every drain, never carried in the pose: a + // carried anchor decouples from the rover as soon as a drag turns the arm. + focusPivotM: hostedFocusPivotM(ctx.focusBodyId, host.id, host.radiusM), + tuning: ctx.tuning, + }; +} + export const bodyRung: ClimbRow<'body'> = { kind: 'body', parent: 'absolute', @@ -75,25 +97,16 @@ export const bodyRung: ClimbRow<'body'> = { tilt, }; } - const host = hostOrThrow(framed.frame, ctx); - const stepped = surfaceStep(memory, tilt, framed.pose, input, { - viewportPx: ctx.viewportPx, - fovYRad: ctx.fovYRad, - bodyRadiusM: host.radiusM, - standoffRadii: host.standoffRadii, - groundRadiusAtM: host.groundRadiusAtM, - innerBoundRadiusM: host.innerBoundRadiusM, - outerBoundRadiusM: host.outerBoundRadiusM, - // The body rotates under the scene frame, so this is resampled per drain. - sceneUpLocal: rotateVec3ByTightMat3T(frameUp(ctx.upBasis), host.state.orientation), - // Derived from the FOCUS every drain, never carried in the pose: a - // carried anchor decouples from the rover as soon as a drag turns the arm. - focusPivotM: hostedFocusPivotM(ctx.focusBodyId, host.id, host.radiusM), - tuning: ctx.tuning, - }); + const surfaceCtx = surfaceStepCtxOf(hostOrThrow(framed.frame, ctx), ctx); + const stepped = surfaceStep(memory, tilt, framed.pose, input, surfaceCtx); return { pose: stepped.pose, memory: stepped.gesture, tilt: stepped.tilt }; }, + nudge(tilt, framed, delta, ctx) { + const surfaceCtx = surfaceStepCtxOf(hostOrThrow(framed.frame, ctx), ctx); + return nudgedSurfacePose(framed.pose, tilt, delta, surfaceCtx); + }, + toParent(framed, ctx) { const host = hostOrThrow(framed.frame, ctx); return { diff --git a/src/services/engine/camera/rungs/siteRung.ts b/src/services/engine/camera/rungs/siteRung.ts index 7605b44a5d..6c8f3dfc25 100644 --- a/src/services/engine/camera/rungs/siteRung.ts +++ b/src/services/engine/camera/rungs/siteRung.ts @@ -17,6 +17,7 @@ import { siteGroundRadiusM } from '../../../../utils/camera/siteGroundRadiusM'; import { sitePointBodyFixed } from '../../../../utils/camera/sitePointBodyFixed'; import { sitePoseFromBodyArm } from '../../../../utils/camera/sitePoseFromBodyArm'; import { sitePoseToBodyArm } from '../../../../utils/camera/sitePoseToBodyArm'; +import { nudgedSitePose } from '../../../../utils/camera/nudgedSitePose'; import { steppedSitePose } from '../../../../utils/camera/steppedSitePose'; import { hostOf } from './hostOf'; import { hostOrThrow } from './hostOrThrow'; @@ -93,6 +94,19 @@ export const siteRung: ClimbRow<'site'> = { }; }, + nudge(tilt, framed, delta, ctx) { + return { + pose: nudgedSitePose( + framed.pose, + delta, + meshBodyOf(framed.frame.site), + ctx.viewportPx, + ctx.fovYRad, + ), + tilt, + }; + }, + toParent(framed, ctx) { const host = hostOrThrow(framed.frame, ctx); const site = siteRowOrThrow(framed.frame.site); diff --git a/src/services/engine/camera/stepCameraRuntime.ts b/src/services/engine/camera/stepCameraRuntime.ts index 9807f39df4..a8396581a4 100644 --- a/src/services/engine/camera/stepCameraRuntime.ts +++ b/src/services/engine/camera/stepCameraRuntime.ts @@ -160,11 +160,13 @@ export function stepCameraRuntime( // old memory said, so the two are one fact and cannot disagree on phase. An // outside commit hands off too: the commit IS the framing, so no approach // is owed, whatever the epoch reads. - const approachDone = - external || (focus === prev.epochs.follow.ref ? (drained.follow?.saturated ?? false) : false); + const activity = { + approachDone: + external || (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 winner = pickWinner(drivers, rootState, activity); const winnerId = winner.id; const epochs = advanceEpochs(prev.epochs, { intent: rootState.camera, diff --git a/src/services/engine/frame/runFrame.ts b/src/services/engine/frame/runFrame.ts index 9bf0aff0c7..39f45adf2e 100644 --- a/src/services/engine/frame/runFrame.ts +++ b/src/services/engine/frame/runFrame.ts @@ -131,7 +131,7 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): bodies: bodyStates, terrainHeightAt: terrainHeightAtOf(state.subsystems.surfaceTiles), clipEpoch, - drivers: deps.drivers, + drivers: deps.controlSchemes.skymap.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. diff --git a/src/services/engine/phases/startLoop.ts b/src/services/engine/phases/startLoop.ts index 3b8b82c291..474d009e7f 100644 --- a/src/services/engine/phases/startLoop.ts +++ b/src/services/engine/phases/startLoop.ts @@ -9,7 +9,7 @@ import { runFrame } from '../frame/runFrame'; import { checkFrameOrder } from '../frame/checkFrameOrder'; import { VIEW_RIGS } from '../../../data/rendering/viewRigs'; -import { CAMERA_DRIVERS } from '../camera/cameraDrivers'; +import { CONTROL_SCHEMES } from '../camera/controlSchemes'; import { goLiveNowAction } from '../../../state/time/goLiveNowAction'; import { selectTimeState } from '../../../state/time/selectors'; import type { RunFrameDeps } from '../../../@types/engine/frame/RunFrameDeps'; @@ -44,7 +44,7 @@ export async function startLoop(state: EngineState, deps: BootstrapDeps): Promis device: phaseLocals.device, context: phaseLocals.context, timingService: state.gpu.timingService, - drivers: CAMERA_DRIVERS, + controlSchemes: CONTROL_SCHEMES, }; // The scheduler was wired with `onFrame: () => frameRef.current()`, reading the diff --git a/src/services/gpu/renderers/horizonShell/horizonShellRenderer.ts b/src/services/gpu/renderers/horizonShell/horizonShellRenderer.ts index 09d6842910..26165e4428 100644 --- a/src/services/gpu/renderers/horizonShell/horizonShellRenderer.ts +++ b/src/services/gpu/renderers/horizonShell/horizonShellRenderer.ts @@ -35,11 +35,11 @@ * offset 48 | vec3 cameraPosGpc (world pos / 1000) + f32 fadeAlpha */ -import { vec3 } from 'wgpu-matrix'; import type { Vec3 } from '../../../../@types/math/Vec3'; import type { ImagePlaneBasis } from '../../../../@types/camera/ImagePlaneBasis'; import { imagePlaneBasis } from '../../../../utils/camera/imagePlaneBasis'; import { frameUp } from '../../../../utils/camera/frameUp'; +import { orbitForwardOf } from '../../../../utils/camera/orbitForwardOf'; import vsCode from '../../shaders/horizonShell/vertex.wesl?static'; import fsCode from '../../shaders/horizonShell/fragment.wesl?static'; import { createShaderModuleWithDevLog } from '../../shaderCompileLogger'; @@ -118,9 +118,8 @@ export function createHorizonShellRenderer(init: Init): HorizonShellRenderer { // Per-frame scratch, allocated once to avoid GC churn. const uniforms = new ArrayBuffer(HORIZON_SHELL_UNIFORM_BUFFER_SIZE); const f32 = new Float32Array(uniforms); - // Plain Vec3 tuples (not vec3.create's Float32Array) so the per-component - // reads below index cleanly under noUncheckedIndexedAccess. wgpu-matrix - // writes into them in place via the `dst` arg just the same. + // Plain Vec3 tuples so the per-component reads below index cleanly under + // noUncheckedIndexedAccess; `orbitForwardOf` writes into them in place. const fwd: Vec3 = [0, 0, 0]; // Frame-pole reference up, allocated once and rewritten in place each frame. const upRefScratch: Vec3 = [0, 0, 0]; @@ -136,7 +135,7 @@ export function createHorizonShellRenderer(init: Init): HorizonShellRenderer { ): void { // ── Camera basis (matches gl-matrix lookAt in computeViewProj) ──── // - // forward = normalize(target - position) + // forward = orbitForwardOf(cam) (decoded, so a lookOffset turns it) // right = normalize(forward × rolledUp) // up = normalize(right × forward) // @@ -146,8 +145,7 @@ export function createHorizonShellRenderer(init: Init): HorizonShellRenderer { // `imagePlaneBasis`, which rolls the frame pole (`frameUp(cam.upBasis)`; // world +Y absent a basis) about the view direction — so the shell rolls in // lockstep with `computeViewProj` (both read the same draw-time `upBasis`). - vec3.subtract(cam.target, cam.position, fwd); - vec3.normalize(fwd, fwd); + orbitForwardOf(cam, fwd); imagePlaneBasis(fwd, cam.roll ?? 0, frameUp(cam.upBasis, upRefScratch), basis); const right = basis.right; const up = basis.up; diff --git a/src/state/camera/cameraSlice.ts b/src/state/camera/cameraSlice.ts index 1886b93a31..8c0bbe44c5 100644 --- a/src/state/camera/cameraSlice.ts +++ b/src/state/camera/cameraSlice.ts @@ -53,7 +53,8 @@ const cameraSlice = createSlice({ camera.dragging = false; }, - // INVARIANT (R12b-3): every committed ABSOLUTE pose is centre-looking. The + // INVARIANT (R12b-3): every committed ABSOLUTE pose's orbit terms are + // centre-looking; `lookOffset` turns only the view. 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 diff --git a/src/state/persistedValues.ts b/src/state/persistedValues.ts new file mode 100644 index 0000000000..7cb1cf8233 --- /dev/null +++ b/src/state/persistedValues.ts @@ -0,0 +1,18 @@ +/** + * The store values that survive a reload, one row each. `main.tsx` installs + * the writer over `PERSISTED_VALUES`; boot-time seeding reads a row directly. + */ +import type { PersistedValue } from '../@types/state/PersistedValue'; +import { selectSplashDismissedVersion } from './ui/selectors'; + +export const SPLASH_SEEN_VERSION: PersistedValue = { + key: 'skymap.splash.seenVersion', + select: selectSplashDismissedVersion, + parse: (raw) => { + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : null; + }, + serialize: (v) => String(v), +}; + +export const PERSISTED_VALUES = [SPLASH_SEEN_VERSION] as const; diff --git a/src/state/ui/buildInitialUiState.ts b/src/state/ui/buildInitialUiState.ts index 15556a2f28..6659e2c158 100644 --- a/src/state/ui/buildInitialUiState.ts +++ b/src/state/ui/buildInitialUiState.ts @@ -20,7 +20,9 @@ import type { UiState } from '../../@types/ui/UiState'; import { hasDeepLink } from '../../utils/url/hasDeepLink'; import { isCinemaSearch } from '../../utils/url/isCinemaSearch'; -import { CURRENT_SPLASH_VERSION, readSeenVersion, readUrlAtMount } from './splashStorage'; +import { readPersisted } from '../../utils/storage/readPersisted'; +import { SPLASH_SEEN_VERSION } from '../persistedValues'; +import { CURRENT_SPLASH_VERSION, readUrlAtMount } from './splashStorage'; /** * Compute the initial UiState. Called once at store construction. @@ -34,7 +36,7 @@ import { CURRENT_SPLASH_VERSION, readSeenVersion, readUrlAtMount } from './splas */ export function buildInitialUiState(): UiState { const { hash, search } = readUrlAtMount(); - const seen = readSeenVersion(); + const seen = readPersisted(SPLASH_SEEN_VERSION); // Every gate reads the same readUrlAtMount() capture — no gate takes a // second, live look at window.location that could disagree with it. diff --git a/src/state/ui/persistSplashVersion.ts b/src/state/ui/persistSplashVersion.ts deleted file mode 100644 index 5f9afe5f7e..0000000000 --- a/src/state/ui/persistSplashVersion.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * persistSplashVersion — thin localStorage effect for splash dismiss. - * - * Writing `seenVersion` is a reactive CONSEQUENCE of `dismissSplash`, not - * slice state, so it lives OUTSIDE the slice as a thin side-effect subscriber. - * On this branch the effect is a `store.subscribe` diff on `dismissedVersion`; - * an alternative `takeEvery(dismissSplash)` saga would also work, but the - * reconcile-saga seam is not on this branch, so the subscription is the chosen - * form. - * - * The diff-on-`dismissedVersion` (not on every dispatch) means unrelated UI - * writes don't touch storage, and `reopenSplash` produces no write because it - * leaves `dismissedVersion` unchanged — the subscriber fires but the diff - * guard short-circuits. - */ - -import { selectSplashDismissedVersion } from './selectors'; -import { writeSeenVersion } from './splashStorage'; -import type { AppStore } from '../../store/types'; - -/** - * Install a store subscriber that writes `dismissedVersion` to localStorage - * whenever it transitions to a non-null value. - * - * Returns the unsubscribe function (caller decides the lifetime). - */ -export function persistSplashVersion(store: AppStore): () => void { - // Snapshot BEFORE subscribing so a returning user whose store was seeded - // with a non-null dismissedVersion does not trigger a spurious write on - // the first subscriber tick. - let lastSeen = selectSplashDismissedVersion(store.getState()); - - const unsubscribe = store.subscribe(() => { - const current = selectSplashDismissedVersion(store.getState()); - if (current !== lastSeen && current !== null) { - writeSeenVersion(current); - lastSeen = current; - } - // If current === null (can't happen via reducers, but guarded for clarity), - // skip and leave lastSeen unchanged — null is not a dismissal event. - }); - - return unsubscribe; -} diff --git a/src/state/ui/splashStorage.ts b/src/state/ui/splashStorage.ts index d974364d20..c5ca08d4cd 100644 --- a/src/state/ui/splashStorage.ts +++ b/src/state/ui/splashStorage.ts @@ -1,23 +1,14 @@ /** - * splashStorage — splash bootstrap I/O. + * splashStorage — splash bootstrap inputs. * - * Holds the persisted seen-version and the mount-time URL that the initial - * splash visibility decision reads, with SSR + private-browsing guards. + * Holds the current splash version and the mount-time URL that the initial + * splash visibility decision reads. The persisted seen-version is the + * `SPLASH_SEEN_VERSION` row in `src/state/persistedValues.ts`. * - * ### Why a separate module - * - * Originally these functions lived inside useSplash.ts. That caused a layering - * inversion: buildInitialUiState (state layer) needed to call them to seed the - * Redux store, but they were defined inside the hooks layer. Importing from - * hooks into state is the wrong direction — the store should not depend on - * React. Moving I/O here lets the state layer read them without touching hooks, - * and useSplash can import them back, which is the correct hooks → state - * direction. + * Lives in the state layer (not beside useSplash) so buildInitialUiState can + * read it without the store depending on the hooks layer. */ -/** Persisted storage key — never rename without a migration. */ -export const SPLASH_STORAGE_KEY = 'skymap.splash.seenVersion'; - /** * Version stamp written to localStorage on dismiss. Bump when meaningful * splash content changes — increments re-show the splash to returning @@ -25,34 +16,6 @@ export const SPLASH_STORAGE_KEY = 'skymap.splash.seenVersion'; */ export const CURRENT_SPLASH_VERSION = 1; -/** - * Read seenVersion from localStorage. Returns null when the key is absent, - * the value is non-integer, or the environment is SSR / private-browsing. - * Null means "not seen" — no 0-sentinel magic needed. - */ -export function readSeenVersion(): number | null { - if (typeof window === 'undefined') return null; - try { - const raw = window.localStorage.getItem(SPLASH_STORAGE_KEY); - if (raw === null) return null; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) ? parsed : null; - } catch { - return null; - } -} - -/** Write seenVersion to localStorage. Swallows storage errors silently. */ -export function writeSeenVersion(version: number): void { - if (typeof window === 'undefined') return; - try { - window.localStorage.setItem(SPLASH_STORAGE_KEY, String(version)); - } catch { - // Private browsing or storage quota — best-effort; the splash will - // re-show next time, which is acceptable degraded behaviour. - } -} - /** * Read the current URL hash + search, returning empty strings under SSR. * Captured lazily at store construction so the splash decision does not flip diff --git a/src/utils/camera/draggedSurfacePose.ts b/src/utils/camera/draggedSurfacePose.ts index 0c913e6c50..5e4d117b9c 100644 --- a/src/utils/camera/draggedSurfacePose.ts +++ b/src/utils/camera/draggedSurfacePose.ts @@ -4,13 +4,13 @@ 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 { lookedSurfacePose } from './lookedSurfacePose'; +import { orbitedSurfacePose } from './orbitedSurfacePose'; import { pickOnBody } from './pickOnBody'; -import { rotateBasisByQuat } from './rotateBasisByQuat'; import { rotatedAboutPoint } from './rotatedAboutPoint'; import { tiltFloorBudgetRad } from './tiltFloorBudgetRad'; import { dot3 } from '../math/dot3'; @@ -51,26 +51,8 @@ export function draggedSurfacePose( 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 }; - } + if (mode === 'orbit') return { pose: orbitedSurfacePose(arm, yawRad, pitchRad), mode }; + if (mode === 'look') return { pose: lookedSurfacePose(arm, yawRad, pitchRad), mode }; const anchorM = gesture.anchorLocalM; // Both modes below latch an anchor, so this only keeps the arm total. diff --git a/src/utils/camera/lookedSurfacePose.ts b/src/utils/camera/lookedSurfacePose.ts new file mode 100644 index 0000000000..9ea4567d1a --- /dev/null +++ b/src/utils/camera/lookedSurfacePose.ts @@ -0,0 +1,27 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { bodyFixedEyeM } from './bodyFixedEyeM'; +import { rotateBasisByQuat } from './rotateBasisByQuat'; +import { multiplyQuat } from '../math/multiplyQuat'; +import { normalize3 } from '../math/normalize3'; +import { quatFromAxisAngle } from '../math/quatFromAxisAngle'; + +/** + * Turns the view about the eye by screen radians. Yaw is about the LOCAL + * vertical rather than the camera's own up: that 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. + */ +export function lookedSurfacePose( + arm: BodyFixedPose, + yawRad: number, + pitchRad: number, +): BodyFixedPose { + const b = arm.basisLocal; + const right: Vec3 = [b[0], b[1], b[2]]; + const q = multiplyQuat( + quatFromAxisAngle(right, pitchRad), + quatFromAxisAngle(normalize3(bodyFixedEyeM(arm)), yawRad), + ); + return { ...arm, basisLocal: rotateBasisByQuat(q, arm.basisLocal) }; +} diff --git a/src/utils/camera/nudgedSitePose.ts b/src/utils/camera/nudgedSitePose.ts new file mode 100644 index 0000000000..f87bba884a --- /dev/null +++ b/src/utils/camera/nudgedSitePose.ts @@ -0,0 +1,46 @@ +import type { ArmDelta } from '../../@types/camera/ArmDelta'; +import type { SitePose } from '../../@types/camera/SitePose'; +import type { MeshBody } from '../../@types/scene/MeshBody'; +import type { Vec2 } from '../../@types/math/Vec2'; +import { steppedSitePose } from './steppedSitePose'; + +/** + * The site turntable's pixel-free motion: zoom → orbit, both through the drag's + * own step so the gain law and the declined-notch identity stay one + * derivation. The turntable has no look or roll, so those axes are ignored. + */ +export function nudgedSitePose( + pose: SitePose, + delta: ArmDelta, + body: MeshBody, + viewportPx: Readonly, + fovYRad: number, +): SitePose { + const { orbit, zoom } = delta; + let next = pose; + if (zoom !== undefined) { + next = steppedSitePose( + next, + { kind: 'zoom', factor: Math.exp(zoom), duringGesture: false, cursorPx: null }, + body, + viewportPx, + fovYRad, + ); + } + if (orbit !== undefined) { + const pxPerRad = viewportPx[1] / fovYRad; + next = steppedSitePose( + next, + { + kind: 'drag', + mode: 'orbit', + startPx: [0, 0], + endPx: [orbit[0] * pxPerRad, orbit[1] * pxPerRad], + }, + body, + viewportPx, + fovYRad, + ); + } + return next; +} diff --git a/src/utils/camera/nudgedSurfacePose.ts b/src/utils/camera/nudgedSurfacePose.ts new file mode 100644 index 0000000000..68e7e436c1 --- /dev/null +++ b/src/utils/camera/nudgedSurfacePose.ts @@ -0,0 +1,54 @@ +import type { ArmDelta } from '../../@types/camera/ArmDelta'; +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { SurfaceStepCtx } from '../../@types/camera/SurfaceStepCtx'; +import type { TiltMemory } from '../../@types/camera/TiltMemory'; +import { lookedSurfacePose } from './lookedSurfacePose'; +import { orbitedSurfacePose } from './orbitedSurfacePose'; +import { rollBasisAboutView } from './rollBasisAboutView'; +import { settledDragPose } from './settledDragPose'; +import { surfaceZoomStep } from './surfaceZoomStep'; + +/** + * The body arm's pixel-free motion: zoom (anchored at screen centre, settling + * itself) → orbit → look, then ONE drag settle, then roll. Roll is applied + * after the settle, which would level it away; it moves no eye and keeps + * forward, so neither the floor nor the tilt memory would have seen it. + */ +export function nudgedSurfacePose( + arm: BodyFixedPose, + tilt: TiltMemory, + delta: ArmDelta, + ctx: SurfaceStepCtx, +): { readonly pose: BodyFixedPose; readonly tilt: TiltMemory } { + const { orbit, look, zoom, roll } = delta; + const zoomed = + zoom === undefined + ? arm + : surfaceZoomStep( + arm, + null, + Math.exp(zoom), + null, + ctx.viewportPx, + ctx.fovYRad, + ctx.bodyRadiusM, + ctx.standoffRadii, + ctx.groundRadiusAtM, + ctx.innerBoundRadiusM, + ctx.outerBoundRadiusM, + ctx.sceneUpLocal, + tilt.rememberedTiltRad, + ctx.tuning, + ctx.focusPivotM, + ); + let settled = { pose: zoomed, tilt }; + if (orbit !== undefined || look !== undefined) { + let moved = zoomed; + if (orbit !== undefined) moved = orbitedSurfacePose(moved, orbit[0], orbit[1]); + if (look !== undefined) moved = lookedSurfacePose(moved, look[0], look[1]); + settled = settledDragPose(zoomed, moved, look !== undefined ? 'look' : 'orbit', tilt, ctx); + } + if (roll === undefined) return settled; + const { pose } = settled; + return { ...settled, pose: { ...pose, basisLocal: rollBasisAboutView(pose.basisLocal, roll) } }; +} diff --git a/src/utils/camera/nudgedWorldPose.ts b/src/utils/camera/nudgedWorldPose.ts new file mode 100644 index 0000000000..78860f2acb --- /dev/null +++ b/src/utils/camera/nudgedWorldPose.ts @@ -0,0 +1,54 @@ +import type { ArmDelta } from '../../@types/camera/ArmDelta'; +import type { CameraPose } from '../../@types/camera/CameraPose'; +import type { PivotFraming } from '../../@types/camera/PivotFraming'; +import type { Mat3 } from '../../@types/math/Mat3'; +import { PITCH_LIMIT } from '../../data/camera/pitchLimit'; +import { applyInputToCamera } from '../../services/camera/applyInputToCamera'; + +/** + * The world arm's pixel-free motion, axes in the order orbit → look → roll; + * zoom is `absoluteRung.step`'s, run by the rung first. Orbit goes back through the drag's own pixel law (altitude damping and + * pitch clamp included) so a nudge and its equivalent drag cannot drift apart. + * `cssHeight` is the CSS height, as for the drag. + */ +export function nudgedWorldPose( + pose: CameraPose, + delta: Omit, + cssHeight: number, + pivot: PivotFraming, + fovYRad: number, + poseBasis: Readonly, + upBasis: Readonly, +): CameraPose { + const { orbit, look, roll } = delta; + if (orbit === undefined && look === undefined && roll === undefined) { + return pose; + } + let next = pose; + if (orbit !== undefined) { + const pxPerRad = cssHeight / fovYRad; + next = applyInputToCamera( + next, + { + kind: 'drag', + mode: 'orbit', + startPx: [0, 0], + endPx: [orbit[0] * pxPerRad, orbit[1] * pxPerRad], + }, + cssHeight, + pivot, + fovYRad, + poseBasis, + upBasis, + ); + } + if (look !== undefined) { + const [yaw, pitch] = next.lookOffset ?? [0, 0]; + next = { + ...next, + lookOffset: [yaw + look[0], Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, pitch + look[1]))], + }; + } + if (roll !== undefined) next = { ...next, roll: (next.roll ?? 0) + roll }; + return next; +} diff --git a/src/utils/camera/orbitForwardOf.ts b/src/utils/camera/orbitForwardOf.ts index 4e1d283508..d905e47131 100644 --- a/src/utils/camera/orbitForwardOf.ts +++ b/src/utils/camera/orbitForwardOf.ts @@ -2,12 +2,19 @@ * `poseBasis`·(yaw, pitch) rather than `target − position`. The subtraction * cancels to zero when `distance` sits below the eye's f64 ULP (a metre-near * probe face 0.1 pc out); the decode is exact at any scale, and is the same - * `dir` `eyeMpcOf` placed the eye along, negated. */ + * `dir` `eyeMpcOf` placed the eye along, negated. A non-zero `lookOffset` then + * turns it about the eye: yaw about the frame up, pitch about the resulting + * right; the single place a view builder learns of the offset. */ import type { OrbitCamera } from '../../@types/camera/OrbitCamera'; import type { Vec3 } from '../../@types/math/Vec3'; import { yawPitchToDir } from './yawPitchToDir'; +import { frameUp } from './frameUp'; import { rotateVec3ByTightMat3 } from '../math/rotateVec3ByTightMat3'; +import { rotateVec3ByQuat } from '../math/rotateVec3ByQuat'; +import { quatFromAxisAngle } from '../math/quatFromAxisAngle'; +import { cross3 } from '../math/cross3'; +import { normalize3 } from '../math/normalize3'; const scratchDir: Vec3 = [0, 0, 0]; @@ -18,5 +25,16 @@ export function orbitForwardOf(cam: OrbitCamera, out?: Vec3): Vec3 { dst[0] = -dst[0]; dst[1] = -dst[1]; dst[2] = -dst[2]; + const offset = cam.lookOffset; + // Absent and [0, 0] must share one path bit for bit, or every golden drifts. + if (offset === undefined || (offset[0] === 0 && offset[1] === 0)) return dst; + const up = frameUp(cam.upBasis); + const yawed = rotateVec3ByQuat(quatFromAxisAngle(up, offset[0]), dst); + // `yawed × up` is right-handed screen right, so +pitch tilts toward the frame up. + const right = normalize3(cross3(yawed, up)); + const turned = rotateVec3ByQuat(quatFromAxisAngle(right, offset[1]), yawed); + dst[0] = turned[0]; + dst[1] = turned[1]; + dst[2] = turned[2]; return dst; } diff --git a/src/utils/camera/orbitedSurfacePose.ts b/src/utils/camera/orbitedSurfacePose.ts new file mode 100644 index 0000000000..180a35fedf --- /dev/null +++ b/src/utils/camera/orbitedSurfacePose.ts @@ -0,0 +1,24 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { Vec3 } from '../../@types/math/Vec3'; +import { BODY_LOCAL_FRAME } from '../../data/camera/bodyLocalFrame'; +import { rotatedAboutPoint } from './rotatedAboutPoint'; +import { multiplyQuat } from '../math/multiplyQuat'; +import { quatFromAxisAngle } from '../math/quatFromAxisAngle'; + +/** + * Orbits the body centre AGAINST the screen-radian motion, which is what + * carries a grabbed limb along with the cursor. The caller's level settle + * holds the entry heading, so this is the north-locked orbit, not a free + * trackball. + */ +export function orbitedSurfacePose( + arm: BodyFixedPose, + yawRad: number, + pitchRad: number, +): BodyFixedPose { + const b = arm.basisLocal; + const right: Vec3 = [b[0], b[1], b[2]]; + const up: Vec3 = [b[3], b[4], b[5]]; + const q = multiplyQuat(quatFromAxisAngle(right, -pitchRad), quatFromAxisAngle(up, -yawRad)); + return rotatedAboutPoint(arm, q, BODY_LOCAL_FRAME.centreM); +} diff --git a/src/utils/camera/reencodePose.ts b/src/utils/camera/reencodePose.ts index 094ba630d4..3ff3795776 100644 --- a/src/utils/camera/reencodePose.ts +++ b/src/utils/camera/reencodePose.ts @@ -38,5 +38,6 @@ export function reencodePose( pitch, distance: pose.distance, roll: pose.roll, + lookOffset: pose.lookOffset, }; } diff --git a/src/utils/camera/rollBasisAboutView.ts b/src/utils/camera/rollBasisAboutView.ts new file mode 100644 index 0000000000..e6673b36bf --- /dev/null +++ b/src/utils/camera/rollBasisAboutView.ts @@ -0,0 +1,24 @@ +import type { Mat3 } from '../../@types/math/Mat3'; + +/** + * Rolls a right | up | forward basis about its forward column, in + * `imagePlaneBasis`'s handedness for `right = forward × up`, so a positive + * angle reads as the same image roll the world arm's `roll` gives. A rotation + * within the right–up plane, so the basis stays orthonormal. + */ +export function rollBasisAboutView(basis: Readonly, rad: number): Mat3 { + const c = Math.cos(rad); + const s = Math.sin(rad); + const [rx, ry, rz, ux, uy, uz, fx, fy, fz] = basis; + return [ + rx * c + ux * s, + ry * c + uy * s, + rz * c + uz * s, + ux * c - rx * s, + uy * c - ry * s, + uz * c - rz * s, + fx, + fy, + fz, + ] as Mat3; +} diff --git a/src/utils/camera/settledDragPose.ts b/src/utils/camera/settledDragPose.ts new file mode 100644 index 0000000000..18b9958645 --- /dev/null +++ b/src/utils/camera/settledDragPose.ts @@ -0,0 +1,65 @@ +import type { BodyFixedPose } from '../../@types/camera/BodyFixedPose'; +import type { SettleCtx } from '../../@types/camera/SettleCtx'; +import type { SurfaceGesture } from '../../@types/camera/SurfaceGesture'; +import type { TiltMemory } from '../../@types/camera/TiltMemory'; +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 './bodyFixedEyeM'; +import { bodyUpWeight } from './bodyUpWeight'; +import { eyeFrameOf } from './eyeFrameOf'; +import { flooredBodyPose } from './flooredBodyPose'; +import { levelledPose } from './levelledPose'; +import { unmappedTiltRad } from './unmappedTiltRad'; + +/** + * The body arm's post-move settle, in its one order: floor → level → tilt + * memory. The level runs on the FLOORED pose because the floor moves the eye + * radially and the ENU it settles against has to be the final standpoint. + * Drags level against the PURE body ENU; a drag-created deviation from the + * zoom's band blend is "unauthored" and the next notch's decay settles it. + */ +export function settledDragPose( + entry: BodyFixedPose, + moved: BodyFixedPose, + mode: SurfaceGesture['mode'], + tilt: TiltMemory, + ctx: SettleCtx, +): { pose: BodyFixedPose; tilt: TiltMemory } { + const { groundRadiusAtM, standoffRadii, bodyRadiusM, tuning } = ctx; + // The ENTRY heading, which pan transports. + const preInPoleFrame = eyeFrameOf(entry, 1, BODY_LOCAL_FRAME.pole); + // No pivot: a drag serves its own gesture anchor, not the focus, and the + // tilt handle already spends its floor budget before reaching here. + const floored = flooredBodyPose(moved, groundRadiusAtM, standoffRadii, null); + // 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. + let rememberedTiltRad = tilt.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, tilt: { ...tilt, rememberedTiltRad } }; +} diff --git a/src/utils/storage/persistValues.ts b/src/utils/storage/persistValues.ts new file mode 100644 index 0000000000..a00210d902 --- /dev/null +++ b/src/utils/storage/persistValues.ts @@ -0,0 +1,27 @@ +import type { PersistedValue } from '../../@types/state/PersistedValue'; +import type { AppStore } from '../../store/types'; + +/** + * One store subscription writing each row's value on change. Snapshots are + * taken BEFORE subscribing so a store seeded from storage writes nothing at + * install; write errors (private mode, quota) are swallowed: best-effort. + */ +export function persistValues( + store: AppStore, + rows: readonly PersistedValue[], +): () => void { + const last = rows.map((row) => row.select(store.getState())); + return store.subscribe(() => { + const state = store.getState(); + rows.forEach((row, i) => { + const current = row.select(state); + if (current === last[i]) return; + last[i] = current; + try { + window.localStorage.setItem(row.key, row.serialize(current)); + } catch { + // Degraded but acceptable: the value is simply not remembered. + } + }); + }); +} diff --git a/src/utils/storage/readPersisted.ts b/src/utils/storage/readPersisted.ts new file mode 100644 index 0000000000..8fef8ccab3 --- /dev/null +++ b/src/utils/storage/readPersisted.ts @@ -0,0 +1,12 @@ +import type { PersistedValue } from '../../@types/state/PersistedValue'; + +/** Null on absence, unparsable value, SSR, or a private-mode storage throw. */ +export function readPersisted(row: PersistedValue): T | null { + if (typeof window === 'undefined') return null; + try { + const raw = window.localStorage.getItem(row.key); + return raw === null ? null : row.parse(raw); + } catch { + return null; + } +} diff --git a/src/utils/url/decodeFramedPose.ts b/src/utils/url/decodeFramedPose.ts index dc4542ab94..89fd0e169e 100644 --- a/src/utils/url/decodeFramedPose.ts +++ b/src/utils/url/decodeFramedPose.ts @@ -49,7 +49,8 @@ export function decodeFramedPose(value: string): FramedCameraPose | null { } if (tag === 'a') { - if (fields.length !== 8) return null; + // 8 fields, or 10 with a trailing `lookOffset` pair. + if (fields.length !== 8 && fields.length !== 10) return null; const n = parseFiniteNumbers(fields.slice(1)); if (n === null) return null; // `distance` is `CameraPose`'s Mpc field, so the same degeneracy floor @@ -63,6 +64,7 @@ export function decodeFramedPose(value: string): FramedCameraPose | null { pitch: n[4]!, distance: n[5]!, roll: n[6]!, + ...(n.length === 9 && { lookOffset: [n[7]!, n[8]!] }), }, }; } diff --git a/src/utils/url/encodeFramedPose.ts b/src/utils/url/encodeFramedPose.ts index 35418c209d..bc8affd2d2 100644 --- a/src/utils/url/encodeFramedPose.ts +++ b/src/utils/url/encodeFramedPose.ts @@ -20,7 +20,11 @@ export function encodeFramedPose(framed: FramedCameraPose): string { // s,,,, return ['s', siteId, headingRad, elevationRad, rangeM].map(String).join(','); } - const { target, yaw, pitch, distance, roll } = framed.pose; - // a,,,,, - return ['a', ...target, yaw, pitch, distance, roll ?? 0].map(String).join(','); + const { target, yaw, pitch, distance, roll, lookOffset } = framed.pose; + // a,,,,,[,,] + const fields = ['a', ...target, yaw, pitch, distance, roll ?? 0]; + // Only a non-zero offset lengthens the link, so every existing link stays byte-identical. + if (lookOffset !== undefined && (lookOffset[0] !== 0 || lookOffset[1] !== 0)) + fields.push(...lookOffset); + return fields.map(String).join(','); } diff --git a/tests/helpers/camera/makeCameraSimHarness.ts b/tests/helpers/camera/makeCameraSimHarness.ts index 3e9b2589a5..c2c61b47dd 100644 --- a/tests/helpers/camera/makeCameraSimHarness.ts +++ b/tests/helpers/camera/makeCameraSimHarness.ts @@ -11,7 +11,7 @@ import { configureStore } from '@reduxjs/toolkit'; import { rootReducer } from '../../../src/store/rootReducer'; -import { CAMERA_DRIVERS } from '../../../src/services/engine/camera/cameraDrivers'; +import { CONTROL_SCHEMES } from '../../../src/services/engine/camera/controlSchemes'; import { NEAR_CLIP_MPC, FAR_CLIP_MPC } from '../../../src/services/engine/camera/cameraFraming'; import { seedCameraRuntime } from '../../../src/services/engine/camera/seedCameraRuntime'; import { createInputAggregator } from '../../../src/services/engine/subsystems/inputAggregator'; @@ -110,7 +110,7 @@ export function makeCameraSimHarness(options: CameraSimHarnessOptions = {}) { device: {}, context: {}, timingService: {}, - drivers: CAMERA_DRIVERS, + controlSchemes: CONTROL_SCHEMES, } as unknown as RunFrameDeps; /** Commit `framed` to the store and re-seed the runtime from it. */ diff --git a/tests/helpers/camera/simulateCameraFrame.ts b/tests/helpers/camera/simulateCameraFrame.ts index 26ab69e40f..708794b447 100644 --- a/tests/helpers/camera/simulateCameraFrame.ts +++ b/tests/helpers/camera/simulateCameraFrame.ts @@ -39,7 +39,7 @@ export function simulateCameraFrame( bodies: deriveBodyStates(simDays) as ReadonlyMap, terrainHeightAt: () => 0, clipEpoch, - drivers: deps.drivers, + drivers: deps.controlSchemes.skymap.drivers, }); state.cameraRuntime = next; for (const action of actions) store.dispatch(action); diff --git a/tests/helpers/conventions/inlineTypeFiles.ts b/tests/helpers/conventions/inlineTypeFiles.ts index 027fc1230e..3f43f2dc0c 100644 --- a/tests/helpers/conventions/inlineTypeFiles.ts +++ b/tests/helpers/conventions/inlineTypeFiles.ts @@ -36,7 +36,6 @@ export const INLINE_TYPE_FILES: ReadonlySet = new Set([ 'src/services/animation/fadeController.ts', 'src/services/animation/scopedVisibilityActions.ts', 'src/services/animation/visibilityActionRow.ts', - 'src/services/camera/surfaceStep.ts', 'src/services/engine/animation/applyPathTuning.ts', 'src/services/engine/animation/buildDwellWarp.ts', 'src/services/engine/animation/buildPathTrack.ts', diff --git a/tests/services/engine/camera/cameraDrivers.test.ts b/tests/services/engine/camera/cameraDrivers.test.ts index b5e0ec6ee8..39eea22dbe 100644 --- a/tests/services/engine/camera/cameraDrivers.test.ts +++ b/tests/services/engine/camera/cameraDrivers.test.ts @@ -16,6 +16,7 @@ import type { CameraDriver } from '../../../../src/@types/engine/camera/CameraDr import type { CameraPose } from '../../../../src/@types/camera/CameraPose'; import type { CameraEpochs } from '../../../../src/@types/engine/camera/CameraEpochs'; import type { DriverId } from '../../../../src/@types/engine/camera/DriverId'; +import type { DriverActivity } from '../../../../src/@types/engine/camera/DriverActivity'; import type { EpochRow } from '../../../../src/@types/engine/camera/EpochRow'; import type { RootState } from '../../../../src/store/types'; import { @@ -63,6 +64,8 @@ function makeStore() { return configureStore({ reducer: rootReducer }); } +const APPROACHING: DriverActivity = { approachDone: false }; +const APPROACH_DONE: DriverActivity = { approachDone: true }; const BASE_POSE: CameraPose = { target: [0, 0, 0], yaw: 1.5, pitch: 0.1, distance: 100 }; const TWEEN_DESC: CameraTweenDescriptor = { @@ -108,7 +111,7 @@ function runAtWinner( nowMs: number, approachDone = false, ) { - const winner = pickWinner(drivers, s, approachDone); + const winner = pickWinner(drivers, s, { approachDone }); const ctx = makeDriverCtx({ state: s, elapsedMs: elapsedForWinner(winner, epochs, nowMs), @@ -295,20 +298,20 @@ describe('pickWinner', () => { const low = makeDriver('autoRotate', 20, true); const high = makeDriver('tween', 60, true); - expect(pickWinner([low, high], fakeState).id).toBe('tween'); - expect(pickWinner([high, low], fakeState).id).toBe('tween'); + expect(pickWinner([low, high], fakeState, APPROACHING).id).toBe('tween'); + expect(pickWinner([high, low], fakeState, APPROACHING).id).toBe('tween'); }); it('skips inactive drivers', () => { const inactive = makeDriver('orbitDrag', 80, false); const active = makeDriver('autoRotate', 20, true); - expect(pickWinner([inactive, active], fakeState).id).toBe('autoRotate'); + expect(pickWinner([inactive, active], fakeState, APPROACHING).id).toBe('autoRotate'); }); it('defensive: returns drivers[0] for an empty-ish all-inactive list', () => { const only = makeDriver('resting', 0, false); // All inactive → defensive fallback → drivers[0] - expect(pickWinner([only], fakeState)).toBe(only); + expect(pickWinner([only], fakeState, APPROACHING)).toBe(only); }); it('clip (95) beats orbitDrag (80) when both are active', () => { @@ -319,7 +322,7 @@ describe('pickWinner', () => { store.dispatch(clipStarted({ data: CLIP_DATA, frame: DEFAULT_ORIENTATION })); const s = store.getState() as unknown as RootState; const drivers = CAMERA_DRIVERS; - expect(pickWinner(drivers, s).id).toBe('clip'); + expect(pickWinner(drivers, s, APPROACHING).id).toBe('clip'); }); }); @@ -329,7 +332,7 @@ describe('pickWinner — precedence', () => { store.dispatch(startCameraTween(TWEEN_DESC)); store.dispatch(beginDrag()); const s = store.getState() as unknown as RootState; - expect(pickWinner(CAMERA_DRIVERS, s).id).toBe('orbitDrag'); + expect(pickWinner(CAMERA_DRIVERS, s, APPROACHING).id).toBe('orbitDrag'); }); }); @@ -355,6 +358,27 @@ describe("the winner's elapsed", () => { ); }); + it('tween eases lookOffset to zero', () => { + const store = makeStore(); + store.dispatch( + startCameraTween({ ...TWEEN_DESC, from: { ...TWEEN_DESC.from, lookOffset: [0.4, -0.2] } }), + ); + const s = store.getState() as unknown as RootState; + const epochs = epochsAt(s, 'tween', 0); + + // The offset keeps the same fraction the tween's own yaw has left to travel. + const midPose = worldArmOf(runAtWinner(CAMERA_DRIVERS, s, epochs, 500).pose); + const { from, to } = TWEEN_DESC; + const keep = (to.yaw - midPose.yaw) / (to.yaw - from.yaw); + expect(keep).toBeGreaterThan(0); + expect(keep).toBeLessThan(0.5); + expect(midPose.lookOffset![0]).toBeCloseTo(0.4 * keep, 12); + expect(midPose.lookOffset![1]).toBeCloseTo(-0.2 * keep, 12); + const end = worldArmOf(runAtWinner(CAMERA_DRIVERS, s, epochs, 1000).pose).lookOffset!; + expect(end[0]).toBeCloseTo(0, 12); + expect(end[1]).toBeCloseTo(0, 12); + }); + it('passes 0 elapsed to orbitDrag (pose does not use elapsed)', () => { const store = makeStore(); store.dispatch(beginDrag()); @@ -501,6 +525,24 @@ describe('CAMERA_DRIVERS — the follow rows', () => { expect(result.roll).toBeCloseTo(0.6, 12); }); + it('followApproach eases lookOffset to zero', () => { + // The committed base carries no offset, so the follow ease lands centre-looking. + const store = makeStore(); + store.dispatch(setSelectionRow({ slot: 'focus', row: EARTH_ROW })); + const s = store.getState() as unknown as RootState; + const produceFollow = makeFollowProduce({ + state: s, + from: { ...BASE_POSE, lookOffset: [0.4, -0.2] }, + }); + + // The elapsed at which easeOutCubic reaches 0.5. + const halfMs = FOCUS_TWEEN_MS * (1 - Math.cbrt(0.5)); + const half = worldArmOf(produceFollow(halfMs).pose).lookOffset!; + expect(half[0]).toBeCloseTo(0.2, 12); + expect(half[1]).toBeCloseTo(-0.1, 12); + expect(worldArmOf(produceFollow(FOCUS_TWEEN_MS).pose).lookOffset).toEqual([0, 0]); + }); + it('deactivates when focus leaves the body; pickWinner hands off to the next driver', () => { // autoRotate off so the resting floor is the fallback winner. const store = makeStore(); @@ -512,29 +554,41 @@ describe('CAMERA_DRIVERS — the follow rows', () => { // No focus → both inactive → resting wins. let s = store.getState() as unknown as RootState; - expect([approach.isActive(s), hold.isActive(s)]).toEqual([false, false]); - expect(pickWinner(drivers, s).id).toBe('resting'); + expect([approach.isActive(s, APPROACHING), hold.isActive(s, APPROACHING)]).toEqual([ + false, + false, + ]); + expect(pickWinner(drivers, s, APPROACHING).id).toBe('resting'); // A non-body focus (Milky Way) → still inactive. store.dispatch(setSelectionRow({ slot: 'focus', row: { type: 'milkyWay' } })); s = store.getState() as unknown as RootState; - expect([approach.isActive(s), hold.isActive(s)]).toEqual([false, false]); + expect([approach.isActive(s, APPROACHING), hold.isActive(s, APPROACHING)]).toEqual([ + false, + false, + ]); // A body focus present in the snapshot → active, and follow wins over // resting. A saturated approach is the ONLY thing that separates the two // rows: before it the approach authors, after it the hold does. store.dispatch(setSelectionRow({ slot: 'focus', row: EARTH_ROW })); s = store.getState() as unknown as RootState; - expect([approach.isActive(s, false), hold.isActive(s, false)]).toEqual([true, true]); - expect(pickWinner(drivers, s, false).id).toBe('followApproach'); - expect(approach.isActive(s, true)).toBe(false); - expect(pickWinner(drivers, s, true).id).toBe('followHold'); + expect([approach.isActive(s, APPROACHING), hold.isActive(s, APPROACHING)]).toEqual([ + true, + true, + ]); + expect(pickWinner(drivers, s, APPROACHING).id).toBe('followApproach'); + expect(approach.isActive(s, APPROACH_DONE)).toBe(false); + expect(pickWinner(drivers, s, APPROACH_DONE).id).toBe('followHold'); // Focus leaves the body again → deactivates → hands back to resting. store.dispatch(setSelectionRow({ slot: 'focus', row: null })); s = store.getState() as unknown as RootState; - expect([approach.isActive(s), hold.isActive(s)]).toEqual([false, false]); - expect(pickWinner(drivers, s).id).toBe('resting'); + expect([approach.isActive(s, APPROACHING), hold.isActive(s, APPROACHING)]).toEqual([ + false, + false, + ]); + expect(pickWinner(drivers, s, APPROACHING).id).toBe('resting'); }); it('the follow approach ease converges from the captured pose to the framing offset', () => { @@ -676,9 +730,9 @@ describe('CAMERA_DRIVERS — follow priority under body focus', () => { // All three are active; once the approach has saturated the winner is // autoRotate (20) over followHold (10), before that the approach (55). - expect(drivers.find((d) => d.id === 'followHold')!.isActive(s)).toBe(true); - expect(pickWinner(drivers, s, true).id).toBe('autoRotate'); - expect(pickWinner(drivers, s, false).id).toBe('followApproach'); + expect(drivers.find((d) => d.id === 'followHold')!.isActive(s, APPROACHING)).toBe(true); + expect(pickWinner(drivers, s, APPROACH_DONE).id).toBe('autoRotate'); + expect(pickWinner(drivers, s, APPROACHING).id).toBe('followApproach'); }); it('yaw advances over frames while auto-rotating a focused body', () => { diff --git a/tests/services/engine/camera/commitOnEdge.test.ts b/tests/services/engine/camera/commitOnEdge.test.ts index fbb42c2870..9c202daad5 100644 --- a/tests/services/engine/camera/commitOnEdge.test.ts +++ b/tests/services/engine/camera/commitOnEdge.test.ts @@ -391,7 +391,7 @@ describe('commitOnEdge — clip deactivation', () => { // at pointerup. The gesture row serves both arms at 80; the clip's 95 // outranks it either way: a clip is not drag-interruptible. const { store, deps } = makeHarness(); - const drivers = deps.drivers; + const drivers = deps.controlSchemes.skymap.drivers; // The rows read `base.frame` only, so the pose value is irrelevant here. store.dispatch( @@ -406,7 +406,7 @@ describe('commitOnEdge — clip deactivation', () => { }), ); store.dispatch(beginDrag()); - expect(pickWinner(drivers, store.getState()).id).toBe('orbitDrag'); + expect(pickWinner(drivers, store.getState(), { approachDone: false }).id).toBe('orbitDrag'); store.dispatch( clipStarted({ @@ -414,6 +414,6 @@ describe('commitOnEdge — clip deactivation', () => { frame: DEFAULT_ORIENTATION, }), ); - expect(pickWinner(drivers, store.getState()).id).toBe('clip'); + expect(pickWinner(drivers, store.getState(), { approachDone: false }).id).toBe('clip'); }); }); diff --git a/tests/services/engine/camera/rungs/absoluteRung.test.ts b/tests/services/engine/camera/rungs/absoluteRung.test.ts new file mode 100644 index 0000000000..c3eb4067cb --- /dev/null +++ b/tests/services/engine/camera/rungs/absoluteRung.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; + +import { absoluteRung } from '../../../../../src/services/engine/camera/rungs/absoluteRung'; +import { deriveBodyStates } from '../../../../../src/services/engine/frame/deriveBodyStates'; +import { ORIENTATION_FRAMES } from '../../../../../src/data/orientation/orientationFrames'; +import { DEFAULT_ORIENTATION } from '../../../../../src/data/defaults'; +import { SCENE_EARTH } from '../../../../../src/data/bodies/sceneEarth'; +import { SCALE_UNITS } from '../../../../../src/data/scaleUnits'; +import { CONST_J2000 } from '../../../../../src/data/time/constJ2000'; +import { DEFAULT_CAMERA_TUNING as TUNING } from '../../../../../src/data/camera/cameraTuning'; +import { EMPTY_TILT_MEMORY } from '../../../../../src/data/camera/emptyTiltMemory'; +import { datumOnlyTerrainHeight } from '../../../../../src/utils/camera/datumOnlyTerrainHeight'; +import type { BodyId } from '../../../../../src/@types/data/body/BodyId'; +import type { BodyState } from '../../../../../src/@types/scene/BodyState'; +import type { FramedPose } from '../../../../../src/@types/camera/FramedPose'; +import type { RungCtx } from '../../../../../src/@types/camera/RungCtx'; + +const B = ORIENTATION_FRAMES[DEFAULT_ORIENTATION]; +const BODIES = deriveBodyStates(CONST_J2000) as ReadonlyMap; +const EARTH = BODIES.get('earth')!; +const CTX: RungCtx = { + bodies: BODIES, + poseBasis: B, + upBasis: B, + terrainHeightAt: datumOnlyTerrainHeight, + focusBodyId: null, + pivot: { radiusMpc: null, floorMpc: 0 }, + viewportPx: [1920, 1080], + fovYRad: 1, + tuning: TUNING, +}; + +// Inside Earth's tilt band with an off-target roll, so the zoom's roll ride has work to do. +const FRAMED: FramedPose<'absolute'> = { + frame: 'absolute', + pose: { + target: [EARTH.positionMpc[0]!, EARTH.positionMpc[1]!, EARTH.positionMpc[2]!], + yaw: 0.7, + pitch: 0.3, + distance: SCENE_EARTH.surface.datumRadiusM * (1 + TUNING.tiltFullHR * 2) * SCALE_UNITS.M_TO_MPC, + roll: 1.4, + }, +}; + +describe('absoluteRung', () => { + it('a zoom nudge is the wheel step of the same factor, roll ride included', () => { + const z = 0.3; + const stepped = absoluteRung.step( + null, + EMPTY_TILT_MEMORY, + FRAMED, + { kind: 'zoom', factor: Math.exp(z), duringGesture: false, cursorPx: null }, + CTX, + ).pose; + const nudged = absoluteRung.nudge(EMPTY_TILT_MEMORY, FRAMED, { zoom: z }, CTX).pose; + expect(stepped.roll).not.toBe(FRAMED.pose.roll); + expect(nudged).toEqual(stepped); + }); +}); diff --git a/tests/services/engine/frame/runFrame.test.ts b/tests/services/engine/frame/runFrame.test.ts index 46aa34f66f..b43d537fcf 100644 --- a/tests/services/engine/frame/runFrame.test.ts +++ b/tests/services/engine/frame/runFrame.test.ts @@ -86,6 +86,7 @@ vi.mock('../../../../src/services/engine/frame/deriveBodyStates', async (importO import { runFrame } from '../../../../src/services/engine/frame/runFrame'; import { renderFrame } from '../../../../src/services/engine/frame/renderFrame'; import { CAMERA_DRIVERS } from '../../../../src/services/engine/camera/cameraDrivers'; +import { CONTROL_SCHEMES } from '../../../../src/services/engine/camera/controlSchemes'; import { reevaluateDemand } from '../../../../src/services/engine/wiring/reevaluateDemand'; import { deriveSourceMasks } from '../../../../src/services/engine/frame/deriveSourceMasks'; import { createDisabledGpuTimingService } from '../../../../src/services/gpu/timing/gpuTimingService'; @@ -274,7 +275,7 @@ function makeState(): EngineState { /** * Build a `RunFrameDeps` of no-op stubs. Every dep is inert because the * renderer-null bail-out inside `runFrame` short-circuits before any of - * them are touched; the camera-driver fixtures override `drivers` + `canvas` + * them are touched; the camera-driver fixtures override `controlSchemes` + `canvas` * + `cb.store` via `makeCamDeps`. */ function makeDeps(store = makeStore()): RunFrameDeps { @@ -290,7 +291,7 @@ function makeDeps(store = makeStore()): RunFrameDeps { context: {} as unknown as GPUCanvasContext, // Disabled stub matches production's "no `?gpuTimings`" path. timingService: createDisabledGpuTimingService(), - drivers: CAMERA_DRIVERS, + controlSchemes: CONTROL_SCHEMES, }; } @@ -327,7 +328,7 @@ function makeCamDeps(state: EngineState, store = makeStore()): RunFrameDeps { clientWidth: 100, clientHeight: 100, } as unknown as HTMLCanvasElement, - drivers: CAMERA_DRIVERS, + controlSchemes: CONTROL_SCHEMES, }; } @@ -804,7 +805,10 @@ describe('runFrame — sim clock (Task 8)', () => { }; }, }; - const deps: RunFrameDeps = { ...makeCamDeps(state, store), drivers: [stub] }; + const deps: RunFrameDeps = { + ...makeCamDeps(state, store), + controlSchemes: { skymap: { drivers: [stub] } }, + }; runFrame(state, deps, NOW); @@ -1218,7 +1222,7 @@ describe('runFrame — effective intent', () => { return { pose: ctx.register, memory: mem }; }, }; - const deps = { ...h.deps, drivers: [probe, ...CAMERA_DRIVERS] }; + const deps = { ...h.deps, controlSchemes: { skymap: { drivers: [probe, ...CAMERA_DRIVERS] } } }; const before = h.store.getState(); runFrame(h.state, deps, 16); diff --git a/tests/state/ui/buildInitialUiState.test.ts b/tests/state/ui/buildInitialUiState.test.ts index 0bda0dcdfe..ed47a95b91 100644 --- a/tests/state/ui/buildInitialUiState.test.ts +++ b/tests/state/ui/buildInitialUiState.test.ts @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { describe, it, expect, beforeEach } from 'vitest'; import { buildInitialUiState } from '../../../src/state/ui/buildInitialUiState'; -import { SPLASH_STORAGE_KEY, CURRENT_SPLASH_VERSION } from '../../../src/state/ui/splashStorage'; +import { CURRENT_SPLASH_VERSION } from '../../../src/state/ui/splashStorage'; +import { SPLASH_SEEN_VERSION } from '../../../src/state/persistedValues'; describe('buildInitialUiState', () => { beforeEach(() => { @@ -16,13 +17,13 @@ describe('buildInitialUiState', () => { }); it('is false when seenVersion equals the current version', () => { - window.localStorage.setItem(SPLASH_STORAGE_KEY, String(CURRENT_SPLASH_VERSION)); + window.localStorage.setItem(SPLASH_SEEN_VERSION.key, String(CURRENT_SPLASH_VERSION)); const state = buildInitialUiState(); expect(state.splash.visible).toBe(false); }); it('is true when seenVersion is lower than the current version', () => { - window.localStorage.setItem(SPLASH_STORAGE_KEY, String(CURRENT_SPLASH_VERSION - 1)); + window.localStorage.setItem(SPLASH_SEEN_VERSION.key, String(CURRENT_SPLASH_VERSION - 1)); const state = buildInitialUiState(); expect(state.splash.visible).toBe(true); }); @@ -52,7 +53,7 @@ describe('buildInitialUiState', () => { describe('splash.dismissedVersion', () => { it('seeds to the stored seenVersion when present', () => { - window.localStorage.setItem(SPLASH_STORAGE_KEY, String(CURRENT_SPLASH_VERSION)); + window.localStorage.setItem(SPLASH_SEEN_VERSION.key, String(CURRENT_SPLASH_VERSION)); const state = buildInitialUiState(); expect(state.splash.dismissedVersion).toBe(CURRENT_SPLASH_VERSION); }); diff --git a/tests/state/ui/persistSplashVersion.test.ts b/tests/state/ui/persistSplashVersion.test.ts deleted file mode 100644 index 51bbff41a2..0000000000 --- a/tests/state/ui/persistSplashVersion.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// @vitest-environment jsdom -/** - * persistSplashVersion — tests for the localStorage persistence effect. - * - * Uses a real store (createAppStore) to confirm end-to-end behaviour: - * dispatch flows through the reducer, subscriber fires, and the effect - * either writes storage or correctly refrains from doing so. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; - -import { createAppStore } from '../../../src/store/createAppStore'; -import { INITIAL_SETTINGS } from '../../../src/state/settings/initialSettings'; -import { dismissSplash, reopenSplash } from '../../../src/state/ui/uiSlice'; -import { persistSplashVersion } from '../../../src/state/ui/persistSplashVersion'; -import { SPLASH_STORAGE_KEY } from '../../../src/state/ui/splashStorage'; -import type { UiState } from '../../../src/@types/ui/UiState'; - -const settings = INITIAL_SETTINGS; - -beforeEach(() => { - window.localStorage.clear(); -}); - -describe('persistSplashVersion', () => { - it('dispatching dismissSplash(2) writes seenVersion to localStorage', () => { - const { store } = createAppStore({ settings }); - persistSplashVersion(store); - - store.dispatch(dismissSplash(2)); - - expect(window.localStorage.getItem(SPLASH_STORAGE_KEY)).toBe('2'); - }); - - it('reopenSplash does not write seenVersion', () => { - // Seed with a dismissed state (visible:false, dismissedVersion:2) so that - // reopenSplash flips visible false→true — a real state change that fires - // the subscriber — but leaves dismissedVersion at 2. The no-write should - // be due to the dismissedVersion diff guard, not a no-op dispatch. - const preloadedUi: UiState = { - paletteOpen: false, - uiHidden: false, - debugPanelOpen: false, - paletteTab: 'highlights', - splash: { visible: false, dismissedVersion: 2 }, - }; - const { store } = createAppStore({ settings, ui: preloadedUi }); - persistSplashVersion(store); - - // localStorage is clear; confirm the subscriber does not write on reopenSplash. - store.dispatch(reopenSplash()); - - expect(window.localStorage.getItem(SPLASH_STORAGE_KEY)).toBeNull(); - }); - - it('the returned unsubscribe stops further writes', () => { - const { store } = createAppStore({ settings }); - const stop = persistSplashVersion(store); - - stop(); - store.dispatch(dismissSplash(3)); - - expect(window.localStorage.getItem(SPLASH_STORAGE_KEY)).toBeNull(); - }); -}); diff --git a/tests/state/ui/splashStorage.test.ts b/tests/state/ui/splashStorage.test.ts deleted file mode 100644 index 749b1fb75b..0000000000 --- a/tests/state/ui/splashStorage.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, beforeEach } from 'vitest'; -import { - SPLASH_STORAGE_KEY, - CURRENT_SPLASH_VERSION, - readSeenVersion, - writeSeenVersion, -} from '../../../src/state/ui/splashStorage'; - -describe('splashStorage', () => { - beforeEach(() => { - window.localStorage.clear(); - }); - - describe('readSeenVersion', () => { - it('returns null when the key is absent', () => { - expect(readSeenVersion()).toBeNull(); - }); - - it('returns the stored integer when the key is present', () => { - window.localStorage.setItem(SPLASH_STORAGE_KEY, '1'); - expect(readSeenVersion()).toBe(1); - }); - - it('returns null on a non-integer value', () => { - window.localStorage.setItem(SPLASH_STORAGE_KEY, 'notanumber'); - expect(readSeenVersion()).toBeNull(); - }); - }); - - describe('writeSeenVersion', () => { - it('writes String(version) to the storage key', () => { - writeSeenVersion(2); - expect(window.localStorage.getItem(SPLASH_STORAGE_KEY)).toBe('2'); - }); - }); -}); diff --git a/tests/utils/camera/nudgedSitePose.test.ts b/tests/utils/camera/nudgedSitePose.test.ts new file mode 100644 index 0000000000..195bc3e599 --- /dev/null +++ b/tests/utils/camera/nudgedSitePose.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; + +import { nudgedSitePose } from '../../../src/utils/camera/nudgedSitePose'; +import { steppedSitePose } from '../../../src/utils/camera/steppedSitePose'; +import type { BodyId } from '../../../src/@types/data/body/BodyId'; +import type { MeshBody } from '../../../src/@types/scene/MeshBody'; +import type { SitePose } from '../../../src/@types/camera/SitePose'; + +const ROVER: MeshBody = { + id: 'fixture-rover', + label: 'Fixture rover', + boundingRadiusM: 2.479, + albedo: [1, 1, 1], + meshKey: 'fixture', + standoffRadii: 2, +}; +const VIEWPORT = [800, 600] as const; +const FOV = 0.8; +const POSE: SitePose = { + siteId: 'fixture-rover' as BodyId, + headingRad: 0, + elevationRad: 0.4, + rangeM: 50, +}; + +describe('nudgedSitePose', () => { + it('site nudge ignores look and roll by reference', () => { + expect(nudgedSitePose(POSE, { look: [0.1, 0.2], roll: 0.3 }, ROVER, VIEWPORT, FOV)).toBe(POSE); + }); + + it('site nudge orbit equals the equivalent drag', () => { + const [dx, dy] = [12, -7]; + const dragged = steppedSitePose( + POSE, + { kind: 'drag', mode: 'orbit', startPx: [100, 100], endPx: [100 + dx, 100 + dy] }, + ROVER, + VIEWPORT, + FOV, + ); + const k = FOV / VIEWPORT[1]; + const nudged = nudgedSitePose(POSE, { orbit: [dx * k, dy * k] }, ROVER, VIEWPORT, FOV); + expect(Math.abs(nudged.headingRad - dragged.headingRad)).toBeLessThan(1e-12); + expect(Math.abs(nudged.elevationRad - dragged.elevationRad)).toBeLessThan(1e-12); + expect(nudged.rangeM).toBe(dragged.rangeM); + }); +}); diff --git a/tests/utils/camera/nudgedSurfacePose.test.ts b/tests/utils/camera/nudgedSurfacePose.test.ts new file mode 100644 index 0000000000..da691241da --- /dev/null +++ b/tests/utils/camera/nudgedSurfacePose.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; + +import { nudgedSurfacePose } from '../../../src/utils/camera/nudgedSurfacePose'; +import { surfaceStep } from '../../../src/services/camera/surfaceStep'; +import { canonicalBasisAt } from '../../../src/utils/camera/canonicalBasisAt'; +import { imagePlaneBasis } from '../../../src/utils/camera/imagePlaneBasis'; +import { eyeFrameOf } from '../../../src/utils/camera/eyeFrameOf'; +import { SURFACE_STANDOFF_RADII } from '../../../src/utils/camera/clampDistance'; +import { DEFAULT_CAMERA_TUNING as TUNING } from '../../../src/data/camera/cameraTuning'; +import { EMPTY_TILT_MEMORY } from '../../../src/data/camera/emptyTiltMemory'; +import type { BodyFixedPose } from '../../../src/@types/camera/BodyFixedPose'; +import type { SurfaceStepCtx } from '../../../src/@types/camera/SurfaceStepCtx'; +import type { Mat3 } from '../../../src/@types/math/Mat3'; +import type { Vec2 } from '../../../src/@types/math/Vec2'; +import type { Vec3 } from '../../../src/@types/math/Vec3'; + +const R = 1; +const VIEWPORT: Vec2 = [100, 100]; +const FOV = Math.PI / 2; +const POLE: Vec3 = [0, 0, 1]; +const CTX: SurfaceStepCtx = { + viewportPx: VIEWPORT, + fovYRad: FOV, + bodyRadiusM: R, + standoffRadii: SURFACE_STANDOFF_RADII, + groundRadiusAtM: () => R, + innerBoundRadiusM: R * (1 - 1e-6), + outerBoundRadiusM: R * (1 + 1e-6), + sceneUpLocal: POLE, + focusPivotM: null, + tuning: TUNING, +}; + +/** Mid-latitude, oblique and headed off north, so the level settle has work to do. */ +const seed: BodyFixedPose = { + bodyId: 'earth', + anchorLocalM: [0, 0, 0], + eyeRelAnchorM: [0.6, -1.2, 1.4], + basisLocal: [1, 0, 0, 0, 1, 0, 0, 0, 1], +}; +const POSE: BodyFixedPose = { + ...seed, + basisLocal: canonicalBasisAt(eyeFrameOf(seed, 1, POLE)!, 0.7, 0.5), +}; + +const col = (m: Mat3, i: number): Vec3 => [m[3 * i]!, m[3 * i + 1]!, m[3 * i + 2]!]; +const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + +describe('nudgedSurfacePose', () => { + it('body nudge orbit equals the equivalent orbit-mode drag', () => { + const [dx, dy] = [10, -6]; + const dragged = surfaceStep( + { gesture: { mode: 'orbit', anchorRadiusM: R, anchorLocalM: null, prevPixel: [50, 50] } }, + EMPTY_TILT_MEMORY, + POSE, + { kind: 'drag', mode: 'orbit', startPx: [50, 50], endPx: [50 + dx, 50 + dy] }, + CTX, + ); + const k = FOV / VIEWPORT[1]; + const nudged = nudgedSurfacePose(POSE, EMPTY_TILT_MEMORY, { orbit: [dx * k, dy * k] }, CTX); + for (let i = 0; i < 3; i++) { + expect(Math.abs(nudged.pose.eyeRelAnchorM[i]! - dragged.pose.eyeRelAnchorM[i]!)).toBeLessThan( + 1e-9, + ); + } + for (let i = 0; i < 9; i++) { + expect(Math.abs(nudged.pose.basisLocal[i]! - dragged.pose.basisLocal[i]!)).toBeLessThan(1e-9); + } + }); + + it('body nudge roll keeps basisLocal orthonormal and survives the settle', () => { + const { pose } = nudgedSurfacePose(POSE, EMPTY_TILT_MEMORY, { roll: 0.2 }, CTX); + const b = pose.basisLocal; + for (let i = 0; i < 3; i++) { + for (let j = 0; j < 3; j++) { + expect(Math.abs(dot(col(b, i), col(b, j)) - (i === j ? 1 : 0))).toBeLessThan(1e-12); + } + } + // The world arm's roll convention, so a cross-arm sign slip fails here. + const expected = imagePlaneBasis(col(POSE.basisLocal, 2), 0.2, col(POSE.basisLocal, 1)).up; + for (let i = 0; i < 3; i++) expect(Math.abs(col(b, 1)[i]! - expected[i]!)).toBeLessThan(1e-9); + }); + + it('body nudge look+roll still writes tilt memory', () => { + // Inside the tilt band (h/R 0.03 < tiltFullHR), where a look authors tilt. + const [x, y, z] = POSE.eyeRelAnchorM; + const k = 1.03 / Math.hypot(x, y, z); + const low: BodyFixedPose = { ...POSE, eyeRelAnchorM: [x * k, y * k, z * k] }; + const looked = nudgedSurfacePose(low, EMPTY_TILT_MEMORY, { look: [0, 0.1] }, CTX); + const rolled = nudgedSurfacePose(low, EMPTY_TILT_MEMORY, { look: [0, 0.1], roll: 0.2 }, CTX); + expect(looked.tilt.rememberedTiltRad).not.toBe(EMPTY_TILT_MEMORY.rememberedTiltRad); + expect(rolled.tilt.rememberedTiltRad).toBe(looked.tilt.rememberedTiltRad); + }); + + it('an empty delta returns the pose by reference', () => { + expect(nudgedSurfacePose(POSE, EMPTY_TILT_MEMORY, {}, CTX).pose).toBe(POSE); + }); +}); diff --git a/tests/utils/camera/nudgedWorldPose.test.ts b/tests/utils/camera/nudgedWorldPose.test.ts new file mode 100644 index 0000000000..bb5b88acfb --- /dev/null +++ b/tests/utils/camera/nudgedWorldPose.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; + +import { nudgedWorldPose } from '../../../src/utils/camera/nudgedWorldPose'; +import { applyInputToCamera } from '../../../src/services/camera/applyInputToCamera'; +import { PITCH_LIMIT } from '../../../src/data/camera/pitchLimit'; +import type { CameraPose } from '../../../src/@types/camera/CameraPose'; +import type { PivotFraming } from '../../../src/@types/camera/PivotFraming'; +import type { Mat3 } from '../../../src/@types/math/Mat3'; + +const I: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1]; +const CSS_HEIGHT = 800; +const FOV = 0.8; +// A surfaced pivot, so the orbit's altitude damping is live in the comparison. +const PIVOT: PivotFraming = { radiusMpc: 1, floorMpc: 1.01 }; +const POSE: CameraPose = { target: [0, 0, 0], yaw: 0.4, pitch: 0.3, distance: 1.5 }; + +const nudge = (pose: CameraPose, delta: Parameters[1]) => + nudgedWorldPose(pose, delta, CSS_HEIGHT, PIVOT, FOV, I, I); + +describe('nudgedWorldPose', () => { + it('world nudge orbit equals the equivalent pixel drag', () => { + const [a, b] = [0.07, -0.05]; + const k = CSS_HEIGHT / FOV; + const dragged = applyInputToCamera( + POSE, + { kind: 'drag', mode: 'orbit', startPx: [0, 0], endPx: [a * k, b * k] }, + CSS_HEIGHT, + PIVOT, + FOV, + I, + I, + ); + const nudged = nudge(POSE, { orbit: [a, b] }); + expect(nudged.yaw).toBeCloseTo(dragged.yaw, 12); + expect(nudged.pitch).toBeCloseTo(dragged.pitch, 12); + expect(nudged.distance).toBe(dragged.distance); + }); + + it('world nudge look accumulates lookOffset and clamps pitch', () => { + const once = nudge(POSE, { look: [0.1, 0.2] }); + expect(once.lookOffset).toEqual([0.1, 0.2]); + const twice = nudge(once, { look: [0.2, 3] }); + expect(twice.lookOffset![0]).toBeCloseTo(0.3, 15); + expect(twice.lookOffset![1]).toBe(PITCH_LIMIT); + expect(twice.yaw).toBe(POSE.yaw); + }); + + it('an empty delta returns the pose by reference', () => { + expect(nudge(POSE, {})).toBe(POSE); + }); +}); diff --git a/tests/utils/camera/orbitForwardOf.test.ts b/tests/utils/camera/orbitForwardOf.test.ts new file mode 100644 index 0000000000..1aa90df598 --- /dev/null +++ b/tests/utils/camera/orbitForwardOf.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import type { OrbitCameraInit } from '../../../src/@types/camera/OrbitCameraInit'; +import type { Vec2 } from '../../../src/@types/math/Vec2'; +import type { Vec3 } from '../../../src/@types/math/Vec3'; +import { createOrbitCamera } from '../../../src/utils/camera/createOrbitCamera'; +import { computeViewProj } from '../../../src/utils/camera/computeViewProj'; +import { frameUp } from '../../../src/utils/camera/frameUp'; +import { orbitForwardOf } from '../../../src/utils/camera/orbitForwardOf'; +import { symmetricFrustum } from '../../../src/utils/camera/symmetricFrustum'; +import { ORIENTATION_FRAMES } from '../../../src/data/orientation/orientationFrames'; + +// A non-identity frame, so a turn about world +Y instead of the frame up fails. +const BASIS = ORIENTATION_FRAMES.ecliptic; +const INIT: OrbitCameraInit = { + target: [3, -2, 7], + distance: 10, + yaw: 0.8, + // Level in the frame, so forward ⟂ frame up and a yaw offset turns it by exactly its angle. + pitch: 0, + roll: 0.25, + poseBasis: BASIS, + upBasis: BASIS, + fovYRad: Math.PI / 4, + aspect: 1.5, + near: 0.1, + far: 100, +}; + +function camWith(lookOffset?: Vec2) { + return createOrbitCamera({ ...INIT, ...(lookOffset && { lookOffset }) }); +} + +function angleBetween(a: Vec3, b: Vec3): number { + const dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + return Math.acos(Math.min(1, Math.max(-1, dot))); +} + +describe('orbitForwardOf', () => { + it.each([ + [[0.3, 0] as Vec2, 0.3], + [[0, 0.2] as Vec2, 0.2], + ])('orbitForwardOf keeps the eye and turns forward by lookOffset %j', (offset, angle) => { + const bare = camWith(); + const turned = camWith(offset); + expect(turned.position).toEqual(bare.position); + expect(angleBetween(orbitForwardOf(turned), orbitForwardOf(bare))).toBeCloseTo(angle, 12); + }); + + it('a positive pitch offset tilts the view toward the frame up', () => { + const up = frameUp(BASIS); + const f = orbitForwardOf(camWith([0, 0.2])); + expect(f[0] * up[0] + f[1] * up[1] + f[2] * up[2]).toBeCloseTo(Math.sin(0.2), 12); + }); + + it('orbitForwardOf with lookOffset [0,0] is bitwise identical to absent', () => { + const bare = camWith(); + const zero = camWith([0, 0]); + expect(new Float64Array(orbitForwardOf(zero))).toEqual(new Float64Array(orbitForwardOf(bare))); + const frustum = symmetricFrustum(INIT.fovYRad, INIT.aspect); + expect(computeViewProj(zero, frustum)).toEqual(computeViewProj(bare, frustum)); + }); +}); diff --git a/tests/utils/storage/persistValues.test.ts b/tests/utils/storage/persistValues.test.ts new file mode 100644 index 0000000000..83e4721c3a --- /dev/null +++ b/tests/utils/storage/persistValues.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { createAppStore } from '../../../src/store/createAppStore'; +import { INITIAL_SETTINGS } from '../../../src/state/settings/initialSettings'; +import { dismissSplash, reopenSplash, toggleUiHidden } from '../../../src/state/ui/uiSlice'; +import { persistValues } from '../../../src/utils/storage/persistValues'; +import { SPLASH_SEEN_VERSION } from '../../../src/state/persistedValues'; +import type { UiState } from '../../../src/@types/ui/UiState'; + +const settings = INITIAL_SETTINGS; +const dismissedUi: UiState = { + paletteOpen: false, + uiHidden: false, + debugPanelOpen: false, + paletteTab: 'highlights', + splash: { visible: false, dismissedVersion: 2 }, +}; + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('persistValues', () => { + it('writes only on change and not at install', () => { + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + const { store } = createAppStore({ settings, ui: dismissedUi }); + persistValues(store, [SPLASH_SEEN_VERSION]); + + store.dispatch(toggleUiHidden()); + expect(setItem).not.toHaveBeenCalled(); + + store.dispatch(dismissSplash(3)); + expect(setItem).toHaveBeenCalledTimes(1); + expect(window.localStorage.getItem(SPLASH_SEEN_VERSION.key)).toBe('3'); + }); + + it('swallows a throwing setItem', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + const { store } = createAppStore({ settings }); + persistValues(store, [SPLASH_SEEN_VERSION]); + + expect(() => store.dispatch(dismissSplash(2))).not.toThrow(); + }); + + it('the returned unsubscribe stops further writes', () => { + const { store } = createAppStore({ settings }); + const stop = persistValues(store, [SPLASH_SEEN_VERSION]); + + stop(); + store.dispatch(dismissSplash(3)); + + expect(window.localStorage.getItem(SPLASH_SEEN_VERSION.key)).toBeNull(); + }); +}); + +describe('SPLASH_SEEN_VERSION row', () => { + it('reopenSplash does not write (dismissedVersion unchanged)', () => { + const { store } = createAppStore({ settings, ui: dismissedUi }); + persistValues(store, [SPLASH_SEEN_VERSION]); + + store.dispatch(reopenSplash()); + + expect(window.localStorage.getItem(SPLASH_SEEN_VERSION.key)).toBeNull(); + }); +}); diff --git a/tests/utils/storage/readPersisted.test.ts b/tests/utils/storage/readPersisted.test.ts new file mode 100644 index 0000000000..b6c498f458 --- /dev/null +++ b/tests/utils/storage/readPersisted.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { readPersisted } from '../../../src/utils/storage/readPersisted'; +import { SPLASH_SEEN_VERSION } from '../../../src/state/persistedValues'; + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('readPersisted', () => { + it('returns the parsed stored value', () => { + window.localStorage.setItem(SPLASH_SEEN_VERSION.key, '1'); + expect(readPersisted(SPLASH_SEEN_VERSION)).toBe(1); + }); + + it('returns null when the key is absent', () => { + expect(readPersisted(SPLASH_SEEN_VERSION)).toBeNull(); + }); + + it('returns null for an unparsable value', () => { + window.localStorage.setItem(SPLASH_SEEN_VERSION.key, 'notanumber'); + expect(readPersisted(SPLASH_SEEN_VERSION)).toBeNull(); + }); + + it('returns null when storage throws', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('SecurityError'); + }); + expect(readPersisted(SPLASH_SEEN_VERSION)).toBeNull(); + }); +}); diff --git a/tests/utils/url/decodeFramedPose.test.ts b/tests/utils/url/decodeFramedPose.test.ts index 48f056d04c..1d345d913a 100644 --- a/tests/utils/url/decodeFramedPose.test.ts +++ b/tests/utils/url/decodeFramedPose.test.ts @@ -4,6 +4,7 @@ import { decodeFramedPose } from '../../../src/utils/url/decodeFramedPose'; import { encodeFramedPose } from '../../../src/utils/url/encodeFramedPose'; import type { FramedCameraPose } from '../../../src/@types/camera/FramedCameraPose'; import type { BodyId } from '../../../src/@types/data/body/BodyId'; +import { worldArmOf } from '../../fixtures/worldArmOf'; // The Everest body arm from a real `l`-key dump (earth-everest.json). const EVEREST_ARM: FramedCameraPose = { @@ -43,6 +44,16 @@ describe('decodeFramedPose', () => { expect(decodeFramedPose(encodeFramedPose(WORLD_ARM))).toEqual(WORLD_ARM); }); + it('framed pose round-trips a non-zero lookOffset through the hash', () => { + const offset: FramedCameraPose = { + frame: 'absolute', + pose: { ...worldArmOf(WORLD_ARM), lookOffset: [0.3, -0.125] }, + }; + const got = worldArmOf(decodeFramedPose(encodeFramedPose(offset))!).lookOffset!; + expect(got[0]).toBeCloseTo(0.3, 12); + expect(got[1]).toBeCloseTo(-0.125, 12); + }); + it('rejects an unrecognised tag', () => { expect(decodeFramedPose('x,1,2,3')).toBeNull(); }); diff --git a/tests/utils/url/encodeFramedPose.test.ts b/tests/utils/url/encodeFramedPose.test.ts index 23f834f26a..14cab0da42 100644 --- a/tests/utils/url/encodeFramedPose.test.ts +++ b/tests/utils/url/encodeFramedPose.test.ts @@ -41,4 +41,12 @@ describe('encodeFramedPose', () => { }; expect(encodeFramedPose(world)).toBe('a,0,0,0,0,0,1,0.42'); }); + + it('encodeFramedPose omits a zero lookOffset', () => { + const zero: FramedCameraPose = { + frame: 'absolute', + pose: { target: [1, 2, 3], yaw: 0.7, pitch: -0.2, distance: 5.5, lookOffset: [0, 0] }, + }; + expect(encodeFramedPose(zero)).toBe('a,1,2,3,0.7,-0.2,5.5,0'); + }); });