From 992878802c79c63f95c32d0b998205608d3a1ee4 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sat, 20 Jun 2026 18:38:20 +0200 Subject: [PATCH] refactor(camera): fold the focus tween into watchFocusTween The focus camera-tween was computed through an engine-injected chain: watchFocusTween -> runFocusTween -> makeRunFocusTween -> FocusTweenTable -> tweenToGalaxy/tweenToStructure/tweenToCameraSnapshot, each dispatching startCameraTween plus an explicit requestRender. Collapse that into the saga itself. watchFocusTween now resolves the ref to a row (resolveDeps), reads the live from-pose + lens FOV via a new cameraRuntime() SagaContext reader, builds the startCameraTween payload with a pure focusTweenDescriptor table (galaxy/structure/milkyWay arms), and dispatches it. The milkyWay arm becomes a peer case in that one table instead of a special tweenToCameraSnapshot snapshot. The explicit requestRender calls are gone: startCameraTween is a camera/* write, so watchWake/WAKE_ROUTES wakes the loop by construction (and the focus ref write also wakes via watchSelectionWake). Deletes makeRunFocusTween, tweenToGalaxy, tweenToStructure, the FocusTweenTable wiring, the orphaned TweenTarget type, and runFocusTween from SagaContext. tweenToCameraSnapshot stays for the home button, minus its redundant requestRender. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/@types/camera/TweenTarget.d.ts | 27 --- .../engine/handles/EngineCameraHandle.d.ts | 4 +- src/services/engine/camera/cameraSnapshot.ts | 60 +++--- .../engine/camera/focusTweenDuration.ts | 4 +- .../engine/camera/makeRunFocusTween.ts | 35 ---- src/services/engine/camera/tweenToGalaxy.ts | 131 ------------- .../engine/camera/tweenToStructure.ts | 67 ------- src/services/engine/engine.ts | 53 ++---- src/state/camera/focusTweenDescriptor.ts | 80 ++++++++ src/state/selection/focusTweenSaga.ts | 48 +++-- src/store/rootSaga.ts | 2 +- src/store/types.ts | 24 ++- .../engine/camera/makeRunFocusTween.test.ts | 76 -------- .../engine/camera/tweenToGalaxy.test.ts | 130 ------------- .../engine/camera/tweenToStructure.test.ts | 172 ------------------ .../state/camera/focusTweenDescriptor.test.ts | 98 ++++++++++ tests/state/selection/focusTweenSaga.test.ts | 53 +++++- tests/store/sagaContext.test.ts | 19 +- 18 files changed, 325 insertions(+), 758 deletions(-) delete mode 100644 src/@types/camera/TweenTarget.d.ts delete mode 100644 src/services/engine/camera/makeRunFocusTween.ts delete mode 100644 src/services/engine/camera/tweenToGalaxy.ts delete mode 100644 src/services/engine/camera/tweenToStructure.ts create mode 100644 src/state/camera/focusTweenDescriptor.ts delete mode 100644 tests/services/engine/camera/makeRunFocusTween.test.ts delete mode 100644 tests/services/engine/camera/tweenToGalaxy.test.ts delete mode 100644 tests/services/engine/camera/tweenToStructure.test.ts create mode 100644 tests/state/camera/focusTweenDescriptor.test.ts diff --git a/src/@types/camera/TweenTarget.d.ts b/src/@types/camera/TweenTarget.d.ts deleted file mode 100644 index 89c8dc284..000000000 --- a/src/@types/camera/TweenTarget.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * TweenTarget — the minimum-surface descriptor `tweenToGalaxy` actually - * consumes. - * - * The helper only reads four fields off the target: `x`, `y`, `z`, and - * `diameterKpc`. Declaring the parameter as `GalaxyInfo` would imply - * the helper might reach for ra/dec/redshift/etc., which it never does. - * The minimum-surface type doubles as documentation: "this is exactly - * the data the tween needs." Production callers pass a full - * `GalaxyInfo` and TypeScript accepts it via structural compatibility. - */ - -export type TweenTarget = { - /** World-space X in Mpc. */ - x: number; - /** World-space Y in Mpc. */ - y: number; - /** World-space Z in Mpc. */ - z: number; - /** - * Physical galaxy diameter in kpc — drives the focus distance via - * `galaxyFocusDistance`. Callers that genuinely lack a diameter (none - * today) should pass the project-wide fallback explicitly rather - * than letting NaN through. - */ - diameterKpc: number; -}; diff --git a/src/@types/engine/handles/EngineCameraHandle.d.ts b/src/@types/engine/handles/EngineCameraHandle.d.ts index a46dac29f..9b7659e63 100644 --- a/src/@types/engine/handles/EngineCameraHandle.d.ts +++ b/src/@types/engine/handles/EngineCameraHandle.d.ts @@ -2,8 +2,8 @@ * EngineCameraHandle — viewpoint tweens and dev helpers. * * Camera focus is fully driven by the Redux `selection` slice: the - * focus-tween saga watches `state.selection.focus` and calls - * `runFocusTween` when it changes. The only imperative camera op + * focus-tween saga watches `state.selection.focus` and builds + dispatches + * the camera tween when it changes. The only imperative camera op * still on the handle is `focusOnHome` (which dispatches a focus-null * write and tweens the camera to the framing snapshot) and the dev * `logState` helper. diff --git a/src/services/engine/camera/cameraSnapshot.ts b/src/services/engine/camera/cameraSnapshot.ts index f234f53fe..6b385991c 100644 --- a/src/services/engine/camera/cameraSnapshot.ts +++ b/src/services/engine/camera/cameraSnapshot.ts @@ -4,38 +4,27 @@ * ### Why a helper * * `focusOnHome` on `EngineHandle` targets `state.initialCamSnapshot` and reads - * the same four orbit fields off it (`target.{x,y,z}`, `distance`, `yaw`, - * `pitch`). Pre-extraction it built a full tween literal inline and handed it - * to `tweens.start(...)`. Pulling that scaffolding here collapses the call site - * to one line, and a future home-targeting method plugs in without re-spelling - * the tween literal. + * the four orbit fields off it (`target.{x,y,z}`, `distance`, `yaw`, `pitch`). + * Collapsing that into one call keeps the handle method a one-liner, and a + * future home-targeting method plugs in without re-spelling the tween literal. * * ### Why the `state` parameter, not `(cam, snapshot)` * - * The helper absorbs the cam-null guard itself — same pattern `tweenToGalaxy` - * established for the focus-commit family. Pulling `state` in is the cost of - * moving that guard out of the call site; the win is that callers who add a - * third home-targeting method later inherit the safe behaviour for free. - * - * ### Why `InitialCam` rather than a narrowed structural type - * - * `tweenToGalaxy` declares its own minimum-surface `TweenTarget` type because - * its only callers (`GalaxyInfo`-bearing) are structurally compatible with a - * much narrower shape. Here, every caller already holds an `InitialCam` (built - * once during bootstrap, stored on `state.initialCamSnapshot`); narrowing - * wouldn't document anything the type doesn't already. Use the existing domain - * type. + * The helper absorbs the cam-null guard itself, so callers do not repeat it. + * Pulling `state` in is the cost of moving that guard out of the call site. * * ### Why `from` reads `cameraRuntime.lastPose.current` * - * Same reason as `tweenToGalaxy`: `lastPose.current` is the pose the user - * actually sees, while `state.cam` (the drag register) may be stale. + * `lastPose.current` is the pose the user actually sees (produced by the driver + * table each frame), while `state.cam` (the drag register) may be stale; seeding + * `from` from the live pose lets an in-flight tween hand off smoothly. * - * ### Why `requestRender` after dispatch + * ### Why no requestRender * - * A direct, synchronous wake for the first tween frame. The `camera/*` write - * also wakes the loop via the `watchWake` saga; the explicit call does not - * depend on saga ordering — same rationale as `tweenToGalaxy`. + * The `startCameraTween` dispatch is a `camera/*` write, and `watchWake`/ + * WAKE_ROUTES turns every such write into a render request — so the wake is + * automatic. (The lone caller, `focusOnHome`, also dispatches a selection write, + * which wakes via `watchSelectionWake` too.) */ import type { EngineState } from '../../../@types/engine/state/EngineState'; @@ -46,26 +35,25 @@ import { startCameraTween } from '../../../state/camera/cameraSlice'; /** * Tween the live camera toward `snapshot` over the project-wide `FOCUS_TWEEN_MS` - * duration — same easing curve, same advance loop as `tweenToGalaxy`. Used by - * `focusOnHome` to return to the bootstrap-derived framing without the visual - * abruptness of a snap. + * duration — same easing curve as the focus tweens. Used by `focusOnHome` to + * return to the bootstrap-derived framing without the visual abruptness of a + * snap. * - * The from-snapshot is taken at call time from `state.cameraRuntime.lastPose` + * The `from` pose is taken at call time from `state.cameraRuntime.lastPose` * (the live produced pose) so an in-flight tween hands off smoothly: the new * tween's `from` captures the camera's current visible position mid-animation. * - * No-op when `state.cam` is null — same cam-null window as `tweenToGalaxy`. - * Callers do NOT need to fire any URL-hash side-effect here; clearing - * `#focus=…` is a separate concern owned by the call site that decides 'this - * action is leaving a focus state'. + * No-op when `state.cam` is null (pre-bootstrap / post-destroy). Callers do NOT + * need to fire any URL-hash side-effect here; clearing `#focus=…` is a separate + * concern owned by the call site that decides 'this action is leaving a focus + * state'. */ export function tweenToCameraSnapshot( state: EngineState, snapshot: InitialCam, store: AppStore, ): void { - const cam = state.cam; - if (!cam) return; + if (!state.cam) return; // Read the live produced pose as the tween's `from`. `lastPose.current` is // the pose the user actually sees (produced by the driver table each frame); @@ -86,8 +74,4 @@ export function tweenToCameraSnapshot( easing: 'easeOutCubic', }), ); - - // Direct wake for the first tween frame — see the module header. The - // `camera/*` dispatch also wakes the loop via `watchWake`. - state.subsystems.scheduler.requestRender(); } diff --git a/src/services/engine/camera/focusTweenDuration.ts b/src/services/engine/camera/focusTweenDuration.ts index e43fcf021..eedb246d0 100644 --- a/src/services/engine/camera/focusTweenDuration.ts +++ b/src/services/engine/camera/focusTweenDuration.ts @@ -1,7 +1,7 @@ /** * Tween duration for focus / home camera moves, in milliseconds. - * Shared by `tweenToGalaxy` and `tweenToStructure` so both kinds of focus - * commitment animate at the same speed. + * Shared by every focus tween (galaxy, structure, Milky Way) and the home + * snapshot so all camera commitments animate at the same speed. * * 600 ms is the sweet spot the UI explored: long enough that the user * reads it as motion (not a teleport) and gets oriented in the new diff --git a/src/services/engine/camera/makeRunFocusTween.ts b/src/services/engine/camera/makeRunFocusTween.ts deleted file mode 100644 index d9caa3ddb..000000000 --- a/src/services/engine/camera/makeRunFocusTween.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * makeRunFocusTween — the engine-side camera-tween runner the watchFocusTween - * saga calls through SagaContext (symmetric with makeRunTierTransition). Given a - * focus SelectionRef it re-resolves the row via the live `resolveDeps` (so it - * never depends on the reconciler having run first), then dispatches by tag to - * an injected `tweens` table. The table is injected — not closed over here — so - * this stays pure and hermetic; the engine builds the real GPU/cam table. - * - * A null ref (focus release) or a ref whose cloud is not loaded resolves to null - * → no tween. The tweens themselves are untouched; this relocates the tween - * trigger from the engine handle into a saga effect. - */ -import { extractSelectionRow } from '../helpers/extractSelectionRow'; -import type { SelectionRef } from '../../../@types/engine/SelectionRef'; -import type { SelectionRow } from '../../../@types/engine/SelectionRow'; -import type { ResolveDeps } from '../../../@types/engine/ResolveDeps'; - -export type FocusTweenTable = { - galaxyCatalog: (row: Extract) => void; - structure: (row: Extract) => void; - milkyWay: () => void; -}; - -export function makeRunFocusTween( - resolveDeps: () => ResolveDeps, - tweens: FocusTweenTable, -): (ref: SelectionRef | null) => void { - return (ref) => { - const row = extractSelectionRow(ref, resolveDeps()); - if (row === null) return; - if (row.type === 'galaxyCatalog') tweens.galaxyCatalog(row); - else if (row.type === 'structure') tweens.structure(row); - else tweens.milkyWay(); - }; -} diff --git a/src/services/engine/camera/tweenToGalaxy.ts b/src/services/engine/camera/tweenToGalaxy.ts deleted file mode 100644 index b54d6d358..000000000 --- a/src/services/engine/camera/tweenToGalaxy.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * tweenToGalaxy — kick off a focus camera tween toward a galaxy. - * - * ### Why a helper - * - * Three public-handle methods on `EngineHandle` — `focusOn`, `selectFamous`, - * and `selectByAlias` — each carried the same five-line block: - * - * ```ts - * store.dispatch(startCameraTween({ - * from: poseOf(cam), - * to: { target: [x, y, z], yaw: cam.yaw, pitch: cam.pitch, distance: ... }, - * durationMs: FOCUS_TWEEN_MS, - * easing: 'easeOutCubic', - * })); - * state.subsystems.scheduler.requestRender(); - * ``` - * - * Three near-identical bodies — exactly the kind of copy-paste that silently - * rots when one site adds a new field (e.g. an FOV transition) and the others - * lag behind. One helper, three call sites collapse to a single line each. - * - * ### Why we DON'T extend the responsibility - * - * Each call site has its own pre-tween bookkeeping that doesn't belong in the - * helper: - * - `selectFamous` / `selectByAlias` resolve a `GalaxyInfo` via - * `buildGalaxyInfo`, then call `setSelected` and `cb.onFocusChange` before - * tweening; - * - `focusOn` calls `cb.onFocusChange` first so the URL hash updates in - * lock-step with the user's commitment. - * - * Pulling that work into `tweenToGalaxy` would force the helper to know about - * callbacks, selection state, and source enums — turning a five-line dispatcher - * into a multi-purpose coordinator. The single responsibility we DO want is - * 'given a camera-able target, start the tween' — keep it tiny. - * - * ### Why the cam-null guard is here - * - * `state.cam` is typed `OrbitCamera | null` because two reachable windows - * leave it null: - * - **Pre-bootstrap**: `createOrbitCamera` runs inside `wireInput` during the - * bootstrap IIFE, after `initGpu` and the first cloud arrival. Code that - * fires before then (e.g. an unlikely `selectByAlias` from a deep-link - * drain that races the very first cloud upload) still finds `cam` null. - * - **Post-destroy**: `handle.destroy()` detaches controls and clears - * `state.cam = null`. An in-flight focus promise that resolves after - * destroy must not crash the engine on shutdown. - * - * The three call sites all check for null themselves today — the helper absorbs - * that check so future call sites get the safe behaviour for free. It is - * genuinely needed; do not remove on the grounds of YAGNI. - * - * ### Why `TweenTarget` is a structural minimum, not `GalaxyInfo` - * - * The helper only reads four fields off the target: `x`, `y`, `z`, and - * `diameterKpc`. Declaring the parameter as `GalaxyInfo` would imply the - * helper might reach for ra/dec/redshift/etc., which it never does. The - * minimum-surface type doubles as documentation: 'this is exactly the data the - * tween needs.' Production callers pass a full `GalaxyInfo` and TypeScript - * accepts it via structural compatibility. - * - * ### Why `from` reads `cameraRuntime.lastPose.current` - * - * At focus time the camera may be mid-tween (rapid re-click). In that case - * `state.cam` (the drag register) holds a stale pose from the last gesture, - * while `lastPose.current` holds the last PRODUCED pose — the one the user - * actually sees on screen. Seeding `from` from `lastPose.current` makes - * re-focus hand off smoothly from exactly the visible position; seeding from - * `state.cam` would produce a one-frame jump to the stale register value. - * At rest, `lastPose.current == base` and `poseOf(state.cam)` would also be - * equal, so both sources agree; `lastPose.current` is always safe. - * - * ### Why `requestRender` after `startCameraTween` - * - * `startCameraTween` reaches the store, and the `watchWake` saga wakes the loop - * on any `camera/*` write — so the dispatch alone would eventually wake it. The - * explicit `scheduler.requestRender()` here is a direct, synchronous wake that - * does not depend on saga-middleware ordering or on the wake saga being forked - * yet, guaranteeing the first tween frame fires immediately from any call site. - */ - -import type { EngineState } from '../../../@types/engine/state/EngineState'; -import type { TweenTarget } from '../../../@types/camera/TweenTarget'; -import type { AppStore } from '../../../store/types'; -import { FOCUS_TWEEN_MS } from './focusTweenDuration'; -import { galaxyFocusDistance } from './galaxyFocusDistance'; -import { startCameraTween } from '../../../state/camera/cameraSlice'; - -/** - * Start a focus tween toward `target`, snapshotting the current live camera - * pose as `from` so an in-flight tween hands off smoothly to the new one. - * - * Yaw and pitch are preserved — the user keeps their orientation; only the - * orbit target and distance change. The duration is the project-wide - * `FOCUS_TWEEN_MS`; the destination distance is derived from `target.diameterKpc` - * via `galaxyFocusDistance` (which clamps to a sensible minimum so dwarfs don't - * end up framing the camera inside the disk). - * - * No-op when `state.cam` is null — see the module header for the two windows - * where that happens. - */ -export function tweenToGalaxy(state: EngineState, target: TweenTarget, store: AppStore): void { - const cam = state.cam; - if (!cam) return; - - // Read the live produced pose as the tween's `from`. `lastPose.current` is - // the pose the user actually sees (produced by the driver table each frame); - // at rest it equals `poseOf(cam)`, but mid-tween it is the interpolated - // position rather than the stale drag register. - const from = state.cameraRuntime.lastPose.current; - - store.dispatch( - startCameraTween({ - from, - to: { - target: [target.x, target.y, target.z], - yaw: from.yaw, - pitch: from.pitch, - distance: galaxyFocusDistance(target.diameterKpc), - }, - durationMs: FOCUS_TWEEN_MS, - easing: 'easeOutCubic', - }), - ); - - // Direct wake for the first tween frame. The `camera/*` dispatch also wakes - // the loop via the `watchWake` saga, but this synchronous call does not - // depend on saga ordering — see the module header. - state.subsystems.scheduler.requestRender(); -} diff --git a/src/services/engine/camera/tweenToStructure.ts b/src/services/engine/camera/tweenToStructure.ts deleted file mode 100644 index 23b51e25b..000000000 --- a/src/services/engine/camera/tweenToStructure.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * tweenToStructure — kick off a focus camera tween toward a structure. - * - * Companion to `tweenToGalaxy`; differs only in where the target distance comes - * from (`structureFocusDistance` fed the structure's apparent radius rather than - * a galaxy's diameter). - * - * No-op when `state.cam` is null — matches `tweenToGalaxy`'s pre-bootstrap / - * post-destroy contract. The selection-side update + URL-callback fan-out happen - * in the caller (`commitStructureFocus`) unconditionally, so a cam-null commit - * still lands the selection even when this tween skips. - * - * `from` reads `state.cameraRuntime.lastPose.current` for the same reason as - * `tweenToGalaxy`: at rest it equals `poseOf(cam)`, but mid-tween it is the - * visible interpolated position. Seeding from the live produced pose avoids a - * jump when the user re-focuses rapidly during an animation. - * - * `requestRender` is called explicitly after the dispatch as a direct, - * synchronous wake. The `camera/*` write also wakes the loop via the - * `watchWake` saga; the explicit call does not depend on saga ordering — same - * rationale as `tweenToGalaxy`. - */ - -import type { EngineState } from '../../../@types/engine/state/EngineState'; -import type { StructureInfo } from '../../../@types/data/structure/StructureInfo'; -import type { AppStore } from '../../../store/types'; -import { FOCUS_TWEEN_MS } from './focusTweenDuration'; -import { structureFocusDistance } from './structureFocusDistance'; -import { startCameraTween } from '../../../state/camera/cameraSlice'; - -export function tweenToStructure( - state: EngineState, - structure: StructureInfo, - store: AppStore, -): void { - const cam = state.cam; - if (!cam) return; - - // Frame on the WIDER apparent extent — the radius the close-approach fade - // reads — so the framing lands the ring + label just past their fade-out. - // Falls back to the physical core for structures with no wider extent. - const radius = structure.apparentRadiusMpc ?? structure.physicalRadiusMpc; - - // Read the live produced pose as the tween's `from`. `lastPose.current` is - // the pose the user actually sees; at rest it equals `poseOf(cam)`, but - // mid-tween it is the interpolated position rather than the stale drag register. - const from = state.cameraRuntime.lastPose.current; - - store.dispatch( - startCameraTween({ - from, - to: { - target: [structure.worldPos[0], structure.worldPos[1], structure.worldPos[2]], - yaw: from.yaw, - pitch: from.pitch, - // fovY drives the screen-fill framing — same value the projection uses. - distance: structureFocusDistance(radius, state.cameraRuntime.projection.fovYRad), - }, - durationMs: FOCUS_TWEEN_MS, - easing: 'easeOutCubic', - }), - ); - - // Direct wake for the first tween frame — see the module header. The - // `camera/*` dispatch also wakes the loop via `watchWake`. - state.subsystems.scheduler.requestRender(); -} diff --git a/src/services/engine/engine.ts b/src/services/engine/engine.ts index 7ab8b70cf..a14fcb181 100644 --- a/src/services/engine/engine.ts +++ b/src/services/engine/engine.ts @@ -87,7 +87,6 @@ import { produceStructureLabels } from './presentation/produceStructureLabels'; import { produceFamousLabels } from './presentation/produceFamousLabels'; import { createStructureFocusSubsystem } from './subsystems/structureFocusSubsystem'; import { HDR_PASSES, UI_PASSES } from './frame/passes'; -import { buildGalaxyInfo } from './helpers/buildGalaxyInfo'; import { logCameraState } from './helpers/logCameraState'; import { updateSelectionFocus } from '../../state/selection/selectionSlice'; import type { AssetSlot } from '../../@types/loading/AssetSlot'; @@ -105,13 +104,6 @@ import { listVolumeFields } from './handles/listVolumeFields'; import { getVolumeFieldsState } from './handles/getVolumeFieldsState'; import { makeRunTierTransition } from './wiring/makeRunTierTransition'; import { makeReconcileEffects } from './wiring/makeReconcileEffects'; -import { makeRunFocusTween } from './camera/makeRunFocusTween'; -import { tweenToGalaxy } from './camera/tweenToGalaxy'; -import { tweenToStructure } from './camera/tweenToStructure'; -import { - MILKY_WAY_CENTER_WORLD, - MILKY_WAY_VIEW_DISTANCE_MPC, -} from '../../data/milkyWay/galacticCenter'; import type { ResolveDeps } from '../../@types/engine/ResolveDeps'; /** @@ -482,12 +474,10 @@ export function createEngine(canvas: HTMLCanvasElement, cb: EngineCallbacks): En // subsystems the closures reach into are populated before any saga dispatches // them. // - // `resolveDeps` hands the reconciler saga and the focus-tween runner the LIVE + // `resolveDeps` hands the reconciler saga and the focus-tween saga the LIVE // engine resources (read lazily each call, because clouds + structures change - // as data loads and the GPU lands only after bootstrap). Named as a const so - // both `resolveDeps:` and `makeRunFocusTween(resolveDeps, ...)` share the same - // closure — no duplication, no drift. requestRender is NOT added here — - // selection sagas reach it through the existing `reconcile` bag. + // as data loads and the GPU lands only after bootstrap). requestRender is NOT + // added here — selection sagas reach it through the existing `reconcile` bag. const resolveDeps = (): ResolveDeps => ({ catalogs: { get: (source: GalaxyCatalogSourceType) => state.data.galaxies.catalogs.get(source), @@ -500,31 +490,18 @@ export function createEngine(canvas: HTMLCanvasElement, cb: EngineCallbacks): En runTierTransition: makeRunTierTransition(state, bootstrapDeps), reconcile: makeReconcileEffects(state), resolveDeps, - runFocusTween: makeRunFocusTween(resolveDeps, { - galaxyCatalog: (row) => tweenToGalaxy(state, buildGalaxyInfo(row), store), - structure: (row) => tweenToStructure(state, row, store), - milkyWay: () => { - const cam = state.cam; - if (!cam) return; - tweenToCameraSnapshot( - state, - { - target: [ - MILKY_WAY_CENTER_WORLD[0], - MILKY_WAY_CENTER_WORLD[1], - MILKY_WAY_CENTER_WORLD[2], - ], - distance: MILKY_WAY_VIEW_DISTANCE_MPC, - yaw: cam.yaw, - pitch: cam.pitch, - fovYRad: cam.fovYRad, - near: cam.near, - far: cam.far, - }, - store, - ); - }, - }), + // The live camera Resources `watchFocusTween` reads to build a focus tween: + // the visible from-pose (so a re-focus hands off from what the user sees) and + // the lens FOV (for structure screen-fill framing). Null when `state.cam` is + // absent — pre-bootstrap or post-destroy — so the focus tween no-ops rather + // than tween from a stale pose. + cameraRuntime: () => + state.cam + ? { + from: state.cameraRuntime.lastPose.current, + fovYRad: state.cameraRuntime.projection.fovYRad, + } + : null, }); // The main async IIFE runs the bootstrap phases; all errors are caught diff --git a/src/state/camera/focusTweenDescriptor.ts b/src/state/camera/focusTweenDescriptor.ts new file mode 100644 index 000000000..9dec7ee15 --- /dev/null +++ b/src/state/camera/focusTweenDescriptor.ts @@ -0,0 +1,80 @@ +/** + * focusTweenDescriptor — the pure `SelectionRow → CameraTweenDescriptor` table. + * + * A focus gesture writes the focus ref; the camera flying to that target is the + * EFFECT of that Intent, and `watchFocusTween` is where that effect lives. This + * function is the pure core of that saga: given the resolved row, the live + * from-pose, and the lens FOV, it returns the `startCameraTween` payload. No + * engine state, no dispatch, no clock — so it is trivially unit-testable and the + * saga stays a thin resolve-then-dispatch shell. + * + * ### Why a table, not a branch chain in the saga + * + * The three focus targets — a catalog galaxy, a structure, the Milky Way — frame + * differently: a galaxy by its `diameterKpc`, a structure by its apparent radius + * through the projection FOV, the Milky Way at a fixed view distance on the + * galactic centre. That is a tagged-union dispatch on `row.type`, so it is an + * exhaustive `switch` returning one descriptor per arm — not an `if/else` ladder + * spread through the saga body (simplicity.md §7). + * + * ### What is shared across arms + * + * Every arm preserves the user's orientation — `yaw`/`pitch` carry over from the + * live `from` pose, only `target` and `distance` change — and every arm uses the + * project-wide `FOCUS_TWEEN_MS` duration and the `easeOutCubic` curve. The `to` + * target is always copied into a fresh array so the descriptor never aliases the + * row's `worldPos` (or the shared `MILKY_WAY_CENTER_WORLD` constant). + */ + +import { FOCUS_TWEEN_MS } from '../../services/engine/camera/focusTweenDuration'; +import { galaxyFocusDistance } from '../../services/engine/camera/galaxyFocusDistance'; +import { structureFocusDistance } from '../../services/engine/camera/structureFocusDistance'; +import { + MILKY_WAY_CENTER_WORLD, + MILKY_WAY_VIEW_DISTANCE_MPC, +} from '../../data/milkyWay/galacticCenter'; +import type { SelectionRow } from '../../@types/engine/SelectionRow'; +import type { CameraPose } from '../../@types/camera/CameraPose'; +import type { CameraTweenDescriptor } from '../../@types/camera/CameraTweenDescriptor'; + +/** + * Build the focus tween's `from → to` descriptor. + * + * `from` is the live produced pose (the camera the user actually sees), so an + * in-flight tween hands off smoothly when the user re-focuses mid-animation. + * `fovYRad` is the projection FOV the structure arm needs to frame a cluster to + * screen-fill; the galaxy and Milky Way arms ignore it. + */ +export function focusTweenDescriptor( + row: SelectionRow, + from: CameraPose, + fovYRad: number, +): CameraTweenDescriptor { + return { + from, + to: { yaw: from.yaw, pitch: from.pitch, ...frame(row, fovYRad) }, + durationMs: FOCUS_TWEEN_MS, + easing: 'easeOutCubic', + }; +} + +/** The per-arm part: where to point and how far back to sit. */ +function frame(row: SelectionRow, fovYRad: number): Pick { + switch (row.type) { + case 'galaxyCatalog': + return { target: [row.x, row.y, row.z], distance: galaxyFocusDistance(row.diameterKpc) }; + case 'structure': + return { + target: [row.worldPos[0], row.worldPos[1], row.worldPos[2]], + // Frame on the WIDER apparent extent — the radius the close-approach + // fade reads — so the ring + label land just past their fade-out; + // fall back to the physical core when there is no wider extent. + distance: structureFocusDistance(row.apparentRadiusMpc ?? row.physicalRadiusMpc, fovYRad), + }; + case 'milkyWay': + return { + target: [MILKY_WAY_CENTER_WORLD[0], MILKY_WAY_CENTER_WORLD[1], MILKY_WAY_CENTER_WORLD[2]], + distance: MILKY_WAY_VIEW_DISTANCE_MPC, + }; + } +} diff --git a/src/state/selection/focusTweenSaga.ts b/src/state/selection/focusTweenSaga.ts index 50c50ac1c..e5e4b3df2 100644 --- a/src/state/selection/focusTweenSaga.ts +++ b/src/state/selection/focusTweenSaga.ts @@ -1,24 +1,48 @@ /** - * watchFocusTween — the camera-tween EFFECT. A focus gesture writes the focus - * ref (updateSelectionFocus); the camera flying to the target is an effect of - * that Intent, so it lives here as a saga — symmetric with watchSelectionWake - * (render-wake) and tierSaga's runTierTransition. It calls the engine-injected - * runFocusTween runner via SagaContext; the runner resolves the ref's coords - * from the live cloud and runs the existing tweens. Firing on the REF (not the - * reconciled row) keeps the tween a response to the Intent and free of any - * dependence on watchSelectionRows running first. + * watchFocusTween — the camera-tween EFFECT of a focus gesture. A focus writes + * the focus ref (updateSelectionFocus); the camera flying to that target is an + * effect of that Intent, so it lives here as a saga — symmetric with + * watchSelectionWake (render-wake) and tierSaga's runTierTransition. + * + * The saga is a thin resolve→build→dispatch shell: + * 1. re-resolve the ref to a row via the live `resolveDeps` (firing on the REF, + * not the reconciled row, keeps the tween a response to the Intent and free + * of any dependence on watchSelectionRows running first); + * 2. read the live camera Resources (`cameraRuntime`) — the visible from-pose + * and the lens FOV — bailing when the camera is not ready (pre-bootstrap / + * post-destroy), so a focus that races bootstrap or arrives after destroy + * simply lands the ref without a tween; + * 3. build the `startCameraTween` payload with the pure `focusTweenDescriptor` + * table and dispatch it. + * + * The dispatch alone wakes the render loop: `startCameraTween` is a `camera/*` + * write, which `watchWake`/WAKE_ROUTES turns into a render request — so there is + * no separate requestRender here. A null ref (focus release) resolves to a null + * row → no tween. * * getContext is read INSIDE the worker (per-action), like watchSelectionWake and - * tierSaga, because the engine registers runFocusTween AFTER the root saga forks. + * tierSaga, because the engine registers its saga context AFTER the root saga + * forks. */ -import { takeEvery, getContext } from 'typed-redux-saga'; +import { takeEvery, getContext, put } from 'typed-redux-saga'; import { updateSelectionFocus } from './selectionSlice'; +import { startCameraTween } from '../camera/cameraSlice'; +import { focusTweenDescriptor } from '../camera/focusTweenDescriptor'; +import { extractSelectionRow } from '../../services/engine/helpers/extractSelectionRow'; import type { SagaContext } from '../../store/types'; export function* watchFocusTween() { yield* takeEvery(updateSelectionFocus, function* (action) { - const runFocusTween = yield* getContext('runFocusTween'); - runFocusTween(action.payload); + const resolveDeps = yield* getContext('resolveDeps'); + const cameraRuntime = yield* getContext('cameraRuntime'); + + const row = extractSelectionRow(action.payload, resolveDeps()); + if (row === null) return; + + const runtime = cameraRuntime(); + if (runtime === null) return; + + yield* put(startCameraTween(focusTweenDescriptor(row, runtime.from, runtime.fovYRad))); }); } diff --git a/src/store/rootSaga.ts b/src/store/rootSaga.ts index 3509fd501..2c5acb954 100644 --- a/src/store/rootSaga.ts +++ b/src/store/rootSaga.ts @@ -11,7 +11,7 @@ * watchSelectionRows — keeps the selectionRows derived cache in sync with selection refs * watchSelectionWake — wakes the render loop on select/focus writes (hover excluded) * watchRequestFocus — resolves a durable focus id to a ref, deferring on catalogLoaded - * watchFocusTween — fires the engine-injected camera tween on every focus ref change + * watchFocusTween — builds + dispatches the camera tween on every focus ref change * * Each watcher is authored beside its concern (the tier watcher in * `state/tier/tierSaga`, the reconcile watchers in `effects/reconcileSagas`) and diff --git a/src/store/types.ts b/src/store/types.ts index 55990753b..549f03c0b 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -23,9 +23,9 @@ * the lazy live-resource read the selection reconciler uses to turn a `SelectionRef` * into a `SelectionRow` (read lazily each call so the reconciler always sees the * current catalog and structure state — render-wake is reused from - * `reconcile.requestRender`, not re-added here); and `runFocusTween` is the - * engine-owned camera-tween runner `watchFocusTween` calls when the focus ref - * changes — symmetric with `runTierTransition`. `SagaContext` is the bag the + * `reconcile.requestRender`, not re-added here); and `cameraRuntime` is the + * live camera read `watchFocusTween` uses to build a focus tween — the visible + * from-pose plus the lens FOV, or null when the camera is not ready. `SagaContext` is the bag the * running root saga reads them back out of via `getContext`; `SetSagaContext` is * the setter the factory hands back so the engine can inject them * post-construction (a `Partial`, so each registration site supplies only what it @@ -42,21 +42,29 @@ import type { createAppStore } from './createAppStore'; import type { ReconcileEffects } from './effects/ReconcileEffects'; import type { ResolveDeps } from '../@types/engine/ResolveDeps'; import type { Tier } from '../@types/data/Tier'; -import type { SelectionRef } from '../@types/engine/SelectionRef'; +import type { CameraPose } from '../@types/camera/CameraPose'; export type RootState = ReturnType; export type AppStore = ReturnType['store']; export type AppDispatch = AppStore['dispatch']; export type RunTierTransition = (prevTier: Tier, nextTier: Tier) => void; -/** Camera-tween runner injected by the engine. `watchFocusTween` calls it when the focus ref changes. */ -export type RunFocusTween = (ref: SelectionRef | null) => void; +/** + * The live camera Resources `watchFocusTween` reads to seed a tween: the visible + * `from` pose (what the user sees this frame, so a re-focus hands off smoothly) + * and the projection FOV (the structure arm frames a cluster to screen-fill). + */ +export type FocusCameraRuntime = { from: CameraPose; fovYRad: number }; export type SagaContext = { runTierTransition: RunTierTransition; // already present — drives per-source data load on tier change reconcile: ReconcileEffects; // already present — provides requestRender + fade/reseed/bias /** Live engine resources the selection reconciler reads to turn a SelectionRef into a SelectionRow. */ resolveDeps: () => ResolveDeps; - /** Engine-owned camera-tween runner — watchFocusTween calls this on a focus ref change. */ - runFocusTween: RunFocusTween; + /** + * The live camera Resources `watchFocusTween` reads to build the tween, or + * null when the camera is not ready (pre-bootstrap / post-destroy) — the focus + * tween then no-ops. + */ + cameraRuntime: () => FocusCameraRuntime | null; }; export type SetSagaContext = (ctx: Partial) => void; diff --git a/tests/services/engine/camera/makeRunFocusTween.test.ts b/tests/services/engine/camera/makeRunFocusTween.test.ts deleted file mode 100644 index 54b1c5a14..000000000 --- a/tests/services/engine/camera/makeRunFocusTween.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -import { makeRunFocusTween } from '../../../../src/services/engine/camera/makeRunFocusTween'; -import { Source } from '../../../../src/data/sources'; -import type { ResolveDeps } from '../../../../src/@types/engine/ResolveDeps'; -import type { GalaxyCatalog } from '../../../../src/@types/data/galaxyCatalog/GalaxyCatalog'; -import type { StructureInfo } from '../../../../src/@types/data/structure/StructureInfo'; - -function makeCloud(): GalaxyCatalog { - return { - count: 1, - positions: new Float32Array([10, 20, 30]), - spectroscopicZ: new Float32Array([0.0123]), - magU: new Float32Array([18.1]), - magG: new Float32Array([17.4]), - magR: new Float32Array([16.9]), - magI: new Float32Array([16.6]), - magZ: new Float32Array([16.4]), - objIDs: new BigUint64Array([1237668n]), - diameterKpc: new Float32Array([42]), - axisRatio: new Float32Array([0.7]), - positionAngleDeg: new Float32Array([35]), - classByte: new Uint8Array([0]), - parentSurveyByte: new Uint8Array([0]), - } as unknown as GalaxyCatalog; -} - -const structure = { - type: 'structure', - category: 'cluster', - id: 'abell-2065', -} as unknown as StructureInfo; - -const deps: ResolveDeps = { - catalogs: { get: (s) => (s === Source.SDSS ? makeCloud() : undefined) }, - famousMeta: [], - structures: { byId: (id) => (id === 'abell-2065' ? structure : null) }, -}; - -describe('makeRunFocusTween', () => { - function build() { - const tweens = { galaxyCatalog: vi.fn(), structure: vi.fn(), milkyWay: vi.fn() }; - return { run: makeRunFocusTween(() => deps, tweens), tweens }; - } - it('galaxy ref → galaxy tween with the resolved row', () => { - const { run, tweens } = build(); - run({ type: 'galaxyCatalog', source: Source.SDSS, index: 0 }); - expect(tweens.galaxyCatalog).toHaveBeenCalledTimes(1); - expect(tweens.galaxyCatalog.mock.calls[0]![0]).toMatchObject({ - type: 'galaxyCatalog', - objId: '1237668', - }); - }); - it('structure ref → structure tween', () => { - const { run, tweens } = build(); - run({ type: 'structure', id: 'abell-2065' }); - expect(tweens.structure).toHaveBeenCalledWith(structure); - }); - it('milkyWay ref → milkyWay tween', () => { - const { run, tweens } = build(); - run({ type: 'milkyWay' }); - expect(tweens.milkyWay).toHaveBeenCalledTimes(1); - }); - it('null ref → no tween (focus release)', () => { - const { run, tweens } = build(); - run(null); - expect(tweens.galaxyCatalog).not.toHaveBeenCalled(); - expect(tweens.structure).not.toHaveBeenCalled(); - expect(tweens.milkyWay).not.toHaveBeenCalled(); - }); - it('galaxy ref to an unloaded cloud → no tween (resolves null)', () => { - const { run, tweens } = build(); - run({ type: 'galaxyCatalog', source: Source.Glade, index: 0 }); - expect(tweens.galaxyCatalog).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/services/engine/camera/tweenToGalaxy.test.ts b/tests/services/engine/camera/tweenToGalaxy.test.ts deleted file mode 100644 index c4be9c1b7..000000000 --- a/tests/services/engine/camera/tweenToGalaxy.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * tweenToGalaxy — unit tests for the camera-tween-to-galaxy helper. - * - * `tweenToGalaxy` dispatches `startCameraTween` to the camera Redux slice - * and wakes the render scheduler. It reads `from` from - * `state.cameraRuntime.lastPose.current` — the live produced pose — rather - * than from `state.cam` (the drag register), so mid-tween re-focus hands - * off from the visible position, not the stale register. - * - * The old `tweens.start` dual-write is gone; these tests pin: - * - * - `startCameraTween` is dispatched with the correct from/to descriptor - * sourced from `lastPose.current`, not `cam`. - * - `scheduler.requestRender()` is called to wake the loop. - * - No-op when `state.cam` is null (pre-bootstrap / post-destroy). - * - * `galaxyFocusDistance` arithmetic is covered by its own test; these tests - * only pin the plumbing between `tweenToGalaxy` and the store. - */ - -import { describe, it, expect, vi } from 'vitest'; - -import { tweenToGalaxy } from '../../../../src/services/engine/camera/tweenToGalaxy'; -import { galaxyFocusDistance } from '../../../../src/services/engine/camera/galaxyFocusDistance'; -import { FOCUS_TWEEN_MS } from '../../../../src/services/engine/camera/focusTweenDuration'; -import type { EngineState } from '../../../../src/@types/engine/state/EngineState'; -import type { CameraPose } from '../../../../src/@types/camera/CameraPose'; -import type { AppStore } from '../../../../src/store/types'; -import type { AppDispatch } from '../../../../src/store/types'; - -/** Build a minimal EngineState fixture with the fields `tweenToGalaxy` reads. */ -function makeState(opts: { - cam?: { yaw: number; pitch: number } | null; - lastPose?: CameraPose; - requestRender?: ReturnType; -}): EngineState { - const lastPose: CameraPose = opts.lastPose ?? { - target: [0, 0, 0], - yaw: 0, - pitch: 0, - distance: 10, - }; - return { - cam: opts.cam === undefined ? ({ yaw: 0, pitch: 0 } as unknown) : opts.cam, - cameraRuntime: { - lastPose: { current: lastPose }, - }, - subsystems: { - scheduler: { requestRender: opts.requestRender ?? vi.fn() }, - }, - } as unknown as EngineState; -} - -function makeStore(): { store: AppStore; dispatch: ReturnType> } { - const dispatch = vi.fn(); - const store = { dispatch, getState: () => ({}) } as unknown as AppStore; - return { store, dispatch }; -} - -describe('tweenToGalaxy', () => { - it('dispatches startCameraTween with to.target = [x, y, z] and to.distance = galaxyFocusDistance(diameterKpc)', () => { - const state = makeState({}); - const { store, dispatch } = makeStore(); - - tweenToGalaxy(state, { x: 100, y: 200, z: 300, diameterKpc: 25 }, store); - - expect(dispatch).toHaveBeenCalledOnce(); - const action = dispatch.mock.calls[0]![0] as { type: string; payload: unknown }; - expect(action.type).toBe('camera/startCameraTween'); - const payload = action.payload as { - from: CameraPose; - to: CameraPose; - durationMs: number; - easing: string; - }; - expect(payload.to.target).toEqual([100, 200, 300]); - expect(payload.to.distance).toBe(galaxyFocusDistance(25)); - expect(payload.durationMs).toBe(FOCUS_TWEEN_MS); - expect(payload.easing).toBe('easeOutCubic'); - }); - - it('reads `from` from cameraRuntime.lastPose.current, not state.cam', () => { - // lastPose.current is the live produced pose (may differ from cam mid-tween). - const lastPose: CameraPose = { target: [1, 2, 3], yaw: 0.5, pitch: -0.2, distance: 77 }; - const state = makeState({ lastPose }); - const { store, dispatch } = makeStore(); - - tweenToGalaxy(state, { x: 0, y: 0, z: 0, diameterKpc: 30 }, store); - - const payload = (dispatch.mock.calls[0]![0] as unknown as { payload: { from: CameraPose } }).payload; - expect(payload.from).toEqual(lastPose); - }); - - it('preserves from.yaw and from.pitch as to.yaw and to.pitch (only target + distance change)', () => { - const lastPose: CameraPose = { target: [0, 0, 0], yaw: 1.23, pitch: -0.45, distance: 50 }; - const state = makeState({ lastPose }); - const { store, dispatch } = makeStore(); - - tweenToGalaxy(state, { x: 10, y: 20, z: 30, diameterKpc: 10 }, store); - - const payload = ( - dispatch.mock.calls[0]![0] as unknown as { payload: { to: CameraPose } } - ).payload; - // Yaw and pitch of `to` inherit from the live produced pose — orientation - // is preserved across the focus jump; only target and distance change. - expect(payload.to.yaw).toBe(1.23); - expect(payload.to.pitch).toBe(-0.45); - }); - - it('calls scheduler.requestRender() to wake the render loop', () => { - const requestRender = vi.fn<() => void>(); - const state = makeState({ requestRender }); - const { store } = makeStore(); - - tweenToGalaxy(state, { x: 0, y: 0, z: 0, diameterKpc: 20 }, store); - - expect(requestRender).toHaveBeenCalledOnce(); - }); - - it('is a no-op when state.cam is null (pre-bootstrap / post-destroy)', () => { - const requestRender = vi.fn<() => void>(); - const state = makeState({ cam: null, requestRender }); - const { store, dispatch } = makeStore(); - - tweenToGalaxy(state, { x: 100, y: 200, z: 300, diameterKpc: 25 }, store); - - expect(dispatch).not.toHaveBeenCalled(); - expect(requestRender).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/services/engine/camera/tweenToStructure.test.ts b/tests/services/engine/camera/tweenToStructure.test.ts deleted file mode 100644 index b26bb8de4..000000000 --- a/tests/services/engine/camera/tweenToStructure.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * tweenToStructure — unit tests for the structure-side camera tween helper. - * - * `tweenToStructure` dispatches `startCameraTween` to the camera Redux slice - * and wakes the render scheduler. It reads `from` from - * `state.cameraRuntime.lastPose.current` (the live produced pose) and - * `fovYRad` from `state.cameraRuntime.projection.fovYRad` (the engine - * Resource), not from `state.cam`. - * - * Tests pin: - * - `startCameraTween` is dispatched with to.target = structure.worldPos - * and to.distance = structureFocusDistance(radius, fovYRad). - * - `from` sources from `cameraRuntime.lastPose.current`. - * - Apparent radius is preferred over physical core when present. - * - `scheduler.requestRender()` is called to wake the loop. - * - No-op when `state.cam` is null. - * - * `structureFocusDistance` arithmetic is covered by its own suite; these - * tests only pin the plumbing between `tweenToStructure` and the store. - */ - -import { describe, it, expect, vi } from 'vitest'; - -import { tweenToStructure } from '../../../../src/services/engine/camera/tweenToStructure'; -import { structureFocusDistance } from '../../../../src/services/engine/camera/structureFocusDistance'; -import { FOCUS_TWEEN_MS } from '../../../../src/services/engine/camera/focusTweenDuration'; -import type { EngineState } from '../../../../src/@types/engine/state/EngineState'; -import type { StructureInfo } from '../../../../src/@types/data/structure/StructureInfo'; -import type { CameraPose } from '../../../../src/@types/camera/CameraPose'; -import type { AppStore } from '../../../../src/store/types'; -import type { AppDispatch } from '../../../../src/store/types'; - -const FOV_Y = (Math.PI / 180) * 60; - -function makeState(opts: { - cam?: object | null; - lastPose?: CameraPose; - fovYRad?: number; - requestRender?: ReturnType; -}): EngineState { - const lastPose: CameraPose = opts.lastPose ?? { - target: [0, 0, 0], - yaw: 0, - pitch: 0, - distance: 100, - }; - return { - cam: opts.cam === undefined ? ({} as unknown) : opts.cam, - cameraRuntime: { - lastPose: { current: lastPose }, - projection: { fovYRad: opts.fovYRad ?? FOV_Y, aspect: 1, near: 0.01, far: 50000 }, - }, - subsystems: { - scheduler: { requestRender: opts.requestRender ?? vi.fn() }, - }, - } as unknown as EngineState; -} - -function makeStore(): { store: AppStore; dispatch: ReturnType> } { - const dispatch = vi.fn(); - const store = { dispatch, getState: () => ({}) } as unknown as AppStore; - return { store, dispatch }; -} - -const VIRGO: StructureInfo = { - type: 'structure', - id: 'virgo', - name: 'Virgo Cluster', - category: 'cluster', - worldPos: [10, 20, 30], - featured: true, - physicalRadiusMpc: 2, -}; - -describe('tweenToStructure', () => { - it('dispatches startCameraTween with to.target = worldPos', () => { - const state = makeState({}); - const { store, dispatch } = makeStore(); - - tweenToStructure(state, VIRGO, store); - - expect(dispatch).toHaveBeenCalledOnce(); - const action = dispatch.mock.calls[0]![0] as unknown as { type: string }; - expect(action.type).toBe('camera/startCameraTween'); - const payload = (action as unknown as { payload: { to: CameraPose } }).payload; - expect(payload.to.target).toEqual([10, 20, 30]); - }); - - it('frames via structureFocusDistance(physicalRadius, projection.fovYRad) when no apparentRadius', () => { - const state = makeState({ fovYRad: FOV_Y }); - const { store, dispatch } = makeStore(); - - tweenToStructure(state, VIRGO, store); - - const payload = ( - dispatch.mock.calls[0]![0] as unknown as { payload: { to: CameraPose } } - ).payload; - expect(payload.to.distance).toBe(structureFocusDistance(2, FOV_Y)); - }); - - it('frames via apparentRadius when the structure carries one', () => { - // Apparent extent (6 Mpc) > physical core (2 Mpc); the framing uses the - // wider apparent extent so the close-approach fade ring lands just in view. - const withApparent: StructureInfo = { ...VIRGO, apparentRadiusMpc: 6 }; - const state = makeState({ fovYRad: FOV_Y }); - const { store, dispatch } = makeStore(); - - tweenToStructure(state, withApparent, store); - - const payload = ( - dispatch.mock.calls[0]![0] as unknown as { payload: { to: CameraPose } } - ).payload; - expect(payload.to.distance).toBe(structureFocusDistance(6, FOV_Y)); - }); - - it('reads `from` from cameraRuntime.lastPose.current, not state.cam', () => { - const lastPose: CameraPose = { target: [1, 2, 3], yaw: 0.7, pitch: -0.3, distance: 55 }; - const state = makeState({ lastPose }); - const { store, dispatch } = makeStore(); - - tweenToStructure(state, VIRGO, store); - - const payload = (dispatch.mock.calls[0]![0] as unknown as { payload: { from: CameraPose } }).payload; - expect(payload.from).toEqual(lastPose); - }); - - it('preserves from.yaw and from.pitch as to.yaw and to.pitch', () => { - const lastPose: CameraPose = { target: [0, 0, 0], yaw: 1.2, pitch: -0.4, distance: 50 }; - const state = makeState({ lastPose }); - const { store, dispatch } = makeStore(); - - tweenToStructure(state, VIRGO, store); - - const payload = (dispatch.mock.calls[0]![0] as unknown as { payload: { to: CameraPose } }).payload; - expect(payload.to.yaw).toBe(1.2); - expect(payload.to.pitch).toBe(-0.4); - }); - - it('dispatches with durationMs = FOCUS_TWEEN_MS and easing = easeOutCubic', () => { - const state = makeState({}); - const { store, dispatch } = makeStore(); - - tweenToStructure(state, VIRGO, store); - - const payload = ( - dispatch.mock.calls[0]![0] as unknown as { payload: { durationMs: number; easing: string } } - ).payload; - expect(payload.durationMs).toBe(FOCUS_TWEEN_MS); - expect(payload.easing).toBe('easeOutCubic'); - }); - - it('calls scheduler.requestRender() to wake the render loop', () => { - const requestRender = vi.fn<() => void>(); - const state = makeState({ requestRender }); - const { store } = makeStore(); - - tweenToStructure(state, VIRGO, store); - - expect(requestRender).toHaveBeenCalledOnce(); - }); - - it('is a no-op when state.cam is null', () => { - const requestRender = vi.fn<() => void>(); - const state = makeState({ cam: null, requestRender }); - const { store, dispatch } = makeStore(); - - tweenToStructure(state, VIRGO, store); - - expect(dispatch).not.toHaveBeenCalled(); - expect(requestRender).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/state/camera/focusTweenDescriptor.test.ts b/tests/state/camera/focusTweenDescriptor.test.ts new file mode 100644 index 000000000..ac502ead7 --- /dev/null +++ b/tests/state/camera/focusTweenDescriptor.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; + +import { focusTweenDescriptor } from '../../../src/state/camera/focusTweenDescriptor'; +import { galaxyFocusDistance } from '../../../src/services/engine/camera/galaxyFocusDistance'; +import { structureFocusDistance } from '../../../src/services/engine/camera/structureFocusDistance'; +import { + MILKY_WAY_CENTER_WORLD, + MILKY_WAY_VIEW_DISTANCE_MPC, +} from '../../../src/data/milkyWay/galacticCenter'; +import { FOCUS_TWEEN_MS } from '../../../src/services/engine/camera/focusTweenDuration'; +import type { CameraPose } from '../../../src/@types/camera/CameraPose'; +import type { GalaxyRow } from '../../../src/@types/engine/GalaxyRow'; +import type { StructureInfo } from '../../../src/@types/data/structure/StructureInfo'; + +// A representative live pose to seed `from`. yaw/pitch must survive into `to` +// (focus keeps the user's orientation; only target + distance change). +const FROM: CameraPose = { target: [9, 9, 9], yaw: 1.23, pitch: -0.4, distance: 5 }; +const FOVY = 0.8; + +const galaxyRow = (over: Partial = {}): GalaxyRow => ({ + type: 'galaxyCatalog', + source: 0, + index: 7, + objId: '12345', + x: 1, + y: 2, + z: 3, + redshift: 0.01, + magU: 0, + magG: 0, + magR: 0, + magI: 0, + magZ: 0, + diameterKpc: 40, + axisRatio: 1, + positionAngleDeg: 0, + classByte: 0, + parentSurveyByte: 0, + ...over, +}); + +const structureRow = (over: Partial = {}): StructureInfo => + ({ + type: 'structure', + worldPos: [10, -20, 30], + physicalRadiusMpc: 2, + apparentRadiusMpc: 5, + ...over, + }) as StructureInfo; + +describe('focusTweenDescriptor', () => { + it('carries the live from-pose, FOCUS_TWEEN_MS, and easeOutCubic on every arm', () => { + const d = focusTweenDescriptor(galaxyRow(), FROM, FOVY); + expect(d.from).toBe(FROM); + expect(d.durationMs).toBe(FOCUS_TWEEN_MS); + expect(d.easing).toBe('easeOutCubic'); + }); + + it('a galaxy row targets its position and frames on its diameter, keeping yaw/pitch', () => { + const d = focusTweenDescriptor(galaxyRow({ x: 1, y: 2, z: 3, diameterKpc: 40 }), FROM, FOVY); + expect(d.to.target).toEqual([1, 2, 3]); + expect(d.to.distance).toBe(galaxyFocusDistance(40)); + expect(d.to.yaw).toBe(FROM.yaw); + expect(d.to.pitch).toBe(FROM.pitch); + }); + + it('a structure row frames on its apparent radius via the lens FOV', () => { + const d = focusTweenDescriptor(structureRow({ apparentRadiusMpc: 5 }), FROM, FOVY); + expect(d.to.target).toEqual([10, -20, 30]); + expect(d.to.distance).toBe(structureFocusDistance(5, FOVY)); + }); + + it('a structure row falls back to its physical radius when no apparent radius', () => { + const d = focusTweenDescriptor( + structureRow({ apparentRadiusMpc: undefined, physicalRadiusMpc: 2 }), + FROM, + FOVY, + ); + expect(d.to.distance).toBe(structureFocusDistance(2, FOVY)); + }); + + it('the Milky Way arm targets the galactic centre at the fixed view distance', () => { + const d = focusTweenDescriptor({ type: 'milkyWay' }, FROM, FOVY); + expect(d.to.target).toEqual([ + MILKY_WAY_CENTER_WORLD[0], + MILKY_WAY_CENTER_WORLD[1], + MILKY_WAY_CENTER_WORLD[2], + ]); + expect(d.to.distance).toBe(MILKY_WAY_VIEW_DISTANCE_MPC); + expect(d.to.yaw).toBe(FROM.yaw); + }); + + it('copies the target into a fresh array — never aliases the source Vec3', () => { + const struct = structureRow({ worldPos: [10, -20, 30] }); + const d = focusTweenDescriptor(struct, FROM, FOVY); + expect(d.to.target).not.toBe(struct.worldPos); + }); +}); diff --git a/tests/state/selection/focusTweenSaga.test.ts b/tests/state/selection/focusTweenSaga.test.ts index 2da8eecdf..d2d650c17 100644 --- a/tests/state/selection/focusTweenSaga.test.ts +++ b/tests/state/selection/focusTweenSaga.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import createSagaMiddleware from 'redux-saga'; import { configureStore } from '@reduxjs/toolkit'; @@ -8,33 +8,70 @@ import { updateSelectionFocus, updateSelectionSelect, } from '../../../src/state/selection/selectionSlice'; +import { cameraRoute } from '../../../src/store/constants'; +import { MILKY_WAY_VIEW_DISTANCE_MPC } from '../../../src/data/milkyWay/galacticCenter'; +import type { CameraPose } from '../../../src/@types/camera/CameraPose'; +import type { ResolveDeps } from '../../../src/@types/engine/ResolveDeps'; +import type { FocusCameraRuntime } from '../../../src/store/types'; const flush = () => new Promise((r) => setTimeout(r, 0)); +// A live from-pose to seed the tween. The Milky-Way arm preserves yaw/pitch and +// targets a fixed distance, so the dispatched descriptor is fully determined by +// (ref type, from-pose) — no engine cloud needed for the milkyWay case. +const FROM: CameraPose = { target: [1, 1, 1], yaw: 0.5, pitch: -0.2, distance: 9 }; + +// resolveDeps stub — the milkyWay ref resolves without touching catalogs. +const resolveDeps = (): ResolveDeps => + ({ + catalogs: { get: () => undefined }, + famousMeta: undefined, + structures: { byId: () => undefined }, + }) as unknown as ResolveDeps; + describe('watchFocusTween', () => { let store: ReturnType; - let runFocusTween: ReturnType void>>; + let cameraRuntime: () => FocusCameraRuntime | null; function build() { const mw = createSagaMiddleware(); const s = configureStore({ reducer: rootReducer, middleware: (g) => g().concat(mw) }); mw.run(watchFocusTween); - runFocusTween = vi.fn<(ref: unknown) => void>(); - mw.setContext({ runFocusTween }); + cameraRuntime = () => ({ from: FROM, fovYRad: 0.8 }); + mw.setContext({ resolveDeps, cameraRuntime: () => cameraRuntime() }); return s; } beforeEach(() => { store = build(); }); - it('a focus ref change runs the tween with the ref', async () => { + it('a focus ref change dispatches startCameraTween with the built descriptor', async () => { store.dispatch(updateSelectionFocus({ type: 'milkyWay' })); await flush(); - expect(runFocusTween).toHaveBeenCalledWith({ type: 'milkyWay' }); + + const tween = store.getState()[cameraRoute].tween; + expect(tween).not.toBeNull(); + expect(tween!.from).toEqual(FROM); + expect(tween!.to.distance).toBe(MILKY_WAY_VIEW_DISTANCE_MPC); + expect(tween!.to.yaw).toBe(FROM.yaw); }); - it('a select (non-focus) write does NOT run the tween', async () => { + + it('a select (non-focus) write does NOT start a tween', async () => { store.dispatch(updateSelectionSelect({ type: 'milkyWay' })); await flush(); - expect(runFocusTween).not.toHaveBeenCalled(); + expect(store.getState()[cameraRoute].tween).toBeNull(); + }); + + it('no-ops when the camera is not ready (cameraRuntime returns null)', async () => { + cameraRuntime = () => null; + store.dispatch(updateSelectionFocus({ type: 'milkyWay' })); + await flush(); + expect(store.getState()[cameraRoute].tween).toBeNull(); + }); + + it('a null focus ref (release) resolves to no row → no tween', async () => { + store.dispatch(updateSelectionFocus(null)); + await flush(); + expect(store.getState()[cameraRoute].tween).toBeNull(); }); }); diff --git a/tests/store/sagaContext.test.ts b/tests/store/sagaContext.test.ts index 05e90d6fc..205ec8f03 100644 --- a/tests/store/sagaContext.test.ts +++ b/tests/store/sagaContext.test.ts @@ -3,30 +3,27 @@ * SagaContext carries the correct function type. * * `runTierTransition` and `reconcile` predate this task (added by PRs #349 and - * #352 respectively). `resolveDeps` is the lazy live-resource read introduced - * in Part 1: the selection reconciler calls it per saga run to get the current - * catalog + structure state without coupling to the engine's internals. - * `runFocusTween` is the engine-injected camera-tween runner added in Task 4b: - * `watchFocusTween` calls it on every focus ref change. + * #352 respectively). `resolveDeps` is the lazy live-resource read: the selection + * reconciler calls it per saga run to get the current catalog + structure state + * without coupling to the engine's internals. `cameraRuntime` is the live camera + * read `watchFocusTween` uses to build a focus tween — the visible from-pose plus + * the lens FOV, or null when the camera is not ready. * * Render-wake is NOT added to SagaContext here — it already arrives via * `reconcile.requestRender` (see ReconcileEffects). */ import { describe, it, expectTypeOf } from 'vitest'; -import type { SagaContext } from '../../src/store/types'; +import type { SagaContext, FocusCameraRuntime } from '../../src/store/types'; import type { ResolveDeps } from '../../src/@types/engine/ResolveDeps'; import type { ReconcileEffects } from '../../src/store/effects/ReconcileEffects'; -import type { SelectionRef } from '../../src/@types/engine/SelectionRef'; describe('SagaContext', () => { it('carries resolveDeps alongside the existing reconcile/tier capabilities', () => { expectTypeOf().toEqualTypeOf<() => ResolveDeps>(); expectTypeOf().toEqualTypeOf(); }); - it('carries runFocusTween — the engine-injected camera-tween runner', () => { - expectTypeOf().toEqualTypeOf< - (ref: SelectionRef | null) => void - >(); + it('carries cameraRuntime — the live camera read the focus-tween saga builds from', () => { + expectTypeOf().toEqualTypeOf<() => FocusCameraRuntime | null>(); }); });