Camera pose as derived state: fold OrbitCamera into a store Intent slice#357
Merged
Conversation
The mutable OrbitCamera is written every frame by three producers (drag, tween, auto-rotate) and its render-wake is hand-paired at each orbitControls mutation site. This spec folds the camera's Intent into the store: base pose (drag accumulator) + tween descriptor + auto-rotate params, with the per-frame interpolated pose DERIVED by resolveCameraPose(intent, now) rather than stored. Continuous input is saga-throttled into coarse base-pose writes; discrete commands dispatch a tween descriptor once; the engine reads the pose back by pull via a get cam() getter mirroring get settings(). cameraDrivers + the tweenManager closure invert into the derivation; wake rides reconcile-sagas' watchWake. Depends on the reconcile-sagas seam landing first. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- watchWake matches settings/ only; the new camera root route needs the predicate generalized to a WAKE_ROUTES set (was wrongly claimed to wake by construction). - autoRotate already moved to settings.camera.autoRotate in #352; the spec now RELOCATES it into the new camera slice rather than treating it as new. - dependency flips from "blocks on merge" to "builds on landed PR #352". - deep-link reworded: #focus=<ref> -> arrival tween on load, never a serialized pose/descriptor (descriptor is session-local). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrites the spec around the grilled model (transcript added): - pose PRODUCED by the existing CameraDriver table (reworked to return a pose, +orbitDrag/resting rows) — not a resolveCameraPose if-chain, not a CameraManipulator. - timeless descriptors; the engine owns the animation clock (Resource); uniform commit-on-edge folds the last pose into base. - drag integrates in the transient state.cam register and commits one pose on pointerup — no throttle, no delta actions. - migration: state.cam stays the bridge; flip the engine read last. - deep-link = #focus ref -> arrival tween; descriptor session-local. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Breaks the camera-intent spec's §8 build order into a TDD task list: slice + pure pieces, writers-populate-with-state.cam-bridge, read-flip cutover, throttle-free input + WAKE_ROUTES wake, trim, quality gates. Reviewed against the spec; fixed three bridging defects (resolver takes the clock not a single elapsedMs; Phase-1 resting floor; bootstrap base seed before cutover). Moves the backlog entry to "Plans ready to pick up". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First step of folding the camera into a store Intent slice. Three type-decl files (one type each): the orbit-param pose the drivers produce, a timeless from/to tween descriptor (clock lives on the engine, not the type), and the slice state shape. Nothing consumes them yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inline-Immer `camera` root slice (beginDrag/endDrag, commitCameraPose, startCameraTween/cancelCameraTween, setAutoRotate) mirroring the settings slice; `cameraRoute = 'camera'`. base is a placeholder overwritten at bootstrap; autoRotate.rate is now the slice's single home for the old per-frame yaw delta. Nothing consumes the slice yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mount the camera reducer under cameraRoute (RootState gains a typed `camera` slot) and add the read seam: selectCameraIntent base + composed selectCameraBase / selectAutoRotate / selectCameraActive. selectCameraActive is the render-loop continuation predicate (dragging || tween || autoRotate). The camera-side selectAutoRotate coexists with the settings one until the Phase-5 relocation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tween easing + shortest-arc yaw math from advanceCameraTween, reshaped as a pure (descriptor, elapsedMs) -> CameraPose: no cam mutation, no updatePosition, saturates to d.to exactly. spinAutoRotate is the pure successor to the per-frame yaw delta (rate = rad/frame at an assumed 60fps budget). Both return fresh poses with fresh target arrays. The driver table wires them in later phases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The read-side bridge from store Intent to the renderer camera: merges a CameraPose (orbit params) with the engine projection Resource (fovYRad/aspect/near/far) and derives world-space position via updatePosition. Pure — fresh target array, fresh position vector, no roll. CameraProjection gets its own one-type file (the engine clock reuses it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework the camera-driver seam so drivers RETURN a CameraPose instead of mutating the cam, and runCameraDrivers returns the winning pose. Adds an always-active `resting` floor (priority 0) so the resolver always yields a pose, plus a poseOf(cam) helper. The runFrame call site now consumes the pose and writes it back onto state.cam (forward-looking shim); shouldKeepTicking and isActive take RootState. buildCameraDrivers keeps the tween/autoRotate wrappers as temporary EngineState-closure shims. Also fixes a skipLibCheck-masked wrong-depth OrbitCamera import in CameraDriver.d.ts, and repoints the cameraDriverWrappers / runFrame / shouldKeepTicking / rootReducer tests onto the new signatures. Shipped as one commit: a resolver signature change is not green without its call sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The engine-owned animation clock that sits between the timeless store descriptors and the easing drivers. tweenElapsed/autoRotateElapsed detect when a tween descriptor's reference identity (or the auto-rotate active bit) changed and zero the relevant start, so the store descriptors carry no startMs. Ref-identity reset replaces a per-driver enter hook. Both functions take nowMs as an arg — never read the wall clock themselves. Not yet wired into the resolver. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thread store: AppStore down the focus cascade (commitFocus → COMMIT_FOCUS → commitGalaxy/Structure/MilkyWayFocus → tweenToGalaxy/ Structure/CameraSnapshot) and dispatch startCameraTween from each leaf alongside the existing tweens.start(). Dual-write bridge: the tweenManager still drives the visible tween while the camera slice records the same from/to as Intent (from = poseOf(cam)); the slice becomes the read-of-truth when the driver reads it next task. Behavior byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t state.cam Flip the camera read so the per-frame pose is PRODUCED by the CameraDriver table reading the Redux store, instead of mutating a live OrbitCamera. Folds plan tasks 2.3 + 3.1 + 3.2 + 4.1 + 4.2 into one change — the read-flip is inseparable: driver-reads-slice, deriveFrameContext-produces-pose, resting=base, state.cam-as-drag-register, and the orbitControls gesture rewire must land together or orbit-controls breaks / the camera jumps. - cameraDrivers: 4-row store-reading table (orbitDrag 80 / tween 60 / autoRotate 20 / resting 0); runCameraDrivers takes the clock + nowMs and computes the winner's elapsed; shared pickWinner; new activeDriverId so the commit-on-edge and the pose step agree on the winner by construction. - frameContext: threaded read-flip — deriveFrameContext(state, canvas, pose, projection) assembles the OrbitCamera from the produced pose + the live projection Resource; pose is produced once per frame in runFrame. - runFrame: produce -> tween-completion (cancelCameraTween) -> commit-on-edge (commitCameraPose on leaving tween/autoRotate) -> update Resources; resize writes projection.aspect; scale-bar reads lastPose + projection. - engine: cameraRuntime Resource bag (clock / projection / lastPose / prevActiveId) on EngineState; wireInput seeds base via commitCameraPose at bootstrap and wires onGestureStart/onGestureEnd/onChange. - orbitControls: onGestureStart/onGestureEnd gesture hooks + onChange (replaces the five onCameraChange fire sites); drag seeds state.cam from lastPose, the grab cancels the tween, the release commits the final pose. - focus handlers: drop the tweens.start dual-write, dispatch startCameraTween with from=cameraRuntime.lastPose, and wake explicitly via requestRender. Auto-rotate bridge (a clearly-marked Phase-5 temporary): runFrame mirrors settings.camera.autoRotate into the camera slice so the driver is fed without a React edit until the App toggle relocates. watchWake->WAKE_ROUTES (4.3) and the Phase-5 trims (tweenManager / onCameraChange removal, toggle relocation) stay separate. Full suite green (2848). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the 5 Minor findings from the cutover review (all cosmetic; no behaviour change): - engine.ts: annotate the `cameraRuntime` literal as `CameraRuntime` and drop the per-field `as` casts (`as [number,number,number]`, `as CameraProjection`, `as CameraPose`, `as string`) — the annotation supplies the types and `target` now infers as `Vec3`, per the no-raw-tuples convention. - runFrame.ts: note that the scale-bar snapshot's `if (state.cam)` guard is the bootstrap-ready proxy, not a data read (values come from lastPose + projection). - commitOnEdge.test.ts: collapse two imports from `cameraDrivers` into one. - cameraDrivers.test.ts: delete the unused `makeRootState` scaffolding. Also mark the 5 folded plan tasks (2.3/3.1/3.2/4.1/4.2) done in the plan doc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The render-on-demand scheduler is passive; store writes that change the drawn scene must poke it. watchWake matched only settings/* writes, so camera/* writes (beginDrag, startCameraTween, setAutoRotate, …) left the loop asleep. Replace the settings-only prefix matcher with a WAKE_ROUTES set keyed on the action's route segment, so any slice listed there wakes the renderer by construction — no parallel watchCameraWake, no per-action requestRender audit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t-slice Reconciles the camera-intent cutover with #350's selection fold. The two overlap on the focus/selection path: #350 dissolved the commitFocus* cascade and the selectionSubsystem into selection-slice sagas (requestFocusSaga, focusTweenSaga → runFocusTween → FocusTweenTable), while our branch made the focus camera tween a store Intent (startCameraTween → tween CameraDriver). Resolution: keep #350's selection sagas; plug the camera-slice tween Intent in by having the engine's FocusTweenTable handlers call our store-dispatching tweenToGalaxy / tweenToStructure / tweenToCameraSnapshot (threading `store`). Deleted our now-superseded commitFocus cascade. Merged both slice registrations (camera + selection + selectionRows) in rootReducer/constants, and kept our camera bootstrap seed + gesture hooks alongside #350's selection dispatches in wireInput. Corrected the now-stale "Task 4.3 out of scope" wake comments in the tween helpers (camera writes wake via WAKE_ROUTES; the explicit requestRender is a direct synchronous wake). typecheck clean; full suite 2922 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lete the bridge
Phase 5.1 of the camera-intent cutover. The App auto-rotate toggle dispatched
`settings/setAutoRotate`, and a per-frame bridge in runFrame mirrored
`settings.camera.autoRotate` into the camera slice the driver reads. Relocate the
Intent to its home: the toggle now dispatches `camera/setAutoRotate({ active, rate })`
directly (rate sourced from the slice via a new `selectAutoRotateRate`, single source
of truth), and the bridge is deleted.
Removes the `camera` sub-object from the settings slice (reducer, initial state,
selector, and EngineSettingsState type) and repoints the App imports to the camera
slice. Deletes the two bridge tests + the settings-side auto-rotate tests; drops the
now-defunct `settings.camera` fixtures across the engine/state test suites.
typecheck clean; full suite 2918 passing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…complete Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… path Phase 5.2 of the camera-intent cutover. The focus tween is now a store Intent (startCameraTween → tween CameraDriver → evaluateTween), so the imperative TweenManager facade and the OrbitCamera-mutating advanceCameraTween it wrapped are both dead. Deletes tweenManager.ts, cameraTween.ts (advanceCameraTween), and the TweenManager / CameraTween types, plus their tests. Removes the engine wiring (createTweenManager construction, the `tweens` subsystem field + its teardown) and the orbit-controls `tweens.cancel()` dual-write — onGestureStart's cancelCameraTween() dispatch is now the single cancel-on-grab path. The tween math survives in the pure evaluateTween (its own tests); the shared easing utils in utils/math are untouched. Tidied comments that referenced the deleted symbols. typecheck clean; full suite 2904 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts: # src/components/App/App.tsx
…the camera surface
Every orbit-controls call site wakes the render loop via `onChange`, so the
deprecated `onCameraChange` wake callback had no fire site — remove it. Scrub
the journey/migration narration from `onChange`'s doc and the stale `tweens` /
`CameraTween` references the tween-manager deletion left in the descriptor and
subsystem-bag headers.
Freeze the surviving camera surface: a shape test pins the slice to exactly
{ base, tween, autoRotate, dragging } and the descriptor to { from, to,
durationMs, easing }, backed by compile-time `@ts-expect-error` guards that
fail at tsc if the descriptor regrows a wall-clock field or the subsystem bag
regrows a `tweens` handle.
(The scale-bar snapshot callback `EngineCallbacks.camera.onCameraChange` is a
distinct identifier and is untouched.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review fix: describe the current camera surface, not the cutover that produced it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e-home the boot placeholder
Entanglement-radar (Phase 6.1) follow-ups:
- The loop-continuation predicate now reads `selectCameraActive(s)` — the
spec §4 selector — as its camera term, instead of re-deriving the same OR
from the driver table inside `shouldKeepTicking`. The selector is the one
definition of "the camera is moving" for both the keep-tick gate and the
React play/pause affordance; the now-redundant `drivers` param is dropped.
Also removes a `pickWinner` import that survived only in comments.
- The engine's `cameraRuntime.lastPose` seeds from the camera slice's initial
`base` (the single home for the pre-bootstrap placeholder) rather than a
third hardcoded `{ distance: 0.43 }` literal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ager symbols Final-review comment hygiene: scrub plan/spec back-references and "old/legacy" journey framing from the camera slice + spinAutoRotate + cameraDrivers headers, and remove the now-dangling `tweenManager` / `cameraTween` example mentions that this branch's deletions orphaned in the renderer, fade, and Vec headers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
skymap | 6625c08 | Commit Preview URL Branch Preview URL |
Jun 20 2026, 02:35 PM |
Three regressions found in smoke-testing the derived-pose cutover: - Wheel zoom did nothing. Wheel isn't a pointer gesture, so `dragging` never flips true and the `resting` driver renders `base`, ignoring the `cam` register the wheel mutated. A discrete wheel zoom now commits straight into `base` via a new `onZoom` callback (reading `base` from the store so rapid ticks accumulate); a wheel mid-drag still folds into `cam` and rides the gesture commit. - Tween flashed the original pose for one frame on completion. Commit-on-edge ran AFTER produce, so the deactivation frame rendered the stale pre-edge `base`. The frame now renders the just-committed pose (`lastPose.current`) on any deactivation edge — fixes the tween-end and auto-rotate-off flickers. - Auto-rotate jumped on drag-release. `spinAutoRotate` spins from a frozen `base` by cumulative elapsed, and the clock only reset on the active-bit flip. Dragging re-commits `base` without resetting elapsed, so resume applied the whole accumulated spin to the new base. The spin clock now also resets on a `base`-identity change (commit edge), never mid-steady-spin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rulkens
added a commit
that referenced
this pull request
Jun 20, 2026
…spike Brings in the camera-pose-as-derived-state refactor (#357) + the focus-tween fold into watchFocusTween (#358). The CameraDriver contract changed from a mutating apply(cam, nowMs) to a pure pose(s, cam, elapsedMs) that RETURNS a CameraPose; assembleOrbitCamera derives position from it. Migrated the four throwaway spike drivers (webshow / flowshow / flyout / floworbit) to the new contract: - apply(cam) mutation -> pose() returning a fresh CameraPose; no more updatePosition (the resolver derives position via assembleOrbitCamera). - Self-clock via performance.now(): the driver-table elapsed clock (elapsedForWinner) only serves the 'tween'/'autoRotate' ids, so any other driver id receives elapsedMs === 0. - Self-sustain the loop: shouldKeepTicking reads camera liveness off the store (drag/tween/autoRotate), so a spike driver gating on local phase is invisible to it; each driver now pokes requestRender() per frame or the take freezes after one tick. - priority 80 -> 90: orbitDrag now occupies 80, so the takes move above the store movers to keep owning the camera. - Drop the fovYRad set (CameraPose carries no FOV); framing reads the live projection FOV off the forwarded cam. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Folds the mutable
OrbitCamerainto a Redux ToolkitcameraIntent slice ({ base, tween, autoRotate, dragging }). The per-frame camera pose is now DERIVED, not stored: a 4-rowCameraDrivertable reads the store each frame and the highest-priority active driver authors the pose.runCameraDriversresolves a single winner (no blending);evaluateTween/spinAutoRotateare pure pose transformers; the animation clock is an engineResource(no wall-clock in the store).commitCameraPosebakes the live pose back intobaseonly on a driver-deactivation edge (gesture-end, tween/auto-rotate finishing) — never per frame — so there's no one-frame snap-back. Loop wake is centralized in aWAKE_ROUTESsaga matcher (settings + camera).The old imperative machinery is deleted:
tweenManager,cameraTween/advanceCameraTween, andOrbitControlsOptions.onCameraChange. Auto-rotate moved out of the settings slice into the camera slice.state.camsurvives only as transient drag scratch.Why
Camera position was mutable engine state coordinated through callbacks and refs. Making it derived store Intent gives every consumer (orbit controls, auto-rotate, focus tweens, sagas, the future tour) one authoritative source, removes the "ask twice, get two answers" hazard, and turns driver precedence into data — a new mover (e.g. a tour at priority 95) is a one-line table row, not surgery on the frame loop.
Notable
origin/main: Fold galaxy selection into the RTK Intent store (ADR 0007 first fold) #350 (galaxy selection → RTK slice +focusTweenSaga→FocusTweenTable) and Fold App's radial store reach into push-down Containers #356 (App store-reach pushed into push-down Containers). Focus tweens are wired through Fold galaxy selection into the RTK Intent store (ADR 0007 first fold) #350's saga into the store-dispatching tween writers; the auto-rotate pill became a container pointed at the camera slice.@ts-expect-errorguards pin the slice to{ base, tween, autoRotate, dragging }and the tween descriptor as clock-free.shouldKeepTickingtoselectCameraActive; single-home the boot placeholder) are folded in.docs/superpowers/{specs,plans}/2026-06-19-camera-intent-slice*.Test plan
npm run typecheck(src + tools) → clean.npm test→ 2983 passing, 489 files.createTweenManager,TweenManager,advanceCameraTween,CameraTween,OrbitControlsOptions.onCameraChange,settings.camera) → zero.🤖 Generated with Claude Code