Skip to content

Camera pose as derived state: fold OrbitCamera into a store Intent slice#357

Merged
rulkens merged 31 commits into
mainfrom
spec/camera-intent-slice
Jun 20, 2026
Merged

Camera pose as derived state: fold OrbitCamera into a store Intent slice#357
rulkens merged 31 commits into
mainfrom
spec/camera-intent-slice

Conversation

@rulkens

@rulkens rulkens commented Jun 20, 2026

Copy link
Copy Markdown
Owner

What

Folds the mutable OrbitCamera into a Redux Toolkit camera Intent slice ({ base, tween, autoRotate, dragging }). The per-frame camera pose is now DERIVED, not stored: a 4-row CameraDriver table reads the store each frame and the highest-priority active driver authors the pose.

orbitDrag (80)  >  tween (60)  >  autoRotate (20)  >  resting (0)

runCameraDrivers resolves a single winner (no blending); evaluateTween / spinAutoRotate are pure pose transformers; the animation clock is an engine Resource (no wall-clock in the store). commitCameraPose bakes the live pose back into base only 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 a WAKE_ROUTES saga matcher (settings + camera).

The old imperative machinery is deleted: tweenManager, cameraTween/advanceCameraTween, and OrbitControlsOptions.onCameraChange. Auto-rotate moved out of the settings slice into the camera slice. state.cam survives 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

Test plan

  • npm run typecheck (src + tools) → clean.
  • npm test2983 passing, 489 files.
  • Deleted-symbol grep (createTweenManager, TweenManager, advanceCameraTween, CameraTween, OrbitControlsOptions.onCameraChange, settings.camera) → zero.
  • Visual smoke test in progress: driver hand-offs (grab mid-spin / mid-tween), no snap-back on release, render-on-demand wake, focus/selection/Milky-Way/deep-link paths, scale bar.

🤖 Generated with Claude Code

rulkens and others added 30 commits June 19, 2026 05:41
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>
…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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 20, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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 rulkens merged commit 4e05343 into main Jun 20, 2026
2 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant