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>(); }); });