From 20de2ed9ae486a5bccd67fe9fe42cf8d8d3c96af Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 21 Jun 2026 01:55:39 +0200 Subject: [PATCH 01/14] docs: spec + plan for dissolving RenderFrameSettings Adds the approved design spec and TDD implementation plan for removing the per-frame RenderFrameSettings bag (passes read from state + ctx), plus the backlog entry folding the GPU-handle nullability follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/BACKLOG.md | 6 + ...26-06-21-dissolve-render-frame-settings.md | 282 ++++++++++++++++++ ...1-dissolve-render-frame-settings-design.md | 158 ++++++++++ 3 files changed, 446 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-21-dissolve-render-frame-settings.md create mode 100644 docs/superpowers/specs/2026-06-21-dissolve-render-frame-settings-design.md diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 976d34ca..a076d51c 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -83,6 +83,12 @@ Diagnosed but unplanned. Captured here so they don't get lost; promote to a spec - **Milliquas needs its own colormap (AGN ≠ galaxy)** — Milliquas points render overwhelmingly blue. The *clamp* half shipped (#282): the redshift K-correction (`kPerZ`) was subtracting more than the whole `[0,2]` ramp span for high-z quasars and pinning every row to the blue floor; `kPerZ` is now 0 so the real B−R spread survives. What remains is the **semantic mismatch**: quasars genuinely have small B−R, so on the galaxy star-forming↔elliptical ramp they legitimately land in the blue third, but "blue" there means "star-forming galaxy" — the wrong reading for a non-thermal AGN continuum. The fix is to give AGN their own visual encoding instead of reusing the galaxy ramp. Directions to weigh in the brainstorm: (a) a distinct AGN ramp (violet/amber) keyed on B−R so quasars read as a different object class; (b) encode **redshift** instead of colour (z is the meaningful axis for objects spanning the observable universe); (c) tint by the Milliquas **class byte** (Q/A/B/K/N/S) or parent-survey byte — both already on the `.bin`. Likely needs a `colourMode` discriminant on `SOURCE_REGISTRY` + a shader ramp branch; brainstorm → spec → plan, not a drop-in. - **Tour feature (full)** — finish the camera tour beyond the Part-2 seed. Tracked design: [splash-screen Part 2 plan](superpowers/plans/2026-05-20-splash-screen-02-stub-tour.md) (ready to execute; engine-side `engine.tour` seed, verified current 2026-06-04) and [2026-05-07 tour-animation spec](superpowers/specs/2026-05-07-tour-animation-design.md) (re-grounded 2026-06-04; labels + MW impostor + engine-API decisions resolved, cinematic palette documented). Execute Part 2 first, then resolve the spec's remaining open decisions (rotation slerp, caption producer, beat list/timing), promote to a plan, and extend the seed into the real waypoint tour. - **Label declutter flickers under continuous camera motion (make it a toggle)** — surfaced 2026-06-18 while spiking the cosmic-flows orbit capture. The label director's greedy screen-space overlap cull (`labelDirectorSubsystem.ts` `declutter`, `DECLUTTER_MARGIN_PX = 48`) suppresses the lower-`prominencePx` of any two anchors landing within 48 px in both x and y. Under a slow orbit (or any sustained camera move) labels repeatedly cross that margin and get suppressed-then-released frame-to-frame, which reads as flicker — distracting on a screen recording, and arguably on normal navigation. Two threads: (1) **add a user setting** to disable declutter — belongs under **Settings → Labels → Advanced** (the Labels section already exists; this is a new per-section Advanced toggle, plumbed `settings.labels.declutter` → `labelDirectorSubsystem`). A throwaway `?nodeclutter` URL gate was added in the `fly-to-edge-spike` worktree as a stopgap and should be replaced by the real setting. (2) **Stabilise the cull itself** so on/off decisions hysteresis-damp rather than toggle per-frame (e.g. a release margin wider than the suppress margin, or a short cooldown before a suppressed label can re-show) — the proper fix if labels are wanted *on* during the cosmic-web clip (Post 1) and the tour. +- **GPU-handle nullability + the `ctx`/`PassDeps` re-threading it forces (revisit)** — surfaced 2026-06-21 while specing the [RenderFrameSettings dissolution](superpowers/specs/2026-06-21-dissolve-render-frame-settings-design.md). **Tackle *after* that dissolution lands** — RFS is the smaller, cleaner first cut of the identical "bag re-threads what's already on state" pattern, and finishing it makes this one obvious. + - **The root fact.** Every field on `EngineGpuHandles` is `Renderer | null` because `createEngine` returns synchronously but the GPU pipelines are built in an async `requestAdapter`→`requestDevice`→shader/atlas chain — absent for ~2 bootstrap frames (and re-nulled by `destroy()`). One *transient* lifecycle fact is encoded as *perpetual* per-field nullability, so every access forever pays a null-check tax though the handle is provably non-null after `initGpu`. + - **The inconsistency.** Four different access styles coexist for the same kind of thing: (1) **ctx-narrowed** — `renderer`/`postProcess`/`volumeOffscreen`/`texturedDisks` narrowed once via `isEngineReady`/`ReadyFrameContext` (`frameContext.ts:57-72`), read as `ctx.renderer`; (2) **`!`-asserted** at point of use (`pointSpritesPass.ts:98` `state.gpu.focusUniform!`); (3) **null-checked** at point of use (~15 renderers); (4) **always-non-null null-object** (`timingService`'s no-op stub — the better pattern, already in the tree). The `runFrame`→`renderFrame` assembly even pulls renderers from two sources inconsistently (`runFrame.ts:344-348`: some from `deps`, some from `state.gpu`). + - **`PassDeps` is the renderer-equivalent of `RenderFrameSettings`.** It re-threads renderers already on `state.gpu` purely to launder the `| null` into a non-null shape at the `renderFrame` boundary — the docblock admits `texturedDiskRenderer`/`proceduralDiskRenderer`/`milkyWayRenderer` live on the bag *only* for `destroy()` reachability and "are not consumed via this bag at runtime (the frame loop receives them through `RunFrameDeps`)." Fix the nullability once and passes read `state.gpu.X` directly (they already get `state`); `PassDeps` sheds its renderer fields. Also folds in the `ctx` half: `ctx` re-declaring `state.gpu.*` handles is the same narrowing-laundering. + - **The careful caveat (scar tissue).** The point-of-use checks are *partly deliberate*: the docblocks exclude handles from `isEngineReady` because "bootstrap progression isn't the inverse of teardown" — the 2026-05-08 black-screen incident (memory `feedback_lifecycle_vs_teardown_invariants`), where consolidating a multi-handle "ready" predicate over-constrained mid-bootstrap callbacks and blanked the canvas. So the naive "one big ready flag" is exactly the move that bit us. + - **Target.** Separate the three concerns currently fused under one `| null`: **existence** (non-null after `initGpu` — nearly all), **data readiness** (`filamentRenderer` exists but empty until `loadFilaments`; `flowFieldRenderer` until demand — genuinely still absent at draw time), and **destroy reachability**. Narrow the bootstrap-guaranteed handles once into a non-null "ready GPU" view (extend `ReadyFrameContext` or a `ReadyGpu` bag), keep `| null` only for the genuinely-absent-at-draw-time ones (some via a `timingService`-style null-object), let `destroy()` iterate the raw bag — done incrementally, respecting the teardown asymmetry, never a big-bang ready flag. - **Thumbnail quality (SDSS / DSS branches)** — the auto-fetched SDSS-cutout and CDS-DSS thumbnails still have the original quality issues: ranked fix options are mask, sky-sub, per-galaxy size, DESI source, brightness norm (see memory `project_thumbnail_quality`). The *famous-galaxy* branch is now fully addressed — procedural-disk fade-out, high-res LOD (#214), and thumbnail calibration + square deproject + disk-plane unification (#229/#234/#235/#240) all shipped — so this item is scoped to the non-curated SDSS/DSS path only. - **Supercluster/wall shape accuracy (focus mode)** — cluster-focus mode (PR #242) renders membership as a sphere of radius `apparentRadiusMpc ?? physicalRadiusMpc` centred on the catalog centroid. For superclusters/walls (MSCC) this is crude: the structure is a flattened sheet, so the sphere swallows foreground/background voids and clips the wall's arms (e.g. Hydra Wall reads ~847 galaxies at medium tier). No all-sky per-galaxy membership catalog exists to replace it — redMaPPer/WHL give cluster member galaxies but only in the SDSS footprint; Liivamägi+2012 gives galaxy→supercluster IDs but is also SDSS-limited and threshold-dependent. Investigate a better proxy: (a) **ellipsoid fit** from MSCC member-cluster positions (`memCl` column — data we already have); (b) **density-field membership** reusing the rhizome/MCPM cosmic-web field or DisPerSE filaments (all-sky, same method the literature uses). Option (a) is cheap and immediate; (b) is more principled and reuses existing plumbing. - **GLADE shell artifact at ~400 Mpc** — hard depth boundary created by Task 7 abs-mag filter; 3 fix options deferred 2026-05-04. See memory `project_glade_shell_artifact`. diff --git a/docs/superpowers/plans/2026-06-21-dissolve-render-frame-settings.md b/docs/superpowers/plans/2026-06-21-dissolve-render-frame-settings.md new file mode 100644 index 00000000..048c3391 --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-dissolve-render-frame-settings.md @@ -0,0 +1,282 @@ +# Dissolve `RenderFrameSettings`: passes read from `state` + `ctx` + +> **Spec:** [`docs/superpowers/specs/2026-06-21-dissolve-render-frame-settings-design.md`](../specs/2026-06-21-dissolve-render-frame-settings-design.md). This plan implements exactly that spec — read it first; it carries the rationale and the Definition of Done. + +## Goal + +Delete the per-frame `RenderFrameSettings` bag. Drop the `settings` parameter from `Pass.enabled` / `Pass.draw` and the four `encode*` functions. Every value re-sources from its real home: user settings off `state.settings.`, selection off `state.selection.select`, the two genuinely-derived values (`visibleSourceMask`, `focus`) off `ctx`, the two fade thresholds from the constants that already own them. Also delete the vestigial `catalogs` / `famousMeta` fields from `RenderFrameInput` + `PassDeps`. + +## Architecture + +The dissolution is **behaviour-neutral**: every byte delivered to every renderer is identical; only the delivery path changes. The hard constraint is that `Pass.enabled`/`draw` is a **shared signature** — it cannot change one-pass-at-a-time without breaking the registry loop and every other pass. So the work is ordered to make the signature change LAST and purely mechanical, with the full suite green at every task boundary: + +- **Phase 0 (Task 1)** — delete the dead `catalogs` / `famousMeta` fields. Independent; first because it's pure deletion and shrinks every fixture. +- **Phase 1 (Task 2)** — add the two derived values to `ReadyFrameContext` (`visibleSourceMask`, `focus`) and wire `runFrame` to populate them. Additive: `RenderFrameSettings` still carries them, nothing reads the new ctx fields yet. +- **Phase 2 (Tasks 3–10)** — flip every reader OFF the `settings` param onto `state` / `ctx` / constants, one small green task per consumer. The `settings` param **stays in the signature, unused**, so the suite stays green per task. Each task also rewrites that consumer's test to drive inputs via `state`/`ctx`. +- **Phase 3 (Task 11)** — drop the now-unused `settings` param from `Pass.enabled`/`draw`, the four `encode*` functions, and `RenderFrameInput`; delete `RenderFrameSettings.d.ts`; remove the `settings: { … }` literal from the `runFrame` assembly; update remaining test call sites. `npm run typecheck` is the safety net. + +`flowFieldPass` + `diskRadiusRingPass` already read `state.settings` directly (`flowFieldPass.ts:44,61`, `diskRadiusRingPass.ts:44-46`) and only have `_settings` placeholders — they need no Phase-2 change, only the Phase-3 signature drop. The four no-settings passes (`markerLinesPass`, `labelsPass`, `horizonShellPass`, `structureMarkersPass`) likewise carry only `_settings` placeholders — Phase-3 signature drop only. + +## Tech stack + +TypeScript + Vitest. No new dependencies. The on-disk binary format is untouched. No WGSL changes (the renderer call signatures are unchanged — only how the JS args are sourced). + +## Global Constraints + +- **Behaviour-neutral.** No settings-shape change, no new toggle, no render-order change. The existing pass `enabled`/`draw` tests and `renderFrame` ordering tests are the safety net — they change only in *how they supply inputs*, never in *what they assert about output*. +- **Green at every task boundary.** The shared `Pass` signature stays intact through Phase 2; only Phase 3 changes it. Run the relevant tests at the end of every task; commit only on green. +- **Conventions.** Didactic comments (explain *why*); `type` aliases not `interface`; `Vec2`/`Vec3` aliases not raw tuples; one-type-per-file in `src/@types/`; typed `vi.fn<() => void>()` not bare `vi.fn()`. Stage specific paths — never `git add -A`. Prettier only touched files. (Execution-time: implementers run bash sequentially and use Read/Grep, not sed/awk/grep.) +- **Field → new-source mapping** (used by the Phase-2 tasks): + +| `settings.` | New source | +|---|---| +| `pointSizePx` | `state.settings.galaxyCatalogs.sizePx` | +| `brightness` | `state.settings.galaxyCatalogs.brightness` | +| `selected` | `state.selection.select` | +| `visibleSourceMask` | `ctx.visibleSourceMask` | +| `highlightFallback` | `state.settings.galaxyCatalogs.highlightFallback` | +| `realOnlyMode` | `state.settings.galaxyCatalogs.realOnly` | +| `biasMode` | `state.settings.bias.mode` | +| `absMagLimit` | `state.settings.bias.absMagLimit` | +| `depthFadeEnabled` | `state.settings.galaxyCatalogs.depthFade` | +| `pxFadeStartPoints` / `pxFadeEndPoints` | `PROCEDURAL_DISK_FADE_START_PX` / `_END_PX` (import from `services/engine/subsystems/proceduralDiskSubsystem`, `:50-51`) | +| `focus` | `ctx.focus` | +| `exposure` | `state.settings.tonemap.exposure` | +| `toneMapCurve` | `state.settings.tonemap.curve` | +| `galaxyTexturesEnabled` | `state.settings.thumbnails.enabled` | +| `milkyWayEnabled` | `state.settings.milkyWay.enabled` | +| `filamentsEnabled` | `state.settings.filaments.enabled` | +| `filamentIntensity` | `state.settings.filaments.intensity` | +| `volumesEnabled` | `state.settings.volumes.enabled` | + +> **For agentic workers:** execute this plan with the `subagent-driven-development` workflow — a fresh implementer subagent per task, dispatched `run_in_background: true`. The main thread runs `npm test` / `npm run typecheck` and commits; implementers only edit. Tick each task's `- [ ]` to `- [x]` in the same response as the TaskUpdate. Front-load constraints in each dispatch (sequential bash, Read/Grep not sed, absolute worktree paths, typed `vi.fn`). Implementers: if a clean implementation is blocked, STOP and report — don't hack around it. + +--- + +## Phase 0 — delete dead fields + +### Task 1: Remove vestigial `catalogs` / `famousMeta` + +**Files:** `src/@types/engine/frame/RenderFrameInput.d.ts` (`:104-106`), `src/@types/engine/frame/PassDeps.d.ts` (`catalogs`/`famousMeta` fields + their imports), `src/services/engine/frame/renderFrame.ts` (`:117-128` deps assembly — drop `catalogs`, `famousMeta`), `src/services/engine/frame/runFrame.ts` (`:380-381` input assembly — drop `famousMeta`, `catalogs`), `tests/services/engine/frame/renderFrame.test.ts` (drop the `catalogs` / `famousMeta` keys from `makeInput`, `:373-374`, and the now-unused `catalogs` local at `:247` + its fixture-root mirror). + +**Why pure deletion:** no pass or `encode*` reads `deps.catalogs` / `deps.famousMeta`. The thumbnail subsystem — the field's claimed consumer per the stale `PassDeps` docblock — reads `state.data.galaxies.catalogs` / `.famousMeta` **directly** at `runFrame.ts:303-308`. No consumer is re-pointed. + +- [ ] Remove the two fields + their now-unused `GalaxyCatalog` / `SourceType` / `FamousMetaEntry` imports from `RenderFrameInput.d.ts` and `PassDeps.d.ts` (check which imports go unused after removal; `RenderFrameInput` keeps `GalaxyCatalog`/`SourceType` only if something else uses them — verify). +- [ ] Remove `catalogs` / `famousMeta` from the `deps` literal in `renderFrame.ts` and from the `renderFrame({ … })` call in `runFrame.ts`. +- [ ] Drop the fixture keys + unused `catalogs` local from `renderFrame.test.ts`. +- [ ] `npm run typecheck` clean; `npm test -- renderFrame` green. `grep -rn "catalogs\|famousMeta" src/@types/engine/frame/` shows neither. +- [ ] Commit (`git add` the specific paths). + +--- + +## Phase 1 — add the derived homes on `ctx` + +### Task 2: `ReadyFrameContext` gains `visibleSourceMask` + `focus` + +**Files:** `src/@types/engine/frame/ReadyFrameContext.d.ts` (modify), `src/services/engine/frame/frameContext.ts` (modify), `src/services/engine/frame/runFrame.ts` (modify), `tests/services/engine/frame/frameContext.test.ts` (modify). + +**Type additions** (join the genuinely-derived half, alongside `vp` / `drawCamPos` / `focusBlend`): + +```ts +// ReadyFrameContext.d.ts +/** Galaxy-catalog draw mask (deriveSourceMasks(state).draw), this frame. */ +visibleSourceMask: number; +/** Full cluster-focus uniform value (produceFocusUniforms, ticked once/frame). */ +focus: FocusUniformsValue; +``` + +Import `FocusUniformsValue` from `../../rendering/FocusUniformsValue`. + +**`deriveFrameContext` signature** — add `visibleSourceMask` as a new trailing arg: + +```ts +deriveFrameContext(state, canvas, pose, projection, visibleSourceMask: number): FrameContext +``` + +Set `visibleSourceMask` at construction (the ready-branch return literal, `frameContext.ts:152-164`). `focus` cannot be set here — `produceFocusUniforms` ticks the controller and must fire exactly once per frame in `runFrame`; seed `focus` to a placeholder in the return literal the same way `focusBlend: 0` is seeded (`:159`), with a didactic comment matching the existing `focusBlend` note (`:144-151`). Use the at-rest uniform value (a `blend: 0` shape) as the seed. + +**`runFrame` wiring:** +- Pass `masks.draw` into the `deriveFrameContext` call (`runFrame.ts:240`). +- Set `ctx.focus = focusUniforms` at the existing `ctx.focusBlend = focusUniforms.blend` line (`runFrame.ts:273`) — same site, no new mutation point, still exactly one `produceFocusUniforms` call. + +Nothing reads the new ctx fields yet; `RenderFrameSettings` still carries them. Suite stays green. + +- [ ] Add the two fields to `ReadyFrameContext.d.ts` with the `FocusUniformsValue` import. +- [ ] Add the `visibleSourceMask` arg to `deriveFrameContext`; set it at construction; seed `focus` (placeholder, mirroring `focusBlend`). +- [ ] Wire `runFrame`: `masks.draw` into the call; `ctx.focus = focusUniforms` at `:273`. +- [ ] In `frameContext.test.ts`, add a test `deriveFrameContext exposes visibleSourceMask and a seeded focus on the ready context` asserting `ctx.visibleSourceMask` equals the passed mask and `ctx.focus.blend === 0`. Update the existing `deriveFrameContext` call sites in that file to pass the new arg. +- [ ] `npm run typecheck` clean; `npm test -- frameContext runFrame` green. +- [ ] Commit. + +--- + +## Phase 2 — flip readers off the `settings` param + +> Each task rewrites ONE consumer to read from its new source per the mapping table, and rewrites THAT consumer's test to drive inputs via `state`/`ctx` instead of the settings bag. The `settings` param stays in the signature (unused → rename to `_settings` where a pass no longer reads it, or keep the name if Phase 3 will drop it cleanly). Suite green per task. + +### Task 3: `pointSpritesPass` rebuilds `PointDrawSettings` from `state` + `ctx` + selection + constants + +**Files:** `src/services/engine/frame/passes/pointSpritesPass.ts` (modify), `tests/services/engine/frame/passes/passes.test.ts` (modify — `pointSpritesPass.draw` describe block, `:420-448`). + +The heaviest consumer: every field of the `PointDrawSettings` record (`pointSpritesPass.ts:77-107`) currently reads `settings.`. Re-source each per the mapping table: +- `settings.selected` → `state.selection.select` (the galaxy-ref → `packSelection` translation at `:67-70` is unchanged, only its input). +- `pointSizePx`, `brightness`, `highlightFallback`, `realOnlyMode`, `biasMode`, `absMagLimit`, `depthFadeEnabled` → `state.settings.{galaxyCatalogs,bias}.<…>`. +- `visibleSourceMask` → `ctx.visibleSourceMask`. +- `pxFadeStart` / `pxFadeEnd` → import `PROCEDURAL_DISK_FADE_START_PX` / `_END_PX`. +- `focusBindGroup` (`state.gpu.focusUniform!.bindGroup`) and `fadeOpacityOf` are already off `state` — unchanged. + +Update the module-header "What it reads" block (`:29-32`) to name the real sources instead of "the whole `RenderFrameSettings` block". + +**Test:** the two `pointSpritesPass.draw` tests (`packs (source, index)…`, `translates null selection…`) currently set selection via `makeSettings({ selected })`. Drive selection via `state.selection.select` instead (extend `STATE_STUB` or pass an override-state). The `STATE_STUB` (`:137-149`) needs `selection`, `settings.{galaxyCatalogs,bias}`, and `ctx.visibleSourceMask` populated enough that the draw runs. Assertions on `drawSettings.selectedPacked` are unchanged. + +- [ ] Re-source every `PointDrawSettings` field per the mapping; import the two fade constants. +- [ ] Update the module-header read-list comment. +- [ ] Rewrite the two draw tests to drive selection via `state.selection.select`; extend the state stub with the needed settings/selection/ctx fields. +- [ ] `npm test -- passes` green. +- [ ] Commit. + +### Task 4: `milkyWayPass.enabled` reads `state.settings.milkyWay.enabled` + +**Files:** `src/services/engine/frame/passes/milkyWayPass.ts` (modify), `tests/services/engine/frame/passes/passes.test.ts` (modify — `milkyWayPass.enabled` block, `:323-363`). + +`settings.milkyWayEnabled` → `state.settings.milkyWay.enabled` (`:61`). `draw` already ignores settings (`_settings`). Update the `### What it reads` note (`:35`). + +- [ ] Re-source the gate; update the comment. +- [ ] Rewrite the three `milkyWayPass.enabled` tests + the `milkyWayPass.draw` test to set `state.settings.milkyWay.enabled` (extend the state stub) instead of `makeSettings({ milkyWayEnabled })`. +- [ ] `npm test -- passes` green. +- [ ] Commit. + +### Task 5: `filamentsPass` reads `state.settings.filaments.{enabled,intensity}` + +**Files:** `src/services/engine/frame/passes/filamentsPass.ts` (modify), `tests/services/engine/frame/passes/filamentsPass.test.ts` (modify), and the `filamentsPass` blocks in `tests/services/engine/frame/passes/passes.test.ts` (`:262-321`). + +`enabled`: `settings.filamentsEnabled` → `state.settings.filaments.enabled` (`:74`). `draw`: `settings.filamentIntensity` → `state.settings.filaments.intensity` (`:96`). + +- [ ] Re-source both reads. +- [ ] Rewrite `filamentsPass.test.ts` (drop its local `makeSettings`, drive `enabled`/`intensity` via state) and the `filamentsPass.enabled`/`.draw` blocks in `passes.test.ts`. The `forwards correct args` assertion on `args[4] === 0.7` now comes from `state.settings.filaments.intensity = 0.7`. +- [ ] `npm test -- filamentsPass passes` green. +- [ ] Commit. + +### Task 6: `texturedDisksPass` + `proceduralDisksPass` read `state.settings.thumbnails.enabled` + +**Files:** `src/services/engine/frame/passes/texturedDisksPass.ts` (`:20`), `src/services/engine/frame/passes/proceduralDisksPass.ts` (`:24`), `tests/services/engine/frame/passes/texturedDisksPass.test.ts`, `tests/services/engine/frame/passes/proceduralDisksPass.test.ts`, and the `proceduralDisksPass.enabled` block in `passes.test.ts` (`:219-255`). + +Both `enabled` gates: `settings.galaxyTexturesEnabled` → `state.settings.thumbnails.enabled`. + +- [ ] Re-source both gates. +- [ ] Rewrite the two per-pass tests + the `passes.test.ts` block to set `state.settings.thumbnails.enabled` (extend each test's state stub) instead of `makeSettings({ galaxyTexturesEnabled })`. +- [ ] `npm test -- texturedDisksPass proceduralDisksPass passes` green. +- [ ] Commit. + +### Task 7: `volumeUpsamplePass.enabled` reads `state.settings.volumes.enabled` + +**Files:** `src/services/engine/frame/passes/volumeUpsamplePass.ts` (`:50`), `tests/services/engine/frame/passes/volumeUpsamplePass.test.ts`. + +`settings.volumesEnabled` → `state.settings.volumes.enabled`. (`draw` already `_settings`.) + +- [ ] Re-source the gate. +- [ ] Rewrite the test to set `state.settings.volumes.enabled` instead of `makeSettings({ volumesEnabled })`. +- [ ] `npm test -- volumeUpsamplePass` green. +- [ ] Commit. + +### Task 8: `selectionRingPass.draw` reads `state.settings.galaxyCatalogs.sizePx` + +**Files:** `src/services/engine/frame/passes/selectionRingPass.ts` (`:62`), `tests/services/engine/frame/passes/selectionRingPass.test.ts`. + +`settings.pointSizePx` → `state.settings.galaxyCatalogs.sizePx`. (`enabled` already `_settings`.) + +- [ ] Re-source the `selectionRingRadiusPx` arg. +- [ ] Rewrite the draw tests to set `state.settings.galaxyCatalogs.sizePx = 4` instead of `makeSettings({ pointSizePx: 4 })`. +- [ ] `npm test -- selectionRingPass` green. +- [ ] Commit. + +### Task 9: `encodeVolumePrepass` reads `state.settings.volumes.enabled` + +**Files:** `src/services/engine/frame/encodeVolumePrepass.ts` (`:62` — `settings.volumesEnabled` → `state.settings.volumes.enabled`). + +Update the gating-rationale comment (`:26`) that says "Master gate: `settings.volumesEnabled`". The `settings` param stays for now (Phase 3 drops it). + +- [ ] Re-source the master gate; update the comment. +- [ ] No dedicated test file — covered by `renderFrame.test.ts`'s volume pre-pass tests (`:542-591`) and `encodeVolumes.test.ts`. Run `npm test -- renderFrame encodeVolumes` green. +- [ ] Commit. + +### Task 10: `renderFrame` reads `ctx.focus` + `state.settings.tonemap.{exposure,curve}` + +**Files:** `src/services/engine/frame/renderFrame.ts` (modify), `tests/services/engine/frame/renderFrame.test.ts` + `tests/services/engine/frame/renderFrame.timing.test.ts` (modify). + +- `state.gpu.focusUniform?.write(settings.focus)` (`:133`) → `…write(ctx.focus)`. +- `postProcess.draw(…, settings.exposure, settings.toneMapCurve, …)` (`:164,178`) → `ctx.…`? No — exposure/curve are user settings: `state.settings.tonemap.exposure` / `…curve`. + +The four `encode*` calls still receive `settings` here (Phase 3 drops the param). This task only re-sources the three reads `renderFrame` itself makes; it does not yet remove `settings` from the input bag. + +**Test:** `renderFrame.test.ts`'s `calls postProcess.draw … with exposure, curve…` (`:480-497`) asserts `args[2]/[3]` equal `fx.input.settings.exposure/toneMapCurve`. Move those values onto `state.settings.tonemap` in the fixture and assert against those. The fixture must populate `ctx.focus` (seed a `blend:0` value) and `state.settings.tonemap`. Mirror in `renderFrame.timing.test.ts`. + +- [ ] Re-source the three reads in `renderFrame.ts`. +- [ ] Add `state.settings.tonemap` + `ctx.focus` to the `renderFrame.test.ts` / `renderFrame.timing.test.ts` fixtures; repoint the exposure/curve assertions. +- [ ] `npm test -- renderFrame` green. +- [ ] Commit. + +--- + +## Phase 3 — drop the `settings` param + delete the type + +### Task 11: Remove `settings` from `Pass`, the four `encode*`, and `RenderFrameInput`; delete `RenderFrameSettings.d.ts` + +**Files:** `src/@types/engine/frame/Pass.d.ts`, `src/@types/engine/frame/RenderFrameInput.d.ts`, `src/services/engine/frame/{encodeHdrSingle,encodeHdrSplit,encodeUiOverlay,encodeVolumePrepass}.ts`, `src/services/engine/frame/renderFrame.ts`, `src/services/engine/frame/runFrame.ts`, all 13 pass files (drop the `settings`/`_settings` param), `src/@types/engine/frame/RenderFrameSettings.d.ts` (delete), and every test call site that still passes a settings arg (`passes.test.ts`, `renderFrame.test.ts`, `renderFrame.timing.test.ts`, and each per-pass test's `makeSettings`/`SETTINGS`). + +**New `Pass` shape** (`Pass.d.ts`): + +```ts +export type Pass = { + readonly name: string; + enabled(state: EngineState, ctx: ReadyFrameContext): boolean; + draw(pass: GPURenderPassEncoder, ctx: ReadyFrameContext, state: EngineState, deps: PassDeps): void; +}; +``` + +Remove the `RenderFrameSettings` import + the argument-order docblock mentions of `settings` (`:14,52,70-71,85,94`). + +**`encode*` signatures:** drop the `settings: RenderFrameSettings` param and its import from all four. Update the loop calls `pass.enabled(state, ctx)` / `pass.draw(hdrPass, ctx, state, deps)` (`encodeHdrSingle.ts:95,97`; `encodeHdrSplit.ts:101,117`; `encodeUiOverlay.ts:76,97`). + +**`renderFrame.ts`:** drop `settings` from the input destructure (`:106`) and from the four `encode*` call sites (`:158,177,179` + the `encodeVolumePrepass` arg is inside the encoders now). Drop `settings` from `RenderFrameInput` destructure. + +**`RenderFrameInput.d.ts`:** remove the `settings: RenderFrameSettings` field (`:101-102`) + the import (`:33`). + +**`runFrame.ts`:** delete the entire `settings: { … }` literal (`:352-379`) from the `renderFrame({ … })` call. The `PROCEDURAL_DISK_FADE_*` imports (`:66-69`) are now only used inside `pointSpritesPass` — check whether `runFrame` still references them (it won't); remove the now-dead import if unused. + +**13 pass files:** drop the trailing `settings`/`_settings` param from `enabled`/`draw` signatures across all passes (point-sprites, procedural-disks, textured-disks, milky-way, filaments, flow, volume-upsample, horizon-shell, structure-markers, marker-lines, labels, selection-ring, disk-radius-ring). + +**Tests:** delete every `makeSettings` / `SETTINGS` builder and the `RenderFrameSettings` import from each test file; update every `pass.enabled(state, ctx)` / `pass.draw(pass, ctx, state, deps)` call to drop the settings arg; drop `settings` from the `renderFrame` input fixtures. + +`npm run typecheck` is the safety net — a missed call site is a tsc error, not a silent pass. + +- [ ] Drop the `settings` param from `Pass.d.ts` + remove the import + docblock mentions. +- [ ] Drop `settings` from the four `encode*` functions and their `pass.enabled`/`pass.draw` calls. +- [ ] Drop `settings` from `renderFrame.ts` (destructure + `encode*` calls) and `RenderFrameInput.d.ts` (field + import). +- [ ] Delete the `settings: { … }` literal from `runFrame.ts`; remove the now-dead `PROCEDURAL_DISK_FADE_*` import if unused there. +- [ ] Drop the `settings`/`_settings` param from all 13 pass files. +- [ ] Delete `src/@types/engine/frame/RenderFrameSettings.d.ts`. +- [ ] Update every test call site to drop the settings arg; remove the `makeSettings`/`SETTINGS` builders + `RenderFrameSettings` imports. +- [ ] `npm run typecheck` clean (both tsconfigs); `npm test` full suite green, no pass-count reduction, output pristine. +- [ ] `grep -rn RenderFrameSettings src tests` is empty; `grep -rn "settings" src/services/engine/frame/passes/` shows no `Pass`-param references (only `state.settings.…` reads). +- [ ] Commit. + +--- + +## Definition of Done + +Per the spec's DoD: + +- `RenderFrameSettings.d.ts` deleted; `grep -rn RenderFrameSettings src tests` empty. +- `Pass.enabled`/`draw` + the four `encode*` carry no `settings` param; no pass references a `settings` argument. +- `RenderFrameInput` + `PassDeps` carry no `catalogs` / `famousMeta`; `grep -rn "catalogs\|famousMeta" src/@types/engine/frame/` shows neither. +- `npm run typecheck` clean (src + tools). +- `npm test` green — full suite, no pass-count reduction, output pristine. +- Manual visual parity on the running dev server: points, thumbnails, filaments, Milky Way, volume, flow, selection ring, labels, pick-buffer debug overlay all render as before. + +## Plan-style self-review + +- Contract code only (type signatures + the new `Pass`/`ctx` shapes + test names); no function bodies. ✓ +- Existing code cited by `file.ts:line`, not pasted. ✓ +- One independently-testable deliverable per task; each ends green + committed. ✓ +- The shared-signature constraint is solved by ordering (signature change last, mechanical), stated in Architecture. ✓ diff --git a/docs/superpowers/specs/2026-06-21-dissolve-render-frame-settings-design.md b/docs/superpowers/specs/2026-06-21-dissolve-render-frame-settings-design.md new file mode 100644 index 00000000..ed8bd4c8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-dissolve-render-frame-settings-design.md @@ -0,0 +1,158 @@ +# Dissolve `RenderFrameSettings`: passes read from `state` + `ctx` (design) + +> **Status:** approved design, awaiting implementation plan. **Why this exists:** +> `RenderFrameSettings` is a per-frame struct assembled in `runFrame.ts:352-379` and +> threaded through every render pass as a `settings` parameter. It is named for +> settings but carries **four different provenances** — renamed user settings, the +> selection slice, two per-frame-derived values, and two module constants — fused +> into one bag. That is the value/place + provenance knot +> [`simplicity.md`](../conventions/simplicity.md) §3/§5 exists to remove: a reader +> can't tell from `settings.x` whether `x` is a user toggle, derived this frame, or a +> compile-time constant, and the settings paths are re-spelled here a second time +> (the selectors are the first). It is also a **half-finished migration**: +> `RenderFrameInput.d.ts:9-17` records that D.2 plumbed `state: EngineState` into +> `Pass.draw` *specifically* so passes could "read engine-side data ... without a +> `RenderFrameSettings` field for every consumer" — then the passes never moved off +> the bag. This finishes that migration and deletes the bag. + +## The decision in one line + +Delete `RenderFrameSettings` and drop the `settings` parameter from `Pass.enabled` / +`Pass.draw` and the four `encode*` functions. Each value flows from its **real +source**: user settings read directly off `state.settings.`, selection off +`state.selection.select`, the two genuinely per-frame-derived values +(`visibleSourceMask`, `focus`) off `ctx`, and the two fade thresholds from the +module constant that already owns them. + +### Why dissolve rather than tidy + +The bag's 16 fields sort cleanly by where they are *actually* reachable, and 14 of 16 +are already in scope at every consumer: + +| Provenance | Count | Real home | +|---|---|---| +| User setting (`pointSizePx`, `biasMode`, `exposure`, …) | 12 | `state.settings.` — passes already receive `state` | +| Selection (`selected`) | 1 | `state.selection.select` | +| Compile-time constant (`pxFadeStartPoints/EndPoints`) | 2 | `PROCEDURAL_DISK_FADE_START_PX` / `_END_PX` (their existing owner) | +| Per-frame derived (`visibleSourceMask`, `focus`) | 2 | `ctx` (the per-frame derived snapshot) | + +Only the last row needs a new wire. Everything else is *already present* at the +consumer and merely re-bagged. Two passes prove the target shape already works: +`flowFieldPass` reads `state.settings.flow` directly (`flowFieldPass.ts:44,61`) and +`diskRadiusRingPass` reads `state.settings.debug` + `state.selection` directly +(`diskRadiusRingPass.ts:44-46`) — neither consults the bag. + +### Why the two derived values belong on `ctx` + +`ReadyFrameContext` is *defined* as "the per-frame derived snapshot" and its docblock +invites exactly this: "Adding a new derived per-frame quantity ... becomes a one-line +addition here." (`ctx` also re-exposes four `state.gpu.*`/`state.subsystems.*` handles +in non-null narrowed form — a deliberate, documented TS-ergonomics trade-off, +`frameContext.ts:57-72` — but the two new fields join the *genuinely-derived* half, +alongside `vp`/`drawCamPos`/`focusBlend`, which live nowhere but `ctx`.) Both values +are produced in `runFrame` around `ctx` construction: + +- **`visibleSourceMask`** ← `deriveSourceMasks(state).draw`, computed at + `runFrame.ts:99` — *before* `ctx` is built (`:240`). So it is passed **into** + `deriveFrameContext` and set at construction (no post-hoc mutation). +- **`focus`** (`FocusUniformsValue`) ← `produceFocusUniforms(nowMs)` at + `runFrame.ts:272` — *after* `ctx` is built, and `ctx.focusBlend` is **already** set + by mutation at `:273` from that same value. The full uniform is set at the **same + line**, so no new mutation point is introduced — `ctx.focus = focusUniforms` + alongside the existing `ctx.focusBlend = focusUniforms.blend`. + +This keeps the once-per-frame tick guarantee for the focus controller (still exactly +one `produceFocusUniforms` call) and the single-source-of-truth derivation for the +masks (still `deriveSourceMasks`, no mirror). + +## Scope + +**In scope:** + +- **Delete** `src/@types/engine/frame/RenderFrameSettings.d.ts`. +- **`Pass` contract** (`Pass.d.ts`): `enabled(state, ctx)` and + `draw(pass, ctx, state, deps)` — the `settings` parameter removed. +- **`ReadyFrameContext`** (`ReadyFrameContext.d.ts`) gains `visibleSourceMask: number` + and `focus: FocusUniformsValue`. `deriveFrameContext` takes `visibleSourceMask` as + an argument; `runFrame` sets `ctx.focus` at the existing `:273` site. +- **`RenderFrameInput`** drops its `settings: RenderFrameSettings` field. `focus` is + no longer a separate concern (rides `ctx`); `exposure`/`toneMapCurve` are read off + `state.settings.tonemap` inside `renderFrame`. +- **The four `encode*` functions** (`encodeHdrSingle`, `encodeHdrSplit`, + `encodeUiOverlay`, `encodeVolumePrepass`) drop the `settings` param and read off + `state` directly (`encodeVolumePrepass` reads `state.settings.volumes.enabled`). +- **14 pass implementations** updated to the new signature; the ~7 that read settings + re-source per the field-mapping table (see the implementation plan's surface map, + derived from this spec). +- **`pointSpritesPass`** — the heaviest consumer — rebuilds its `PointDrawSettings` + object from `state.settings` + `ctx` + `state.selection` + the imported constants. +- **`runFrame.ts`** assembly: the `settings: { … }` literal is removed; `masks.draw` + flows into `deriveFrameContext`; `focusUniforms` is set on `ctx`. +- **Dead `catalogs` / `famousMeta` fields removed** from `RenderFrameInput.d.ts` + (`:104-106`), `PassDeps.d.ts` (`catalogs`, `famousMeta`), and the `renderFrame` + `deps` assembly (`:125-126`) + `runFrame` input assembly (`:380-381`). These are + **vestigial**: no pass or `encode*` reads `deps.catalogs` / `deps.famousMeta`, and + the thumbnail subsystem — the field's claimed consumer per the stale `PassDeps` + docblock — actually reads `state.data.galaxies.catalogs` / `.famousMeta` **directly** + at `runFrame.ts:303-308`. Pure deletion; no consumer is re-pointed. +- **Tests** updated to the new signatures (no settings bag constructed): the three + high-effort fixtures (`renderFrame.test.ts`, `renderFrame.timing.test.ts`, + `passes.test.ts`) plus the per-pass tests; the fixtures that set `catalogs` / + `famousMeta` on the render input drop those keys. + +**Out of scope (explicitly):** + +- **The slice-typed-selector / `useSettings` unification.** Whether engine reads go + through `selectX(state.settings)` vs raw `state.settings.` is an orthogonal + decision; this spec uses **raw paths**, consistent with the ~47 existing engine + read sites and the two passes that already do so. The selector question can be + taken or left later without touching this work. +- **The `labels.declutterEnabled` feature.** That is the *next* PR and the first + feature built on the clean read surface; it is not part of the dissolution. + +## Contract shapes + +```ts +// Pass.d.ts — settings param removed +export type Pass = { + readonly name: string; + enabled(state: EngineState, ctx: ReadyFrameContext): boolean; + draw(pass: GPURenderPassEncoder, ctx: ReadyFrameContext, state: EngineState, deps: PassDeps): void; +}; + +// ReadyFrameContext.d.ts — two derived values added +export type ReadyFrameContext = { + // … existing fields … + /** Galaxy-catalog draw mask (deriveSourceMasks(state).draw), this frame. */ + visibleSourceMask: number; + /** Full cluster-focus uniform value (produceFocusUniforms, ticked once/frame). */ + focus: FocusUniformsValue; +}; +``` + +The RenderFrameSettings-field → new-source mapping (per consumer, with line numbers) +lives in the implementation plan's surface table, not here — it is mechanical and +would rot if pasted twice. + +## Behaviour neutrality + +This is a **pure refactor**: every value delivered to every renderer is byte-identical +to today's, only its delivery path changes. There is no settings shape change, no new +user-facing toggle, no render-order change. The existing pass `enabled`/`draw` tests +and the `renderFrame` golden/baseline tests are the safety net; they change only in +*how they supply inputs* (off `state`/`ctx` instead of a settings bag), not in *what +they assert about output*. + +## Definition of Done + +- `RenderFrameSettings.d.ts` deleted; `grep -rn RenderFrameSettings src tests` is + empty. +- `Pass.enabled`/`draw` and the four `encode*` functions carry no `settings` + parameter; no pass references a `settings` argument. +- `RenderFrameInput` and `PassDeps` carry no `catalogs` / `famousMeta` field; + `grep -rn "catalogs\|famousMeta" src/@types/engine/frame/` shows neither. +- `npm run typecheck` clean (both `src` and `tools` tsconfigs). +- `npm test` green — full suite, no reduction in pass count, output pristine. +- Manual visual parity check on the running dev server: points, thumbnails, + filaments, Milky Way, volume, flow, selection ring, labels, and the pick-buffer + debug overlay all render as before (behaviour-neutral). From e66f9bef9ef1a512f056c6cfde17a3f5d7a93360 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 21 Jun 2026 02:01:51 +0200 Subject: [PATCH 02/14] refactor(frame): delete vestigial catalogs/famousMeta from render input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No pass or encode* read deps.catalogs / deps.famousMeta — the thumbnail subsystem reads state.data.galaxies.{catalogs,famousMeta} directly. Pure deletion of the dead fields from RenderFrameInput, PassDeps, the renderFrame deps assembly, the runFrame input assembly, and the test fixtures that fed them (plus the now-orphaned makeCloud helpers + imports). Phase 0 of dissolving RenderFrameSettings. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/@types/engine/frame/PassDeps.d.ts | 13 --------- src/@types/engine/frame/RenderFrameInput.d.ts | 7 ----- src/services/engine/frame/renderFrame.ts | 4 --- src/services/engine/frame/runFrame.ts | 2 -- .../engine/frame/passes/passes.test.ts | 2 -- .../services/engine/frame/renderFrame.test.ts | 29 ------------------- .../engine/frame/renderFrame.timing.test.ts | 29 ------------------- tests/visual/renderFrameSplitBaseline.test.ts | 29 ------------------- 8 files changed, 115 deletions(-) diff --git a/src/@types/engine/frame/PassDeps.d.ts b/src/@types/engine/frame/PassDeps.d.ts index 543d76dc..b47a5341 100644 --- a/src/@types/engine/frame/PassDeps.d.ts +++ b/src/@types/engine/frame/PassDeps.d.ts @@ -22,9 +22,6 @@ import type { HorizonShellRenderer } from '../../rendering/HorizonShellRenderer' import type { FilamentRenderer } from '../../rendering/FilamentRenderer'; import type { VolumeFieldRenderer } from '../../rendering/VolumeFieldRenderer'; import type { FlowFieldRenderer } from '../../rendering/FlowFieldRenderer'; -import type { FamousMetaEntry } from '../../loading/FamousMetaEntry'; -import type { GalaxyCatalog } from '../../data/GalaxyCatalog'; -import type { SourceType } from '../../data/SourceType'; export type PassDeps = { /** Atlas-bound 3D-oriented disk renderer for large galaxy thumbnails. */ @@ -62,16 +59,6 @@ export type PassDeps = { milkyWayRenderer: MilkyWayRenderer; /** Observable-universe horizon shell renderer. */ horizonShellRenderer: HorizonShellRenderer; - /** - * Live source-catalog map. Forwarded into `thumbnails.runFrame` - * which iterates it back-to-front for the painter's-algorithm - * sort. Lives on `deps` (not `ctx`) because it isn't a derived - * snapshot — it's a long-lived reference whose contents change - * across frames. - */ - catalogs: ReadonlyMap; - /** Famous-galaxy metadata — also forwarded into thumbnails. */ - famousMeta: readonly FamousMetaEntry[]; /** * Animation time in seconds for the Milky Way impostor's * shader-clock uniform. Already scaled by the engine's chosen diff --git a/src/@types/engine/frame/RenderFrameInput.d.ts b/src/@types/engine/frame/RenderFrameInput.d.ts index 47f9d7be..ca13b796 100644 --- a/src/@types/engine/frame/RenderFrameInput.d.ts +++ b/src/@types/engine/frame/RenderFrameInput.d.ts @@ -18,8 +18,6 @@ */ import type { EngineState } from '../state/EngineState'; -import type { GalaxyCatalog } from '../../data/GalaxyCatalog'; -import type { SourceType } from '../../data/SourceType'; import type { TexturedDiskRenderer } from '../../rendering/TexturedDiskRenderer'; import type { ProceduralDiskRenderer } from '../../rendering/ProceduralDiskRenderer'; import type { MilkyWayRenderer } from '../../rendering/MilkyWayRenderer'; @@ -27,7 +25,6 @@ import type { HorizonShellRenderer } from '../../rendering/HorizonShellRenderer' import type { FilamentRenderer } from '../../rendering/FilamentRenderer'; import type { VolumeFieldRenderer } from '../../rendering/VolumeFieldRenderer'; import type { FlowFieldRenderer } from '../../rendering/FlowFieldRenderer'; -import type { FamousMetaEntry } from '../../loading/FamousMetaEntry'; import type { GpuTimingService } from '../../gpu/timing/GpuTimingService'; import type { ReadyFrameContext } from './ReadyFrameContext'; import type { RenderFrameSettings } from './RenderFrameSettings'; @@ -101,10 +98,6 @@ export type RenderFrameInput = { // ── Settings ────────────────────────────────────────────────────────── settings: RenderFrameSettings; - // ── Forwarded to the thumbnail subsystem ────────────────────────────── - famousMeta: readonly FamousMetaEntry[]; - catalogs: ReadonlyMap; - /** * Per-pass GPU timing service (always non-null; check `.enabled` * before doing timing work). When enabled, `renderFrame` takes diff --git a/src/services/engine/frame/renderFrame.ts b/src/services/engine/frame/renderFrame.ts index c87c6159..6309530c 100644 --- a/src/services/engine/frame/renderFrame.ts +++ b/src/services/engine/frame/renderFrame.ts @@ -104,8 +104,6 @@ export function renderFrame(input: RenderFrameInput): void { texturedDiskRenderer, proceduralDiskRenderer, settings, - famousMeta, - catalogs, timingService, } = input; @@ -122,8 +120,6 @@ export function renderFrame(input: RenderFrameInput): void { flowFieldRenderer, milkyWayRenderer, horizonShellRenderer, - catalogs, - famousMeta, milkyWayITimeSec, }; diff --git a/src/services/engine/frame/runFrame.ts b/src/services/engine/frame/runFrame.ts index e88b9564..50ffd61d 100644 --- a/src/services/engine/frame/runFrame.ts +++ b/src/services/engine/frame/runFrame.ts @@ -377,8 +377,6 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): filamentIntensity: state.settings.filaments.intensity, volumesEnabled: state.settings.volumes.enabled, }, - famousMeta: state.data.galaxies.famousMeta, - catalogs: state.data.galaxies.catalogs, timingService: deps.timingService, }); diff --git a/tests/services/engine/frame/passes/passes.test.ts b/tests/services/engine/frame/passes/passes.test.ts index c5c676c3..cb4b629f 100644 --- a/tests/services/engine/frame/passes/passes.test.ts +++ b/tests/services/engine/frame/passes/passes.test.ts @@ -123,8 +123,6 @@ function makeDeps(overrides: Partial = {}): PassDeps { flowFieldRenderer: null, milkyWayRenderer: { draw: vi.fn() } as any, horizonShellRenderer: { draw: vi.fn() } as any, - catalogs: new Map(), - famousMeta: [], milkyWayITimeSec: 0, ...overrides, }; diff --git a/tests/services/engine/frame/renderFrame.test.ts b/tests/services/engine/frame/renderFrame.test.ts index ead95c6c..2ac33b99 100644 --- a/tests/services/engine/frame/renderFrame.test.ts +++ b/tests/services/engine/frame/renderFrame.test.ts @@ -24,7 +24,6 @@ import { ToneMapCurve } from '../../../../src/data/toneMapCurve'; import { renderFrame } from '../../../../src/services/engine/frame/renderFrame'; import { createDisabledGpuTimingService } from '../../../../src/services/gpu/timing/gpuTimingService'; import type { OrbitCamera } from '../../../../src/@types/camera/OrbitCamera'; -import type { GalaxyCatalog } from '../../../../src/@types/data/galaxyCatalog/GalaxyCatalog'; import type { mat4 } from 'gl-matrix'; import type { SelectionRef } from '../../../../src/@types/engine/SelectionRef'; @@ -197,30 +196,6 @@ function makeCam(): OrbitCamera { } as unknown as OrbitCamera; } -function makeCloud(count = 1): GalaxyCatalog { - const fill = (v: number) => { - const a = new Float32Array(count); - a.fill(v); - return a; - }; - return { - count, - objIDs: new BigUint64Array(count), - positions: new Float32Array(count * 3), - magU: fill(20), - magG: fill(20), - magR: fill(20), - magI: fill(20), - magZ: fill(20), - axisRatio: fill(1), - positionAngleDeg: fill(0), - diameterKpc: fill(50), - classByte: new Uint8Array(count), - parentSurveyByte: new Uint8Array(count), - spectroscopicZ: new Float32Array(count), - }; -} - /** Build a complete RenderFrameInput fixture with sensible defaults. */ function makeInput( overrides: { settings?: Partial; disabledPasses?: Record } = {}, @@ -244,7 +219,6 @@ function makeInput( const texturedDiskRenderer = makeMockTexturedDiskRenderer(); const proceduralDiskRenderer = makeMockProceduralDiskRenderer(); const cam = makeCam(); - const catalogs = new Map([[Source.SDSS, makeCloud(1)]]); const settings = { pointSizePx: 2.5, @@ -310,7 +284,6 @@ function makeInput( texturedDiskRenderer, proceduralDiskRenderer, cam, - catalogs, // Mirror these on the fixture root so tests read them directly // instead of reaching into `input.ctx.*` for every assertion. canvasWidth, @@ -370,8 +343,6 @@ function makeInput( texturedDiskRenderer, proceduralDiskRenderer, settings, - famousMeta: [], - catalogs, // Disabled stub (`service.enabled === false`) → renderFrame takes // the single-pass branch. Active-mode behaviour lives in // `renderFrame.timing.test.ts`. diff --git a/tests/services/engine/frame/renderFrame.timing.test.ts b/tests/services/engine/frame/renderFrame.timing.test.ts index 12edb72a..0f9bc4ef 100644 --- a/tests/services/engine/frame/renderFrame.timing.test.ts +++ b/tests/services/engine/frame/renderFrame.timing.test.ts @@ -35,14 +35,12 @@ import { describe, it, expect, vi } from 'vitest'; import type { mat4 } from 'gl-matrix'; -import { Source } from '../../../../src/data/sources'; import { BiasMode } from '../../../../src/data/galaxyCatalog/biasMode'; import { ToneMapCurve } from '../../../../src/data/toneMapCurve'; import { createDisabledGpuTimingService } from '../../../../src/services/gpu/timing/gpuTimingService'; import { renderFrame } from '../../../../src/services/engine/frame/renderFrame'; import type { RenderFrameInput } from '../../../../src/@types/engine/frame/RenderFrameInput'; import type { OrbitCamera } from '../../../../src/@types/camera/OrbitCamera'; -import type { GalaxyCatalog } from '../../../../src/@types/data/galaxyCatalog/GalaxyCatalog'; import type { GpuTimingService } from '../../../../src/@types/gpu/timing/GpuTimingService'; import type { TimingSlotName } from '../../../../src/@types/gpu/timing/TimingSlotName'; import type { SourceType } from '../../../../src/@types/data/SourceType'; @@ -145,30 +143,6 @@ function makeCam(): OrbitCamera { } as unknown as OrbitCamera; } -function makeCloud(count: number): GalaxyCatalog { - const fill = (v: number): Float32Array => { - const a = new Float32Array(count); - a.fill(v); - return a; - }; - return { - count, - objIDs: new BigUint64Array(count), - positions: new Float32Array(count * 3), - magU: fill(20), - magG: fill(20), - magR: fill(20), - magI: fill(20), - magZ: fill(20), - axisRatio: fill(1), - positionAngleDeg: fill(0), - diameterKpc: fill(50), - classByte: new Uint8Array(count), - parentSurveyByte: new Uint8Array(count), - spectroscopicZ: new Float32Array(count), - }; -} - /** * Build a minimal RenderFrameInput where only point-sprites and * milky-way passes are enabled. Every other optional renderer / slot @@ -192,7 +166,6 @@ function makeMinimalInputWithTiming(timingService: GpuTimingService): { const postProcess = makePostProcess(); const cam = makeCam(); - const catalogs = new Map([[Source.SDSS, makeCloud(1)]]); const canvasWidth = 1280; const canvasHeight = 720; const viewProj = new Float32Array(16) as unknown as mat4; @@ -273,8 +246,6 @@ function makeMinimalInputWithTiming(timingService: GpuTimingService): { texturedDiskRenderer: texturedDiskRenderer as never, proceduralDiskRenderer: proceduralDiskRenderer as never, settings: settings as never, - famousMeta: [], - catalogs, timingService, }; diff --git a/tests/visual/renderFrameSplitBaseline.test.ts b/tests/visual/renderFrameSplitBaseline.test.ts index a9c40526..486ec58b 100644 --- a/tests/visual/renderFrameSplitBaseline.test.ts +++ b/tests/visual/renderFrameSplitBaseline.test.ts @@ -55,13 +55,11 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { Source } from '../../src/data/sources'; import { BiasMode } from '../../src/data/galaxyCatalog/biasMode'; import { ToneMapCurve } from '../../src/data/toneMapCurve'; import { renderFrame } from '../../src/services/engine/frame/renderFrame'; import { createDisabledGpuTimingService } from '../../src/services/gpu/timing/gpuTimingService'; import type { OrbitCamera } from '../../src/@types/camera/OrbitCamera'; -import type { GalaxyCatalog } from '../../src/@types/data/galaxyCatalog/GalaxyCatalog'; import type { mat4 } from 'gl-matrix'; import type { SourceType } from '../../src/@types/data/SourceType'; @@ -214,30 +212,6 @@ function makeCam(): OrbitCamera { } as unknown as OrbitCamera; } -function makeCloud(count: number): GalaxyCatalog { - const fill = (v: number): Float32Array => { - const a = new Float32Array(count); - a.fill(v); - return a; - }; - return { - count, - objIDs: new BigUint64Array(count), - positions: new Float32Array(count * 3), - magU: fill(20), - magG: fill(20), - magR: fill(20), - magI: fill(20), - magZ: fill(20), - axisRatio: fill(1), - positionAngleDeg: fill(0), - diameterKpc: fill(50), - classByte: new Uint8Array(count), - parentSurveyByte: new Uint8Array(count), - spectroscopicZ: new Float32Array(count), - }; -} - // ── Test ─────────────────────────────────────────────────────────────────── describe('renderFrame visual baseline', () => { @@ -288,7 +262,6 @@ describe('renderFrame visual baseline', () => { const postProcess = makePostProcess(records); const cam = makeCam(); - const catalogs = new Map([[Source.SDSS, makeCloud(1)]]); const canvasWidth = 1280; const canvasHeight = 720; const viewProj = new Float32Array(16) as unknown as mat4; @@ -400,8 +373,6 @@ describe('renderFrame visual baseline', () => { texturedDiskRenderer: texturedDiskRenderer as never, proceduralDiskRenderer: proceduralDiskRenderer as never, settings: settings as never, - famousMeta: [], - catalogs, // Disabled stub forces the single-pass path. The split-pass // (timing-on) shape is exercised in `renderFrame.timing.test.ts`. timingService: createDisabledGpuTimingService(), From 04a35dbf61d633455b723d882de70e88198ca51c Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 21 Jun 2026 02:08:49 +0200 Subject: [PATCH 03/14] refactor(frame): add visibleSourceMask + focus to ReadyFrameContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive Phase 1 of dissolving RenderFrameSettings: the two genuinely per-frame-derived values gain a home on ctx. visibleSourceMask is set at construction (passed into deriveFrameContext from masks.draw); focus is seeded to the at-rest ZERO_FOCUS sentinel and overwritten by runFrame at the existing ctx.focusBlend site — still exactly one produceFocusUniforms tick per frame. Nothing reads the new fields yet; RenderFrameSettings still carries them. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../engine/frame/ReadyFrameContext.d.ts | 5 ++++ src/services/engine/frame/frameContext.ts | 13 +++++++++ src/services/engine/frame/runFrame.ts | 3 +- .../subsystems/structureFocusSubsystem.ts | 2 +- .../engine/frame/encodeVolumes.test.ts | 2 ++ .../engine/frame/frameContext.test.ts | 28 +++++++++++++++---- .../engine/frame/passes/filamentsPass.test.ts | 2 ++ .../engine/frame/passes/flowFieldPass.test.ts | 2 ++ .../engine/frame/passes/passes.test.ts | 2 ++ .../frame/passes/proceduralDisksPass.test.ts | 2 ++ .../frame/passes/selectionRingPass.test.ts | 2 ++ .../frame/passes/texturedDisksPass.test.ts | 2 ++ .../frame/passes/volumeUpsamplePass.test.ts | 2 ++ .../services/engine/frame/renderFrame.test.ts | 2 ++ 14 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/@types/engine/frame/ReadyFrameContext.d.ts b/src/@types/engine/frame/ReadyFrameContext.d.ts index 921e7b2a..b6ca47a3 100644 --- a/src/@types/engine/frame/ReadyFrameContext.d.ts +++ b/src/@types/engine/frame/ReadyFrameContext.d.ts @@ -54,6 +54,7 @@ import type { PointRenderer } from '../../rendering/PointRenderer'; import type { PostProcess } from '../../rendering/PostProcess'; import type { VolumeOffscreen } from '../../rendering/VolumeOffscreen'; import type { TexturedDiskSubsystem } from '../subsystems/TexturedDiskSubsystem'; +import type { FocusUniformsValue } from '../../rendering/FocusUniformsValue'; /** The ready case: every per-frame derived value is non-null. */ export type ReadyFrameContext = { @@ -70,6 +71,10 @@ export type ReadyFrameContext = { drawPxPerRad: number; /** Structure-focus recession blend 0→1, from structureFocus.produceFocusUniforms (ticked once/frame). */ focusBlend: number; + /** Galaxy-catalog draw mask (deriveSourceMasks(state).draw), this frame. */ + visibleSourceMask: number; + /** Full cluster-focus uniform value (produceFocusUniforms, ticked once/frame). */ + focus: FocusUniformsValue; /** * Non-null GPU + subsystem handles, narrowed across the bootstrap * gate so consumers don't have to re-check `state.gpu.*` / diff --git a/src/services/engine/frame/frameContext.ts b/src/services/engine/frame/frameContext.ts index 05770a8a..1497d009 100644 --- a/src/services/engine/frame/frameContext.ts +++ b/src/services/engine/frame/frameContext.ts @@ -90,6 +90,7 @@ import type { CameraProjection } from '../../../@types/camera/CameraProjection'; import { computeViewProj } from '../../../utils/camera/computeViewProj'; import { isEngineReady } from '../helpers/engineReady'; import { assembleOrbitCamera } from '../camera/assembleOrbitCamera'; +import { ZERO_FOCUS } from '../subsystems/structureFocusSubsystem'; /** * Derive the per-frame context from an already-produced pose and projection. @@ -114,6 +115,7 @@ export function deriveFrameContext( canvas: HTMLCanvasElement, pose: CameraPose, projection: CameraProjection, + visibleSourceMask: number, ): FrameContext { // The bootstrap gate. Every site that asks 'is the engine bootstrapped?' — // per-frame, slot-commit, public-handle — funnels through the one @@ -149,6 +151,15 @@ export function deriveFrameContext( // and double-ticking would double-advance the ramp). So the value is a // placeholder until `runFrame` fills it in, before any consumer (label // director, marker upload, render settings) reads it. + // + // `focus` is seeded to ZERO_FOCUS (blend=0, the at-rest sentinel) and overwritten + // by `runFrame` with this frame's real FocusUniformsValue the moment the ready + // gate passes. Same reason as `focusBlend`: `produceFocusUniforms` ticks the + // focus fade controller — a once-per-frame side effect — so it can't run here. + // ZERO_FOCUS is a module-private constant in structureFocusSubsystem; importing + // it avoids duplicating the literal and keeps a single source of truth for the + // at-rest defaults (blend=0, apparentRadiusMpc=1 so smoothstep edges are + // never degenerate, everything else a don't-care). return { isReady: true, cam, @@ -157,6 +168,8 @@ export function deriveFrameContext( drawCamPos, drawPxPerRad, focusBlend: 0, + visibleSourceMask, + focus: ZERO_FOCUS, renderer, postProcess, volumeOffscreen, diff --git a/src/services/engine/frame/runFrame.ts b/src/services/engine/frame/runFrame.ts index 50ffd61d..31f2805a 100644 --- a/src/services/engine/frame/runFrame.ts +++ b/src/services/engine/frame/runFrame.ts @@ -237,7 +237,7 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): // for downstream `renderFrame()`. The 'not ready' branch is the brief window // before the first cloud lands; once cam + GPU handles populate together, // it's never taken again. - const ctx = deriveFrameContext(state, deps.canvas, renderPose, state.cameraRuntime.projection); + const ctx = deriveFrameContext(state, deps.canvas, renderPose, state.cameraRuntime.projection, masks.draw); if (!ctx.isReady) { // Essential wake: bootstrap populates cam/GPU handles without waking any // channel — keep re-polling until the gate opens. @@ -271,6 +271,7 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): state.subsystems.structureFocus.update(focusedStructure, nowMs); const focusUniforms = state.subsystems.structureFocus.produceFocusUniforms(nowMs); ctx.focusBlend = focusUniforms.blend; + ctx.focus = focusUniforms; // ── Per-frame impostor planners ─────────────────────────────────────────── // diff --git a/src/services/engine/subsystems/structureFocusSubsystem.ts b/src/services/engine/subsystems/structureFocusSubsystem.ts index 8ad8ebf3..c34895e4 100644 --- a/src/services/engine/subsystems/structureFocusSubsystem.ts +++ b/src/services/engine/subsystems/structureFocusSubsystem.ts @@ -47,7 +47,7 @@ export const FOCUS_FADE_DURATION_MS = 400; * never equal — a degenerate edge0 == edge1 would risk a NaN that mix() * propagates even at blend 0. The values are otherwise don't-cares here. */ -const ZERO_FOCUS: FocusUniformsValue = { +export const ZERO_FOCUS: FocusUniformsValue = { center: [0, 0, 0], apparentRadiusMpc: 1, physicalRadiusMpc: 0, diff --git a/tests/services/engine/frame/encodeVolumes.test.ts b/tests/services/engine/frame/encodeVolumes.test.ts index ac8d9a8e..49bc6b0d 100644 --- a/tests/services/engine/frame/encodeVolumes.test.ts +++ b/tests/services/engine/frame/encodeVolumes.test.ts @@ -49,6 +49,8 @@ function makeCtx(): ReadyFrameContext { drawCamPos: [0, 0, 5] as Readonly<[number, number, number]>, drawPxPerRad: 720, focusBlend: 0, + visibleSourceMask: 0xffffffff, + focus: { center: [0, 0, 0] as Readonly<[number, number, number]>, apparentRadiusMpc: 1, physicalRadiusMpc: 0, blend: 0 }, renderer: {} as never, postProcess: { view: {} as GPUTextureView, diff --git a/tests/services/engine/frame/frameContext.test.ts b/tests/services/engine/frame/frameContext.test.ts index 5296857a..72b1004c 100644 --- a/tests/services/engine/frame/frameContext.test.ts +++ b/tests/services/engine/frame/frameContext.test.ts @@ -86,6 +86,7 @@ describe('deriveFrameContext — not-ready branch', () => { makeCanvas(), RESTING_POSE, PROJECTION, + 0xffffffff, ); expect(ctx.isReady).toBe(false); }); @@ -96,6 +97,7 @@ describe('deriveFrameContext — not-ready branch', () => { makeCanvas(), RESTING_POSE, PROJECTION, + 0xffffffff, ); expect(ctx.isReady).toBe(false); }); @@ -106,6 +108,7 @@ describe('deriveFrameContext — not-ready branch', () => { makeCanvas(), RESTING_POSE, PROJECTION, + 0xffffffff, ); expect(ctx.isReady).toBe(false); }); @@ -116,6 +119,7 @@ describe('deriveFrameContext — not-ready branch', () => { makeCanvas(), RESTING_POSE, PROJECTION, + 0xffffffff, ); expect(ctx.isReady).toBe(false); }); @@ -126,6 +130,7 @@ describe('deriveFrameContext — not-ready branch', () => { makeCanvas(), RESTING_POSE, PROJECTION, + 0xffffffff, ); expect(ctx.isReady).toBe(false); }); @@ -135,7 +140,7 @@ describe('deriveFrameContext — ready branch', () => { it('assembles ctx.cam from pose + projection (not from state.cam)', () => { const pose: CameraPose = { target: [1, 2, 3], yaw: 0.5, pitch: 0.1, distance: 50 }; const projection: CameraProjection = { fovYRad: 1.2, aspect: 2, near: 0.01, far: 5000 }; - const ctx = deriveFrameContext(makeState(), makeCanvas(), pose, projection); + const ctx = deriveFrameContext(makeState(), makeCanvas(), pose, projection, 0xffffffff); expect(ctx.isReady).toBe(true); if (!ctx.isReady) return; // ctx.cam must reflect the pose and projection. @@ -150,7 +155,7 @@ describe('deriveFrameContext — ready branch', () => { // The projection Resource is the source of fovYRad; state.cam.fovYRad is // only the drag register bootstrap value and is never read for rendering. const projection: CameraProjection = { fovYRad: 0.9, aspect: 1, near: 0.1, far: 1000 }; - const ctx = deriveFrameContext(makeState(), makeCanvas(), RESTING_POSE, projection); + const ctx = deriveFrameContext(makeState(), makeCanvas(), RESTING_POSE, projection, 0xffffffff); expect(ctx.isReady).toBe(true); if (!ctx.isReady) return; expect(ctx.cam.fovYRad).toBe(0.9); @@ -159,7 +164,7 @@ describe('deriveFrameContext — ready branch', () => { it('drawPxPerRad uses projection.fovYRad', () => { const projection: CameraProjection = { fovYRad: 1, aspect: 16 / 9, near: 0.1, far: 10000 }; const canvas = makeCanvas(1920, 1080); - const ctx = deriveFrameContext(makeState(), canvas, RESTING_POSE, projection); + const ctx = deriveFrameContext(makeState(), canvas, RESTING_POSE, projection, 0xffffffff); expect(ctx.isReady).toBe(true); if (!ctx.isReady) return; // pxPerRad = height / (2 * tan(fovY / 2)) @@ -169,7 +174,7 @@ describe('deriveFrameContext — ready branch', () => { it('ctx.vp matches computeViewProj(assembleOrbitCamera(pose, projection))', () => { const pose: CameraPose = { target: [0, 0, 0], yaw: 0.3, pitch: 0.1, distance: 100 }; - const ctx = deriveFrameContext(makeState(), makeCanvas(), pose, PROJECTION); + const ctx = deriveFrameContext(makeState(), makeCanvas(), pose, PROJECTION, 0xffffffff); expect(ctx.isReady).toBe(true); if (!ctx.isReady) return; const expected = computeViewProj(assembleOrbitCamera(pose, PROJECTION)); @@ -177,7 +182,7 @@ describe('deriveFrameContext — ready branch', () => { }); it('populates canvasSize from canvas dimensions', () => { - const ctx = deriveFrameContext(makeState(), makeCanvas(800, 600), RESTING_POSE, PROJECTION); + const ctx = deriveFrameContext(makeState(), makeCanvas(800, 600), RESTING_POSE, PROJECTION, 0xffffffff); expect(ctx.isReady).toBe(true); if (!ctx.isReady) return; expect(ctx.canvasSize).toEqual({ width: 800, height: 600 }); @@ -192,6 +197,7 @@ describe('deriveFrameContext — ready branch', () => { makeCanvas(), RESTING_POSE, PROJECTION, + 0xffffffff, ); expect(ctx.isReady).toBe(true); if (!ctx.isReady) return; @@ -207,11 +213,21 @@ describe('deriveFrameContext — ready branch', () => { makeCanvas(), RESTING_POSE, PROJECTION, + 0xffffffff, ); expect(ctx.isReady).toBe(true); if (!ctx.isReady) return; expect(ctx.volumeOffscreen).toBe(volumeOffscreen); }); + + it('exposes visibleSourceMask and a seeded focus on the ready context', () => { + const mask = 0b1011; + const ctx = deriveFrameContext(makeState(), makeCanvas(), RESTING_POSE, PROJECTION, mask); + expect(ctx.isReady).toBe(true); + if (!ctx.isReady) return; + expect(ctx.visibleSourceMask).toBe(mask); + expect(ctx.focus.blend).toBe(0); + }); }); describe('deriveFrameContext — type narrowing', () => { @@ -221,6 +237,7 @@ describe('deriveFrameContext — type narrowing', () => { makeCanvas(), RESTING_POSE, PROJECTION, + 0xffffffff, ); if (ctx.isReady) { // If FrameContext were `{ cam: OrbitCamera | null }` instead of a @@ -236,6 +253,7 @@ describe('deriveFrameContext — type narrowing', () => { makeCanvas(), RESTING_POSE, PROJECTION, + 0xffffffff, ); if (ctx.isReady) { // @ts-expect-error — drawCamPos is Readonly<[...]>; index assignment is forbidden. diff --git a/tests/services/engine/frame/passes/filamentsPass.test.ts b/tests/services/engine/frame/passes/filamentsPass.test.ts index dac028bd..0622b4fa 100644 --- a/tests/services/engine/frame/passes/filamentsPass.test.ts +++ b/tests/services/engine/frame/passes/filamentsPass.test.ts @@ -37,6 +37,8 @@ function makeCtx(focusBlend: number): ReadyFrameContext { drawCamPos: [0, 0, 5] as Readonly<[number, number, number]>, drawPxPerRad: 720, focusBlend, + visibleSourceMask: 0xffffffff, + focus: { center: [0, 0, 0] as Readonly<[number, number, number]>, apparentRadiusMpc: 1, physicalRadiusMpc: 0, blend: focusBlend }, renderer: {} as never, postProcess: { view: {} as GPUTextureView, diff --git a/tests/services/engine/frame/passes/flowFieldPass.test.ts b/tests/services/engine/frame/passes/flowFieldPass.test.ts index 12e1f55f..399dfaa8 100644 --- a/tests/services/engine/frame/passes/flowFieldPass.test.ts +++ b/tests/services/engine/frame/passes/flowFieldPass.test.ts @@ -24,6 +24,8 @@ function makeCtx(): ReadyFrameContext { drawCamPos: [0, 0, 5] as Readonly<[number, number, number]>, drawPxPerRad: 720, focusBlend: 0, + visibleSourceMask: 0xffffffff, + focus: { center: [0, 0, 0] as Readonly<[number, number, number]>, apparentRadiusMpc: 1, physicalRadiusMpc: 0, blend: 0 }, renderer: {} as never, postProcess: {} as never, volumeOffscreen: {} as never, diff --git a/tests/services/engine/frame/passes/passes.test.ts b/tests/services/engine/frame/passes/passes.test.ts index cb4b629f..1148c4e9 100644 --- a/tests/services/engine/frame/passes/passes.test.ts +++ b/tests/services/engine/frame/passes/passes.test.ts @@ -81,6 +81,8 @@ function makeCtx(overrides: Partial = {}): ReadyFrameContext drawCamPos: [0, 0, 5] as Readonly<[number, number, number]>, drawPxPerRad: 720 / (2 * Math.tan(cam.fovYRad / 2)), focusBlend: 0, + visibleSourceMask: 0xffffffff, + focus: { center: [0, 0, 0] as Readonly<[number, number, number]>, apparentRadiusMpc: 1, physicalRadiusMpc: 0, blend: 0 }, renderer, postProcess, volumeOffscreen, diff --git a/tests/services/engine/frame/passes/proceduralDisksPass.test.ts b/tests/services/engine/frame/passes/proceduralDisksPass.test.ts index 35bff746..15c4cf06 100644 --- a/tests/services/engine/frame/passes/proceduralDisksPass.test.ts +++ b/tests/services/engine/frame/passes/proceduralDisksPass.test.ts @@ -32,6 +32,8 @@ function makeCtx(overrides: Partial = {}): ReadyFrameContext drawCamPos: [0, 0, 5] as Readonly<[number, number, number]>, drawPxPerRad: 720 / (2 * Math.tan(cam.fovYRad / 2)), focusBlend: 0, + visibleSourceMask: 0xffffffff, + focus: { center: [0, 0, 0] as Readonly<[number, number, number]>, apparentRadiusMpc: 1, physicalRadiusMpc: 0, blend: 0 }, renderer: { draw: vi.fn() } as any, postProcess: { view: {} as GPUTextureView, diff --git a/tests/services/engine/frame/passes/selectionRingPass.test.ts b/tests/services/engine/frame/passes/selectionRingPass.test.ts index cc2e79a4..a3f9cee7 100644 --- a/tests/services/engine/frame/passes/selectionRingPass.test.ts +++ b/tests/services/engine/frame/passes/selectionRingPass.test.ts @@ -22,6 +22,8 @@ function makeCtx(): ReadyFrameContext { drawCamPos: [0, 0, 0] as Readonly<[number, number, number]>, drawPxPerRad: 720, focusBlend: 0, + visibleSourceMask: 0xffffffff, + focus: { center: [0, 0, 0] as Readonly<[number, number, number]>, apparentRadiusMpc: 1, physicalRadiusMpc: 0, blend: 0 }, renderer: {} as never, postProcess: {} as never, volumeOffscreen: {} as never, diff --git a/tests/services/engine/frame/passes/texturedDisksPass.test.ts b/tests/services/engine/frame/passes/texturedDisksPass.test.ts index fe2214ba..ac10a5b7 100644 --- a/tests/services/engine/frame/passes/texturedDisksPass.test.ts +++ b/tests/services/engine/frame/passes/texturedDisksPass.test.ts @@ -31,6 +31,8 @@ function makeCtx(): ReadyFrameContext { drawCamPos: [0, 0, 5] as Readonly<[number, number, number]>, drawPxPerRad: 720 / (2 * Math.tan(cam.fovYRad / 2)), focusBlend: 0, + visibleSourceMask: 0xffffffff, + focus: { center: [0, 0, 0] as Readonly<[number, number, number]>, apparentRadiusMpc: 1, physicalRadiusMpc: 0, blend: 0 }, renderer: { draw: vi.fn() } as any, postProcess: { view: {} as GPUTextureView, diff --git a/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts b/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts index ba4e0d31..772c7be8 100644 --- a/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts +++ b/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts @@ -44,6 +44,8 @@ function makeCtx(offscreenView: GPUTextureView = {} as GPUTextureView): ReadyFra drawCamPos: [0, 0, 5] as Readonly<[number, number, number]>, drawPxPerRad: 720, focusBlend: 0, + visibleSourceMask: 0xffffffff, + focus: { center: [0, 0, 0] as Readonly<[number, number, number]>, apparentRadiusMpc: 1, physicalRadiusMpc: 0, blend: 0 }, renderer: {} as never, postProcess: { view: {} as GPUTextureView, diff --git a/tests/services/engine/frame/renderFrame.test.ts b/tests/services/engine/frame/renderFrame.test.ts index 2ac33b99..a5034407 100644 --- a/tests/services/engine/frame/renderFrame.test.ts +++ b/tests/services/engine/frame/renderFrame.test.ts @@ -262,6 +262,8 @@ function makeInput( >, drawPxPerRad: canvasHeight / (2 * Math.tan(cam.fovYRad / 2)), focusBlend: 0, + visibleSourceMask: 0xffffffff, + focus: { center: [0, 0, 0] as Readonly<[number, number, number]>, apparentRadiusMpc: 1, physicalRadiusMpc: 0, blend: 0 }, renderer: pointRenderer, postProcess, volumeOffscreen, From 2a583e79d763c6b52c321b202a0349d70795443c Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 21 Jun 2026 02:13:26 +0200 Subject: [PATCH 04/14] refactor(frame): pointSpritesPass reads from state + ctx, not the settings bag The heaviest RenderFrameSettings consumer now sources every PointDrawSettings field from its real home: state.settings.galaxyCatalogs/bias, ctx.visibleSourceMask, state.selection.select, and the PROCEDURAL_DISK_FADE_* constants. The settings param stays in the draw signature (renamed _settings) until the Phase 3 sweep. Byte-identical render. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../engine/frame/passes/pointSpritesPass.ts | 47 +++++++++++------- .../engine/frame/passes/passes.test.ts | 48 +++++++++++++++---- 2 files changed, 68 insertions(+), 27 deletions(-) diff --git a/src/services/engine/frame/passes/pointSpritesPass.ts b/src/services/engine/frame/passes/pointSpritesPass.ts index 41db834b..bfc718fb 100644 --- a/src/services/engine/frame/passes/pointSpritesPass.ts +++ b/src/services/engine/frame/passes/pointSpritesPass.ts @@ -19,17 +19,23 @@ * * ### What it reads * - * - `ctx.renderer` (the bootstrap-narrowed `PointRenderer`) + * - `ctx.renderer` — the bootstrap-narrowed `PointRenderer` * - `ctx.vp` — view-projection matrix * - `ctx.canvasSize` — backing-store viewport dimensions * - `ctx.drawCamPos` — camera position, fed to the shader's parallax * + brightness terms * - `ctx.drawPxPerRad` — radian→pixel scale for apparent-size * computation - * - The whole `RenderFrameSettings` block — every field of the - * `PointDrawSettings` object passed to `pointRenderer.draw` - * originates either there or in `ctx`. See `renderFrame.ts`'s - * `RenderFrameSettings` shape for the per-field rationale. + * - `ctx.visibleSourceMask` — bitmask of currently-visible source codes + * - `state.settings.galaxyCatalogs.{sizePx,brightness,highlightFallback,realOnly,depthFade}` + * — point-billboard appearance knobs + * - `state.settings.bias.{mode,absMagLimit}` — luminosity-bias correction + * - `state.selection.select` — structured selection ref translated to the + * packed u32 the shader compares per vertex + * - `PROCEDURAL_DISK_FADE_START_PX` / `PROCEDURAL_DISK_FADE_END_PX` — + * module constants from `proceduralDiskSubsystem`; kept in one place so + * the fade-in band (disks pass) and fade-out band (points pass) can't + * drift apart and recreate the double-bright donut artefact * * ### Selection-packed encoding * @@ -46,6 +52,10 @@ import type { Pass } from '../../../../@types/engine/frame/Pass'; import { packSelection, SELECTION_NONE_SENTINEL } from '../../../../data/selectionEncoding'; import { galaxyCatalogIdOf } from '../../../../utils/galaxyCatalogIdOf'; +import { + PROCEDURAL_DISK_FADE_START_PX, + PROCEDURAL_DISK_FADE_END_PX, +} from '../../subsystems/proceduralDiskSubsystem'; export const pointSpritesPass: Pass = { name: 'point-sprites', @@ -56,7 +66,7 @@ export const pointSpritesPass: Pass = { return true; }, - draw(pass, ctx, state, settings, _deps) { + draw(pass, ctx, state, _settings, _deps) { const { renderer, vp, canvasSize, drawCamPos, drawPxPerRad } = ctx; const { width, height } = canvasSize; @@ -64,9 +74,10 @@ export const pointSpritesPass: Pass = { // against per-vertex `(sourceCode << 27u) | instance_index`. // Structure targets don't light up galaxy halos, so they map to the // "nothing selected" sentinel. + const selected = state.selection.select; const selectedPacked = - settings.selected !== null && settings.selected.type === 'galaxyCatalog' - ? packSelection(settings.selected.source, settings.selected.index) + selected !== null && selected.type === 'galaxyCatalog' + ? packSelection(selected.source, selected.index) : SELECTION_NONE_SENTINEL; // Capture the fade registry + timestamp once so the per-source @@ -75,23 +86,23 @@ export const pointSpritesPass: Pass = { const fades = state.subsystems.fades; renderer.draw(pass, vp, [width, height], { - pointSizePx: settings.pointSizePx, - brightness: settings.brightness, + pointSizePx: state.settings.galaxyCatalogs.sizePx, + brightness: state.settings.galaxyCatalogs.brightness, selectedPacked, - visibleSourceMask: settings.visibleSourceMask, + visibleSourceMask: ctx.visibleSourceMask, camPosWorld: drawCamPos, pxPerRad: drawPxPerRad, - highlightFallback: settings.highlightFallback, - realOnlyMode: settings.realOnlyMode, - biasMode: settings.biasMode, - absMagLimit: settings.absMagLimit, - depthFadeEnabled: settings.depthFadeEnabled, + highlightFallback: state.settings.galaxyCatalogs.highlightFallback, + realOnlyMode: state.settings.galaxyCatalogs.realOnly, + biasMode: state.settings.bias.mode, + absMagLimit: state.settings.bias.absMagLimit, + depthFadeEnabled: state.settings.galaxyCatalogs.depthFade, // The points-pass fragment fades alpha to zero across the same // apparent-pixel-size band the procedural-disk pass fades IN over. // Both thresholds come from one source of truth so they can't drift // apart and re-introduce the double-bright donut artefact. - pxFadeStart: settings.pxFadeStartPoints, - pxFadeEnd: settings.pxFadeEndPoints, + pxFadeStart: PROCEDURAL_DISK_FADE_START_PX, + pxFadeEnd: PROCEDURAL_DISK_FADE_END_PX, // Shared cluster-focus bind group (@group(3)). The engine owns the // single focus buffer (written once per frame in renderFrame); we // bind its group. At rest (blend 0) the shader multiplier is 1.0. diff --git a/tests/services/engine/frame/passes/passes.test.ts b/tests/services/engine/frame/passes/passes.test.ts index 1148c4e9..e077d3b4 100644 --- a/tests/services/engine/frame/passes/passes.test.ts +++ b/tests/services/engine/frame/passes/passes.test.ts @@ -417,18 +417,41 @@ describe('horizonShellPass.draw', () => { }); }); +// Minimal settings shape for the pointSpritesPass.draw tests — only +// the fields the pass now reads from `state.settings`. +const POINT_SPRITES_SETTINGS_STUB = { + galaxyCatalogs: { + sizePx: 2.5, + brightness: 1.0, + highlightFallback: true, + realOnly: false, + depthFade: true, + }, + bias: { + mode: BiasMode.None, + absMagLimit: -19, + }, +} as unknown as EngineState['settings']; + describe('pointSpritesPass.draw', () => { it('packs (source, index) into the selectedPacked u32', () => { const ctx = makeCtx(); - const settings = makeSettings({ - selected: { - type: 'galaxyCatalog', - source: Source.SDSS, - index: 42, - } as SelectionRef, - }); + // Selection is sourced from state.selection.select, not makeSettings. + const stateWithSelection = { + ...STATE_STUB, + selection: { + select: { + type: 'galaxyCatalog', + source: Source.SDSS, + index: 42, + } as SelectionRef, + hover: null, + focus: null, + }, + settings: POINT_SPRITES_SETTINGS_STUB, + } as unknown as EngineState; const deps = makeDeps(); - pointSpritesPass.draw(PASS_STUB, ctx, STATE_STUB, settings, deps); + pointSpritesPass.draw(PASS_STUB, ctx, stateWithSelection, makeSettings(), deps); const drawSpy = ctx.renderer.draw as ReturnType; expect(drawSpy).toHaveBeenCalledTimes(1); // Selection lives on arg[3].selectedPacked (the PointDrawSettings @@ -440,7 +463,14 @@ describe('pointSpritesPass.draw', () => { it('translates null selection to the 0xFFFFFFFF sentinel', () => { const ctx = makeCtx(); - pointSpritesPass.draw(PASS_STUB, ctx, STATE_STUB, makeSettings({ selected: null }), makeDeps()); + // Null selection via state.selection.select; settings shape satisfies + // the pass's direct reads from state.settings. + const stateNullSelection = { + ...STATE_STUB, + selection: { select: null, hover: null, focus: null }, + settings: POINT_SPRITES_SETTINGS_STUB, + } as unknown as EngineState; + pointSpritesPass.draw(PASS_STUB, ctx, stateNullSelection, makeSettings(), makeDeps()); const drawSpy = ctx.renderer.draw as ReturnType; const drawSettings = drawSpy.mock.calls[0]![3] as Record; expect(drawSettings.selectedPacked).toBe(0xffffffff >>> 0); From 8331455c4cd3f41aa803f05ca3f9e0f2441bab59 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 21 Jun 2026 02:15:47 +0200 Subject: [PATCH 05/14] refactor(frame): milkyWayPass.enabled reads state.settings.milkyWay.enabled Re-sources the Milky-Way gate off the settings bag onto its real home. The settings param stays in the signature (renamed _settings) until the Phase 3 sweep. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../engine/frame/passes/milkyWayPass.ts | 10 +++--- .../engine/frame/passes/passes.test.ts | 35 ++++++++++++------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/services/engine/frame/passes/milkyWayPass.ts b/src/services/engine/frame/passes/milkyWayPass.ts index 9098bd5e..5e75a9e7 100644 --- a/src/services/engine/frame/passes/milkyWayPass.ts +++ b/src/services/engine/frame/passes/milkyWayPass.ts @@ -14,7 +14,7 @@ * * Two gates, both in `enabled`: * - * 1. `settings.milkyWayEnabled` — user toggle. + * 1. `state.settings.milkyWay.enabled` — user toggle. * 2. `milkyWayFadeAlpha(camDist) > 0` — camera-distance fade band * defined in `utils/math/milkyWayFadeAlpha.ts` (full strength inside * 10 Mpc, smoothstep out to 0 at 50 Mpc). @@ -32,7 +32,7 @@ * - `deps.milkyWayRenderer` * - `deps.milkyWayITimeSec` — animation clock for the raymarcher * - `ctx.vp`, `ctx.canvasSize`, `ctx.drawCamPos` - * - `settings.milkyWayEnabled` (via the gate) + * - `state.settings.milkyWay.enabled` (user toggle, via the gate) * * ### Why drawn LAST inside the HDR pass * @@ -51,14 +51,14 @@ import { MILKY_WAY_CENTER_WORLD } from '../../../../data/milkyWay/galacticCenter export const milkyWayPass: Pass = { name: 'milky-way', - enabled(state, ctx, settings) { - // Settings boolean is the user's intent; opacityOf > 0 keeps the + enabled(state, ctx, _settings) { + // State boolean is the user's intent; opacityOf > 0 keeps the // pass alive through the ~100 ms toggle fade-out tail. The // distance-based milkyWayFadeAlpha still gates separately — if // the camera is too far away from the Milky Way to render it, // skip even when the toggle is on. const togglePart = - settings.milkyWayEnabled || + state.settings.milkyWay.enabled || state.subsystems.fades.opacityOf({ kind: 'milkyWay' }, performance.now()) > 0; if (!togglePart) return false; const camDistMpc = Math.hypot(ctx.drawCamPos[0], ctx.drawCamPos[1], ctx.drawCamPos[2]); diff --git a/tests/services/engine/frame/passes/passes.test.ts b/tests/services/engine/frame/passes/passes.test.ts index e077d3b4..ba367dd9 100644 --- a/tests/services/engine/frame/passes/passes.test.ts +++ b/tests/services/engine/frame/passes/passes.test.ts @@ -321,31 +321,40 @@ describe('filamentsPass.draw', () => { }); describe('milkyWayPass.enabled', () => { - it('returns true when milkyWayEnabled is true and camera is inside the fade band', () => { + it('returns true when milkyWay.enabled is true and camera is inside the fade band', () => { // Default makeCtx() puts the camera at 5 Mpc, inside the full-alpha // (≤10 Mpc) regime. Both gates pass. + const stateOn = { + ...STATE_STUB, + settings: { milkyWay: { enabled: true } }, + } as unknown as EngineState; expect( - milkyWayPass.enabled(STATE_STUB, makeCtx(), makeSettings({ milkyWayEnabled: true })), + milkyWayPass.enabled(stateOn, makeCtx(), makeSettings()), ).toBe(true); }); - it('returns false when milkyWayEnabled is false AND fade opacity is 0', () => { - // STATE_STUB's fades.opacityOf returns 1 by default — override to 0 - // so the gate doesn't keep the pass alive through a fade-out tail. - const stateZeroFade = { + it('returns false when milkyWay.enabled is false AND fade opacity is 0', () => { + // fades.opacityOf returns 0 so the gate doesn't keep the pass alive + // through a fade-out tail; toggle is also off — both conditions false. + const stateOffZeroFade = { subsystems: { fades: { opacityOf: () => 0, isAnyAnimating: () => false } }, + settings: { milkyWay: { enabled: false } }, } as unknown as EngineState; expect( - milkyWayPass.enabled(stateZeroFade, makeCtx(), makeSettings({ milkyWayEnabled: false })), + milkyWayPass.enabled(stateOffZeroFade, makeCtx(), makeSettings()), ).toBe(false); }); - it('returns true when milkyWayEnabled is false BUT fade opacity > 0 (fade-out tail still drawing)', () => { + it('returns true when milkyWay.enabled is false BUT fade opacity > 0 (fade-out tail still drawing)', () => { // opacityOf = 1 simulates a toggle fade-out still in flight, and the // distance-based fadeAlpha also passes (camera near origin), so the // gate's second condition is non-zero — the pass renders. + const stateOffFading = { + ...STATE_STUB, + settings: { milkyWay: { enabled: false } }, + } as unknown as EngineState; expect( - milkyWayPass.enabled(STATE_STUB, makeCtx(), makeSettings({ milkyWayEnabled: false })), + milkyWayPass.enabled(stateOffFading, makeCtx(), makeSettings()), ).toBe(true); }); @@ -353,12 +362,14 @@ describe('milkyWayPass.enabled', () => { // 1000 Mpc — well past FADE_OUTER_MPC (50 Mpc). Gating in `enabled` // (not just `draw`) skips the empty beginRenderPass + // timestamp-write on the split-encoder path. + const stateOn = { + ...STATE_STUB, + settings: { milkyWay: { enabled: true } }, + } as unknown as EngineState; const ctx = makeCtx({ drawCamPos: [1000, 0, 0] as Readonly<[number, number, number]>, }); - expect(milkyWayPass.enabled(STATE_STUB, ctx, makeSettings({ milkyWayEnabled: true }))).toBe( - false, - ); + expect(milkyWayPass.enabled(stateOn, ctx, makeSettings())).toBe(false); }); }); From ceba09bf85287b1ef00f5987b3e4ce6dad8a7326 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 21 Jun 2026 02:19:17 +0200 Subject: [PATCH 06/14] refactor(frame): filamentsPass reads state.settings.filaments.{enabled,intensity} Re-sources both the enabled gate and the draw intensity off the settings bag. The settings param stays in both signatures (renamed _settings) until the Phase 3 sweep. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../engine/frame/passes/filamentsPass.ts | 18 +++++--- .../engine/frame/passes/filamentsPass.test.ts | 36 ++++++++++------ .../engine/frame/passes/passes.test.ts | 43 +++++++++++++------ 3 files changed, 64 insertions(+), 33 deletions(-) diff --git a/src/services/engine/frame/passes/filamentsPass.ts b/src/services/engine/frame/passes/filamentsPass.ts index 94c76943..03e22c8d 100644 --- a/src/services/engine/frame/passes/filamentsPass.ts +++ b/src/services/engine/frame/passes/filamentsPass.ts @@ -11,7 +11,7 @@ * ### When it draws * * Gated on TWO conditions: - * 1. `settings.filamentsEnabled` — user toggle (off by default). + * 1. `state.settings.filaments.enabled` — user toggle (off by default). * 2. `deps.filamentRenderer !== null` — the binary is an optional * asset. When the deployment doesn't ship `filaments.bin`, * `state.gpu.filamentRenderer` is constructed but never @@ -22,6 +22,12 @@ * Both checks live in `enabled` so the inner `draw` body can * dereference `filamentRenderer` without a redundant null guard. * + * ### State reads + * + * `enabled` reads `state.settings.filaments.enabled` (user toggle) and + * `state.subsystems.fades.opacityOf` (fade-out tail). `draw` reads + * `state.settings.filaments.intensity` (line brightness scale). + * * ### Why between thumbnails and Milky Way * * The pre-D.2 inline order was points → thumbnails → filaments → @@ -64,18 +70,18 @@ export const filamentsPass: Pass = { // Update: the runtime `draw` short-circuits anyway via the // `filamentRenderer === null` early return; `enabled` returning // true with a null renderer is a self-correcting near-miss. - enabled(state, _ctx, settings) { - // Settings boolean is the user's intent; opacityOf > 0 is the visual + enabled(state, _ctx, _settings) { + // State boolean is the user's intent; opacityOf > 0 is the visual // state. We render whenever EITHER is true so a fade-out continues // drawing after the user toggles off (until opacity hits 0). The // toggle handler in engine.ts flips the setting AND fires fadeTo // synchronously; this gate is what keeps the pass alive through the // ~100 ms ramp. - if (settings.filamentsEnabled) return true; + if (state.settings.filaments.enabled) return true; return state.subsystems.fades.opacityOf({ kind: 'filament' }, performance.now()) > 0; }, - draw(pass, ctx, state, settings, deps) { + draw(pass, ctx, state, _settings, deps) { // Renderer-null check lives here rather than in `enabled` because // `enabled` doesn't receive `deps`. Keeping this as a defensive // early-return makes the `enabled === true → draw runs` invariant @@ -93,7 +99,7 @@ export const filamentsPass: Pass = { vp, [canvasSize.width, canvasSize.height], FILAMENT_LINE_HALFWIDTH_PX, - settings.filamentIntensity, + state.settings.filaments.intensity, // Focus recession is applied HERE (on the drawn opacity), not on the // `enabled` gate above: recession ∈ [FILAMENT_RECESSION, 1] can never // zero the layer, so the gate keeps reading the pure toggle opacity. diff --git a/tests/services/engine/frame/passes/filamentsPass.test.ts b/tests/services/engine/frame/passes/filamentsPass.test.ts index 0622b4fa..278d594e 100644 --- a/tests/services/engine/frame/passes/filamentsPass.test.ts +++ b/tests/services/engine/frame/passes/filamentsPass.test.ts @@ -51,26 +51,34 @@ function makeCtx(focusBlend: number): ReadyFrameContext { }; } -function makeSettings(overrides: Partial = {}): RenderFrameSettings { - return { - filamentsEnabled: true, - filamentIntensity: 1, - ...(overrides as object), - } as RenderFrameSettings; -} - /** - * Build a state whose filament fade reports `opacity`. `opacityOf` is a + * Build a state whose filament fade reports `opacity` and whose + * `settings.filaments` matches the supplied overrides. `opacityOf` is a * single stub returning the same value regardless of handle/now — the * filaments pass only ever asks for the `{kind:'filament'}` handle, so a * constant stub faithfully models "the filament layer is at `opacity`". */ -function makeState(opacity: number): EngineState { +function makeState( + opacity: number, + filamentsOverrides: Partial<{ enabled: boolean; intensity: number }> = {}, +): EngineState { return { subsystems: { fades: { opacityOf: () => opacity } }, + settings: { + filaments: { + enabled: true, + intensity: 1, + ...filamentsOverrides, + }, + }, } as unknown as EngineState; } +/** Minimal settings stub — the pass no longer reads from this bag. */ +function makeSettings(): RenderFrameSettings { + return {} as RenderFrameSettings; +} + function makeDeps(drawSpy = vi.fn()): PassDeps { return { filamentRenderer: { draw: drawSpy } } as unknown as PassDeps; } @@ -96,9 +104,9 @@ describe('filamentsPass.draw focus recession', () => { describe('filamentsPass.enabled is unaffected by focus recession', () => { it('returns false when the toggle is off and opacity is 0, regardless of blend', () => { - const state = makeState(0); - const settings = makeSettings({ filamentsEnabled: false }); - expect(filamentsPass.enabled(state, makeCtx(0), settings)).toBe(false); - expect(filamentsPass.enabled(state, makeCtx(1), settings)).toBe(false); + // Pass enabled=false via state; settings arg is unused by the pass. + const state = makeState(0, { enabled: false }); + expect(filamentsPass.enabled(state, makeCtx(0), makeSettings())).toBe(false); + expect(filamentsPass.enabled(state, makeCtx(1), makeSettings())).toBe(false); }); }); diff --git a/tests/services/engine/frame/passes/passes.test.ts b/tests/services/engine/frame/passes/passes.test.ts index ba367dd9..15fee9ce 100644 --- a/tests/services/engine/frame/passes/passes.test.ts +++ b/tests/services/engine/frame/passes/passes.test.ts @@ -260,29 +260,38 @@ describe('proceduralDisksPass.enabled', () => { // HDR_PASSES registry check above pins the name in canonical order. describe('filamentsPass.enabled', () => { - it('returns true when filamentsEnabled is true (renderer presence checked in draw)', () => { + it('returns true when filaments.enabled is true (renderer presence checked in draw)', () => { + const stateOn = { + ...STATE_STUB, + settings: { filaments: { enabled: true, intensity: 1 } }, + } as unknown as EngineState; expect( - filamentsPass.enabled(STATE_STUB, makeCtx(), makeSettings({ filamentsEnabled: true })), + filamentsPass.enabled(stateOn, makeCtx(), makeSettings()), ).toBe(true); }); - it('returns false when filamentsEnabled is false AND fade opacity is 0', () => { - // STATE_STUB.fades.opacityOf returns 1 by default — override to 0 - // so the gate doesn't keep the pass alive through a fade-out tail. + it('returns false when filaments.enabled is false AND fade opacity is 0', () => { + // fades.opacityOf returns 0 so the gate doesn't keep the pass alive + // through a fade-out tail; toggle is also off — both conditions false. const stateZeroFade = { subsystems: { fades: { opacityOf: () => 0, isAnyAnimating: () => false } }, + settings: { filaments: { enabled: false, intensity: 1 } }, } as unknown as EngineState; expect( - filamentsPass.enabled(stateZeroFade, makeCtx(), makeSettings({ filamentsEnabled: false })), + filamentsPass.enabled(stateZeroFade, makeCtx(), makeSettings()), ).toBe(false); }); - it('returns true when filamentsEnabled is false BUT fade opacity > 0 (fade-out tail still drawing)', () => { + it('returns true when filaments.enabled is false BUT fade opacity > 0 (fade-out tail still drawing)', () => { // STATE_STUB's opacityOf = 1 simulates a fade-out in progress; the // gate keeps the pass alive so the user sees the smooth ~100 ms ramp // instead of an instant pop. + const stateOffFading = { + ...STATE_STUB, + settings: { filaments: { enabled: false, intensity: 1 } }, + } as unknown as EngineState; expect( - filamentsPass.enabled(STATE_STUB, makeCtx(), makeSettings({ filamentsEnabled: false })), + filamentsPass.enabled(stateOffFading, makeCtx(), makeSettings()), ).toBe(true); }); }); @@ -293,23 +302,31 @@ describe('filamentsPass.draw', () => { // receive `deps`. With a null renderer there's nothing to spy on — // just assert no exception escapes. const deps = makeDeps({ filamentRenderer: null }); + const stateOn = { + ...STATE_STUB, + settings: { filaments: { enabled: true, intensity: 1 } }, + } as unknown as EngineState; expect(() => filamentsPass.draw( PASS_STUB, makeCtx(), - STATE_STUB, - makeSettings({ filamentsEnabled: true }), + stateOn, + makeSettings(), deps, ), ).not.toThrow(); }); it('forwards correct args to filamentRenderer.draw when present', () => { - const drawSpy = vi.fn(); + const drawSpy = vi.fn<(...args: unknown[]) => void>(); const deps = makeDeps({ filamentRenderer: { draw: drawSpy } as any }); const ctx = makeCtx(); - const settings = makeSettings({ filamentsEnabled: true, filamentIntensity: 0.7 }); - filamentsPass.draw(PASS_STUB, ctx, STATE_STUB, settings, deps); + // intensity=0.7 now comes from state.settings.filaments.intensity. + const stateWith07 = { + ...STATE_STUB, + settings: { filaments: { enabled: true, intensity: 0.7 } }, + } as unknown as EngineState; + filamentsPass.draw(PASS_STUB, ctx, stateWith07, makeSettings(), deps); expect(drawSpy).toHaveBeenCalledTimes(1); const args = drawSpy.mock.calls[0]!; expect(args[0]).toBe(PASS_STUB); From 29aa46b4d3b33b08732c4cfd0922dcc330e5cd84 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 21 Jun 2026 02:22:03 +0200 Subject: [PATCH 07/14] refactor(frame): disk passes read state.settings.thumbnails.enabled Both texturedDisksPass and proceduralDisksPass gate on the real thumbnails setting instead of the settings bag's galaxyTexturesEnabled. The settings param stays in both signatures (renamed _settings) until the Phase 3 sweep. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../frame/passes/proceduralDisksPass.ts | 13 ++++++------ .../engine/frame/passes/texturedDisksPass.ts | 20 +++++++++---------- .../engine/frame/passes/passes.test.ts | 18 +++++++++++------ .../frame/passes/proceduralDisksPass.test.ts | 14 ++++++++----- .../frame/passes/texturedDisksPass.test.ts | 12 ++++++++--- 5 files changed, 46 insertions(+), 31 deletions(-) diff --git a/src/services/engine/frame/passes/proceduralDisksPass.ts b/src/services/engine/frame/passes/proceduralDisksPass.ts index 177e05dd..8066b371 100644 --- a/src/services/engine/frame/passes/proceduralDisksPass.ts +++ b/src/services/engine/frame/passes/proceduralDisksPass.ts @@ -1,10 +1,11 @@ /** * proceduralDisksPass — LOD-1 procedural disk impostors. * - * Issues a single draw call against `proceduralDiskRenderer` using the - * instance array `state.subsystems.proceduralDisks.lastOutput.instances`, - * populated by the subsystem's `runFrame` earlier in the same frame - * (called from `runFrame.ts` before the HDR_PASSES loop opens). + * Reads `state.settings.thumbnails.enabled` as the master gate, then + * `state.subsystems.proceduralDisks.lastOutput.instances` (populated + * by the subsystem's `runFrame` earlier in the same frame, called from + * `runFrame.ts` before the HDR_PASSES loop opens), and issues one draw + * call against `proceduralDiskRenderer`. * * ### Why read from lastOutput instead of running the planner here * @@ -21,8 +22,8 @@ import type { Pass } from '../../../../@types/engine/frame/Pass'; export const proceduralDisksPass: Pass = { name: 'procedural-disks', - enabled(state, _ctx, settings) { - if (!settings.galaxyTexturesEnabled) return false; + enabled(state, _ctx, _settings) { + if (!state.settings.thumbnails.enabled) return false; if (state.subsystems.proceduralDisks === null) return false; return state.subsystems.proceduralDisks.lastOutput.instances.length > 0; }, diff --git a/src/services/engine/frame/passes/texturedDisksPass.ts b/src/services/engine/frame/passes/texturedDisksPass.ts index 07f49631..ceb48bf9 100644 --- a/src/services/engine/frame/passes/texturedDisksPass.ts +++ b/src/services/engine/frame/passes/texturedDisksPass.ts @@ -1,23 +1,21 @@ /** * texturedDisksPass — LOD-2 textured galaxy thumbnails (3D-oriented disks). * - * What's left of the former `texturedDisksPass` after the - * 2026-05-18 quad-removal. Reads from - * `state.subsystems.texturedDisks.lastOutput.disks` (populated - * upstream in `runFrame.ts`) and dispatches one draw call to - * `texturedDiskRenderer`. The legacy screen-aligned quad fallback - * was deleted because the build pipeline's deterministic orientation - * fallback (`buildAllBins.ts`) ensures every encoded galaxy has finite - * (axisRatio, PA) — see `texturedDiskSubsystem.ts` for the full - * rationale. + * Reads `state.settings.thumbnails.enabled` as the master gate, then + * `state.subsystems.texturedDisks.lastOutput.disks` (populated upstream + * in `runFrame.ts`), and dispatches one draw call to + * `texturedDiskRenderer`. The legacy screen-aligned quad fallback was + * deleted because the build pipeline's deterministic orientation fallback + * (`buildAllBins.ts`) ensures every encoded galaxy has finite (axisRatio, + * PA) — see `texturedDiskSubsystem.ts` for the full rationale. */ import type { Pass } from '../../../../@types/engine/frame/Pass'; export const texturedDisksPass: Pass = { name: 'textured-disks', - enabled(state, _ctx, settings) { - if (!settings.galaxyTexturesEnabled) return false; + enabled(state, _ctx, _settings) { + if (!state.settings.thumbnails.enabled) return false; if (state.subsystems.texturedDisks === null) return false; return state.subsystems.texturedDisks.lastOutput.disks.length > 0; }, diff --git a/tests/services/engine/frame/passes/passes.test.ts b/tests/services/engine/frame/passes/passes.test.ts index 15fee9ce..90a5e48f 100644 --- a/tests/services/engine/frame/passes/passes.test.ts +++ b/tests/services/engine/frame/passes/passes.test.ts @@ -217,39 +217,45 @@ describe('pointSpritesPass.enabled', () => { }); describe('proceduralDisksPass.enabled', () => { - it('returns false when galaxyTexturesEnabled is false', () => { + it('returns false when state.settings.thumbnails.enabled is false', () => { const state = { subsystems: { proceduralDisks: { lastOutput: { instances: [{}] } }, }, + settings: { thumbnails: { enabled: false } }, } as unknown as EngineState; expect( - proceduralDisksPass.enabled(state, makeCtx(), makeSettings({ galaxyTexturesEnabled: false })), + proceduralDisksPass.enabled(state, makeCtx(), makeSettings()), ).toBe(false); }); it('returns false when subsystem is null', () => { - const state = { subsystems: { proceduralDisks: null } } as unknown as EngineState; + const state = { + subsystems: { proceduralDisks: null }, + settings: { thumbnails: { enabled: true } }, + } as unknown as EngineState; expect( - proceduralDisksPass.enabled(state, makeCtx(), makeSettings({ galaxyTexturesEnabled: true })), + proceduralDisksPass.enabled(state, makeCtx(), makeSettings()), ).toBe(false); }); it('returns false when no instances are pending', () => { const state = { subsystems: { proceduralDisks: { lastOutput: { instances: [] } } }, + settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; expect( - proceduralDisksPass.enabled(state, makeCtx(), makeSettings({ galaxyTexturesEnabled: true })), + proceduralDisksPass.enabled(state, makeCtx(), makeSettings()), ).toBe(false); }); it('returns true when enabled, subsystem present, and instances pending', () => { const state = { subsystems: { proceduralDisks: { lastOutput: { instances: [{}] } } }, + settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; expect( - proceduralDisksPass.enabled(state, makeCtx(), makeSettings({ galaxyTexturesEnabled: true })), + proceduralDisksPass.enabled(state, makeCtx(), makeSettings()), ).toBe(true); }); }); diff --git a/tests/services/engine/frame/passes/proceduralDisksPass.test.ts b/tests/services/engine/frame/passes/proceduralDisksPass.test.ts index 15c4cf06..16568c10 100644 --- a/tests/services/engine/frame/passes/proceduralDisksPass.test.ts +++ b/tests/services/engine/frame/passes/proceduralDisksPass.test.ts @@ -77,22 +77,25 @@ describe('proceduralDisksPass', () => { }); it('enabled() returns false when subsystems.proceduralDisks is null', () => { - const state = { subsystems: { proceduralDisks: null } } as unknown as EngineState; + const state = { + subsystems: { proceduralDisks: null }, + settings: { thumbnails: { enabled: true } }, + } as unknown as EngineState; expect(proceduralDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(false); }); - it('enabled() returns false when galaxyTexturesEnabled is false', () => { + it('enabled() returns false when state.settings.thumbnails.enabled is false', () => { const state = { subsystems: { proceduralDisks: { lastOutput: { instances: [{}] } } }, + settings: { thumbnails: { enabled: false } }, } as unknown as EngineState; - const settings = makeSettings(); - settings.galaxyTexturesEnabled = false; - expect(proceduralDisksPass.enabled(state, makeCtx(), settings)).toBe(false); + expect(proceduralDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(false); }); it('enabled() returns false when lastOutput.instances is empty', () => { const state = { subsystems: { proceduralDisks: { lastOutput: { instances: [] } } }, + settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; expect(proceduralDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(false); }); @@ -100,6 +103,7 @@ describe('proceduralDisksPass', () => { it('enabled() returns true with a non-empty lastOutput', () => { const state = { subsystems: { proceduralDisks: { lastOutput: { instances: [{}] } } }, + settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; expect(proceduralDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(true); }); diff --git a/tests/services/engine/frame/passes/texturedDisksPass.test.ts b/tests/services/engine/frame/passes/texturedDisksPass.test.ts index ac10a5b7..353e6aa7 100644 --- a/tests/services/engine/frame/passes/texturedDisksPass.test.ts +++ b/tests/services/engine/frame/passes/texturedDisksPass.test.ts @@ -73,23 +73,28 @@ describe('texturedDisksPass', () => { expect(texturedDisksPass.name).toBe('textured-disks'); }); - it('enabled() returns false when galaxyTexturesEnabled is false', () => { + it('enabled() returns false when state.settings.thumbnails.enabled is false', () => { const state = { subsystems: { texturedDisks: { lastOutput: { disks: [{}], quads: [] } } }, + settings: { thumbnails: { enabled: false } }, } as unknown as EngineState; expect( - texturedDisksPass.enabled(state, makeCtx(), makeSettings({ galaxyTexturesEnabled: false })), + texturedDisksPass.enabled(state, makeCtx(), makeSettings()), ).toBe(false); }); it('enabled() returns false when subsystem is null', () => { - const state = { subsystems: { texturedDisks: null } } as unknown as EngineState; + const state = { + subsystems: { texturedDisks: null }, + settings: { thumbnails: { enabled: true } }, + } as unknown as EngineState; expect(texturedDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(false); }); it('enabled() returns false when disks array is empty', () => { const state = { subsystems: { texturedDisks: { lastOutput: { disks: [] } } }, + settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; expect(texturedDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(false); }); @@ -97,6 +102,7 @@ describe('texturedDisksPass', () => { it('enabled() returns true when disks array is non-empty', () => { const state = { subsystems: { texturedDisks: { lastOutput: { disks: [{}] } } }, + settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; expect(texturedDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(true); }); From aa6eb4441e4c29a3621fc05d850a6c367f3d3ed0 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 21 Jun 2026 02:24:36 +0200 Subject: [PATCH 08/14] refactor(frame): volumeUpsamplePass.enabled reads state.settings.volumes.enabled Re-sources the volume-upsample gate off the settings bag. The settings param stays in the signature (renamed _settings) until the Phase 3 sweep. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/engine/frame/passes/volumeUpsamplePass.ts | 10 ++++++---- .../engine/frame/passes/volumeUpsamplePass.test.ts | 7 +++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/services/engine/frame/passes/volumeUpsamplePass.ts b/src/services/engine/frame/passes/volumeUpsamplePass.ts index 56503add..ac5b5f39 100644 --- a/src/services/engine/frame/passes/volumeUpsamplePass.ts +++ b/src/services/engine/frame/passes/volumeUpsamplePass.ts @@ -27,7 +27,9 @@ * is the per-frame fine-grained gate that skips the upsample when no * fields are enabled (since `encodeVolumes` then skipped the half-res * raymarch and the half-res target was cleared to zero — adding zero to - * HDR is wasted work). + * HDR is wasted work). The master on/off gate reads + * `state.settings.volumes.enabled` directly — the old `settings.volumesEnabled` + * forwarded the same bit; the per-frame bag is being dissolved. */ import type { Pass } from '../../../../@types/engine/frame/Pass'; @@ -36,18 +38,18 @@ import type { VolumeFieldId } from '../../../../@types/data/volume/VolumeFieldId export const volumeUpsamplePass: Pass = { name: 'volume-upsample', - enabled(state, _ctx, settings) { + enabled(state, _ctx, _settings) { // Pre-bootstrap window: either handle null means initGpu hasn't // finished. Same shape as the old scalarVolumePass gate. if (state.gpu.volumeFieldRenderer === null) return false; if (state.gpu.volumeUpsample === null) return false; - // Master gate: settings boolean OR a non-zero master fade tail. + // Master gate: state boolean OR a non-zero master fade tail. // While master is fading out, encodeHdr* is still drawing into // the half-res target (each field's opacity multiplied by the // master), so this blit must run to bring those pixels onto HDR. const now = performance.now(); const masterOpacity = state.subsystems.fades.opacityOf({ kind: 'volumesMaster' }, now); - if (!settings.volumesEnabled && masterOpacity <= 0) return false; + if (!state.settings.volumes.enabled && masterOpacity <= 0) return false; // Per-field gate: active fields OR fade-out tails in flight. const settingsOf = (id: VolumeFieldId) => state.settings.volumes.items[id]; if (state.gpu.volumeFieldRenderer.hasActiveFields(settingsOf)) return true; diff --git a/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts b/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts index 772c7be8..d1ecb248 100644 --- a/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts +++ b/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts @@ -75,7 +75,7 @@ const DEPS_STUB = {} as PassDeps; // --------------------------------------------------------------------------- describe('volumeUpsamplePass.enabled', () => { - it('returns false when volumesEnabled is false and master fade is fully out', () => { + it('returns false when volumes.enabled is false and master fade is fully out', () => { const state = { gpu: { volumeFieldRenderer: { hasActiveFields: () => true, listIds: () => [] }, @@ -84,9 +84,10 @@ describe('volumeUpsamplePass.enabled', () => { // Master opacity 0 = no fade-out tail in flight. The gate // short-circuits to false when both gates miss. subsystems: { fades: { opacityOf: () => 0 } }, + settings: { volumes: { enabled: false } }, } as unknown as EngineState; expect( - volumeUpsamplePass.enabled(state, makeCtx(), makeSettings({ volumesEnabled: false })), + volumeUpsamplePass.enabled(state, makeCtx(), makeSettings()), ).toBe(false); }); @@ -103,6 +104,7 @@ describe('volumeUpsamplePass.enabled', () => { volumeUpsample: { draw: vi.fn(), destroy: vi.fn() }, }, subsystems: { fades: { opacityOf: () => 0 } }, + settings: { volumes: { enabled: true } }, } as unknown as EngineState; expect(volumeUpsamplePass.enabled(state, makeCtx(), makeSettings())).toBe(false); }); @@ -134,6 +136,7 @@ describe('volumeUpsamplePass.enabled', () => { volumeUpsample: { draw: vi.fn(), destroy: vi.fn() }, }, subsystems: { fades: { opacityOf: () => 1 } }, + settings: { volumes: { enabled: true } }, } as unknown as EngineState; expect(volumeUpsamplePass.enabled(state, makeCtx(), makeSettings())).toBe(true); }); From 20a87efdfd99a58a44d7a8161e918e223bbf5a1d Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 21 Jun 2026 02:27:30 +0200 Subject: [PATCH 09/14] refactor(frame): selectionRingPass.draw reads state.settings.galaxyCatalogs.sizePx Re-sources the selection-ring radius off the settings bag. The settings param stays in the signature (renamed _settings) until the Phase 3 sweep. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../engine/frame/passes/selectionRingPass.ts | 7 ++++--- .../frame/passes/selectionRingPass.test.ts | 20 +++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/services/engine/frame/passes/selectionRingPass.ts b/src/services/engine/frame/passes/selectionRingPass.ts index 51f67673..809f5225 100644 --- a/src/services/engine/frame/passes/selectionRingPass.ts +++ b/src/services/engine/frame/passes/selectionRingPass.ts @@ -14,7 +14,8 @@ * a galaxy yields its catalog diameter, the Milky Way its disc radius, a * structure `null` (it renders its ring through the cluster marker pass). * This pass then defers the apparent-px math to the shared - * `selectionRingRadiusPx` helper. Because a structure is already a table + * `selectionRingRadiusPx` helper, passing `state.settings.galaxyCatalogs.sizePx` + * as the minimum-radius floor. Because a structure is already a table * row returning null, no per-kind branch is needed here: a new halo-bearing * kind is one table row, and the descriptor carries the position so the pass * never re-narrows the union to read coordinates. @@ -41,7 +42,7 @@ export const selectionRingPass: Pass = { return selectionHalo(row) !== null; }, - draw(pass, ctx, state, settings, _deps) { + draw(pass, ctx, state, _settings, _deps) { const row = state.selectionRows.select; // A null descriptor is the structure arm (it renders its ring through the // cluster marker pass). The descriptor carries both the radius and the @@ -59,7 +60,7 @@ export const selectionRingPass: Pass = { radiusMpc, camDist, ctx.drawPxPerRad, - settings.pointSizePx, + state.settings.galaxyCatalogs.sizePx, ); state.gpu.selectionRingRenderer!.draw( diff --git a/tests/services/engine/frame/passes/selectionRingPass.test.ts b/tests/services/engine/frame/passes/selectionRingPass.test.ts index a3f9cee7..cf323f57 100644 --- a/tests/services/engine/frame/passes/selectionRingPass.test.ts +++ b/tests/services/engine/frame/passes/selectionRingPass.test.ts @@ -35,6 +35,14 @@ function makeSettings(overrides: Partial = {}): RenderFrame return { pointSizePx: 4, ...overrides } as RenderFrameSettings; } +function makeStateWithSizePx(row: SelectionRow | null, sizePx: number): EngineState { + return { + gpu: { selectionRingRenderer: makeRendererSpy() }, + selectionRows: { select: row, focus: null, hover: null }, + settings: { galaxyCatalogs: { sizePx } }, + } as unknown as EngineState; +} + const PASS_STUB = { setPipeline: vi.fn(), setBindGroup: vi.fn(), @@ -142,12 +150,12 @@ describe('selectionRingPass.enabled', () => { describe('selectionRingPass.draw', () => { it('computes ringRadiusPx from the row and forwards to renderer', () => { - const state = makeStateWithSelection(galaxyRow()); + const state = makeStateWithSizePx(galaxyRow(), 4); selectionRingPass.draw( PASS_STUB, makeCtx(), state, - makeSettings({ pointSizePx: 4 }), + makeSettings(), DEPS_STUB, ); @@ -169,13 +177,13 @@ describe('selectionRingPass.draw', () => { it('uses apparentPxRadius when galaxy is closer and larger on screen', () => { // Galaxy at 10 Mpc so the apparent radius dominates. - const state = makeStateWithSelection(galaxyRow({ z: 10 })); + const state = makeStateWithSizePx(galaxyRow({ z: 10 }), 4); selectionRingPass.draw( PASS_STUB, makeCtx(), state, - makeSettings({ pointSizePx: 4 }), + makeSettings(), DEPS_STUB, ); const rendererSpy = state.gpu.selectionRingRenderer as unknown as ReturnType< @@ -188,7 +196,7 @@ describe('selectionRingPass.draw', () => { }); it('draws the ring at MILKY_WAY_CENTER_WORLD for a milkyWay row', () => { - const state = makeStateWithSelection(MILKY_WAY_ROW); + const state = makeStateWithSizePx(MILKY_WAY_ROW, 4); selectionRingPass.draw(PASS_STUB, makeCtx(), state, makeSettings(), DEPS_STUB); const rendererSpy = state.gpu.selectionRingRenderer as unknown as ReturnType< @@ -204,7 +212,7 @@ describe('selectionRingPass.draw', () => { }); it('calls renderer.draw() exactly once with viewProj + viewport', () => { - const state = makeStateWithSelection(galaxyRow()); + const state = makeStateWithSizePx(galaxyRow(), 4); selectionRingPass.draw(PASS_STUB, makeCtx(), state, makeSettings(), DEPS_STUB); const rendererSpy = state.gpu.selectionRingRenderer as unknown as ReturnType< typeof makeRendererSpy From 200169a263451f623f46e0ae9b3aad27710486bc Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 21 Jun 2026 02:36:13 +0200 Subject: [PATCH 10/14] refactor(frame): encodeVolumePrepass reads state.settings.volumes.enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-sources the volume master gate off the settings bag. Also migrates the three renderFrame integration fixtures (renderFrame, timing, split-baseline) to derive a full nested state.settings + state.selection from each fixture's flat settings bag — these integration tests drive every pass through the real pipeline, so they need the state shape the passes now read directly (Tasks 3-8 moved those reads off the bag). The flat bag stays the single fixture knob; all overrides and assertions are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../engine/frame/encodeVolumePrepass.ts | 6 ++--- .../services/engine/frame/renderFrame.test.ts | 25 ++++++++++++++----- .../engine/frame/renderFrame.timing.test.ts | 19 +++++++++++++- tests/visual/renderFrameSplitBaseline.test.ts | 18 ++++++++++++- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/services/engine/frame/encodeVolumePrepass.ts b/src/services/engine/frame/encodeVolumePrepass.ts index af448c02..9326a49a 100644 --- a/src/services/engine/frame/encodeVolumePrepass.ts +++ b/src/services/engine/frame/encodeVolumePrepass.ts @@ -23,7 +23,7 @@ * * ### Gating rationale * - * Master gate: `settings.volumesEnabled` OR a non-zero master fade tail. + * Master gate: `state.settings.volumes.enabled` OR a non-zero master fade tail. * Focus recession dims the whole volume subsystem in lockstep with the * filament overlay, but it's applied to the master MULTIPLIER only, not the * gate above: recession ∈ [VOLUME_RECESSION, 1] can never zero the layer, @@ -53,13 +53,13 @@ export function encodeVolumePrepass( encoder: GPUCommandEncoder, ctx: ReadyFrameContext, state: EngineState, - settings: RenderFrameSettings, + _settings: RenderFrameSettings, timingService: GpuTimingService | null, ): void { if (state.gpu.volumeFieldRenderer !== null) { const nowMs = performance.now(); const masterOpacity = state.subsystems.fades.opacityOf({ kind: 'volumesMaster' }, nowMs); - if (settings.volumesEnabled || masterOpacity > 0) { + if (state.settings.volumes.enabled || masterOpacity > 0) { // Focus recession dims the whole volume subsystem in lockstep with the // filament overlay. Applied to the master MULTIPLIER only, not the gate // above: recession ∈ [VOLUME_RECESSION, 1] can never zero the layer, so diff --git a/tests/services/engine/frame/renderFrame.test.ts b/tests/services/engine/frame/renderFrame.test.ts index a5034407..98f7337e 100644 --- a/tests/services/engine/frame/renderFrame.test.ts +++ b/tests/services/engine/frame/renderFrame.test.ts @@ -317,9 +317,22 @@ function makeInput( // most tests pass no overrides so the default is an empty record (matches // production); the skip-on-toggle test passes `overrides.disabledPasses`. settings: { + galaxyCatalogs: { + sizePx: settings.pointSizePx, + brightness: settings.brightness, + highlightFallback: settings.highlightFallback, + realOnly: settings.realOnlyMode, + depthFade: settings.depthFadeEnabled, + }, + bias: { mode: settings.biasMode, absMagLimit: settings.absMagLimit }, + thumbnails: { enabled: settings.galaxyTexturesEnabled }, + milkyWay: { enabled: settings.milkyWayEnabled }, + filaments: { enabled: settings.filamentsEnabled, intensity: settings.filamentIntensity }, + volumes: { enabled: settings.volumesEnabled }, flow: { enabled: false }, debug: { disabledPasses: overrides.disabledPasses ?? {} }, }, + selection: { select: settings.selected }, assetSlots: { flow: null }, // proceduralDisksPass / texturedDisksPass each read their slot // off `state.subsystems` in their `enabled()` gate; nulling both @@ -513,12 +526,12 @@ describe('renderFrame', () => { }); it('opens a pre-HDR render pass against the half-res view when volumes are active', () => { - // When `volumesEnabled` is true AND volumeFieldRenderer has active - // fields, `encodeVolumes` must run BEFORE the HDR mega-pass. The - // fixture's default settings has volumesEnabled=false → no pre-pass - // fires. We force-enable it here and stub a renderer with an - // active field, then check that the FIRST beginRenderPass goes - // against the half-res view. + // When `state.settings.volumes.enabled` is true AND volumeFieldRenderer + // has active fields, `encodeVolumes` must run BEFORE the HDR mega-pass. + // The fixture's default state has volumes.enabled=false → no pre-pass + // fires. We force-enable it here and stub a renderer with an active + // field, then check that the FIRST beginRenderPass goes against the + // half-res view. const fx2 = makeInput({ settings: { volumesEnabled: true } }); // Wire in a volumeFieldRenderer with active fields. const drawSpy = vi.fn(); diff --git a/tests/services/engine/frame/renderFrame.timing.test.ts b/tests/services/engine/frame/renderFrame.timing.test.ts index 0f9bc4ef..0fc91c31 100644 --- a/tests/services/engine/frame/renderFrame.timing.test.ts +++ b/tests/services/engine/frame/renderFrame.timing.test.ts @@ -223,7 +223,23 @@ function makeMinimalInputWithTiming(timingService: GpuTimingService): { // A null slot → slotReady false → not loaded. // The encoders read the renderer-toggle override bag off // `settings.debug.disabledPasses`; empty by default so no pass is skipped. - settings: { flow: { enabled: false }, debug: { disabledPasses: {} } }, + settings: { + galaxyCatalogs: { + sizePx: settings.pointSizePx, + brightness: settings.brightness, + highlightFallback: settings.highlightFallback, + realOnly: settings.realOnlyMode, + depthFade: settings.depthFadeEnabled, + }, + bias: { mode: settings.biasMode, absMagLimit: settings.absMagLimit }, + thumbnails: { enabled: settings.galaxyTexturesEnabled }, + milkyWay: { enabled: settings.milkyWayEnabled }, + filaments: { enabled: settings.filamentsEnabled, intensity: settings.filamentIntensity }, + volumes: { enabled: settings.volumesEnabled }, + flow: { enabled: false }, + debug: { disabledPasses: {} }, + }, + selection: { select: settings.selected }, assetSlots: { flow: null }, subsystems: { proceduralDisks: null, @@ -347,6 +363,7 @@ describe('renderFrame — timing service hookup', () => { // Force volumes on with an active volumeFieldRenderer. (input.settings as any).volumesEnabled = true; + (input.state as any).settings.volumes = { enabled: true }; const drawSpy = vi.fn(); (input as any).volumeFieldRenderer = { draw: drawSpy, diff --git a/tests/visual/renderFrameSplitBaseline.test.ts b/tests/visual/renderFrameSplitBaseline.test.ts index 486ec58b..b3baff63 100644 --- a/tests/visual/renderFrameSplitBaseline.test.ts +++ b/tests/visual/renderFrameSplitBaseline.test.ts @@ -344,7 +344,23 @@ describe('renderFrame visual baseline', () => { // A null slot → slotReady false → not loaded. // The encoders read the renderer-toggle override bag off // `settings.debug.disabledPasses`; empty so every pass fires. - settings: { flow: { enabled: false }, debug: { disabledPasses: {} } }, + settings: { + galaxyCatalogs: { + sizePx: settings.pointSizePx, + brightness: settings.brightness, + highlightFallback: settings.highlightFallback, + realOnly: settings.realOnlyMode, + depthFade: settings.depthFadeEnabled, + }, + bias: { mode: settings.biasMode, absMagLimit: settings.absMagLimit }, + thumbnails: { enabled: settings.galaxyTexturesEnabled }, + milkyWay: { enabled: settings.milkyWayEnabled }, + filaments: { enabled: settings.filamentsEnabled, intensity: settings.filamentIntensity }, + volumes: { enabled: settings.volumesEnabled }, + flow: { enabled: false }, + debug: { disabledPasses: {} }, + }, + selection: { select: settings.selected }, assetSlots: { flow: null }, subsystems: { proceduralDisks: proceduralDisksSubsystem, From 27509f64cf6d7c33ce6dbb3fbc3f03e3284de400 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 21 Jun 2026 02:39:24 +0200 Subject: [PATCH 11/14] refactor(frame): renderFrame reads ctx.focus + state.settings.tonemap The three reads renderFrame itself makes now source from their real homes: the focus uniform write takes ctx.focus, and postProcess.draw's exposure + curve come from state.settings.tonemap. The settings bag still rides on RenderFrameInput until the Phase 3 sweep removes it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/engine/frame/renderFrame.ts | 9 +++++---- tests/services/engine/frame/renderFrame.test.ts | 1 + tests/services/engine/frame/renderFrame.timing.test.ts | 1 + tests/visual/renderFrameSplitBaseline.test.ts | 1 + 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/services/engine/frame/renderFrame.ts b/src/services/engine/frame/renderFrame.ts index 6309530c..291d241c 100644 --- a/src/services/engine/frame/renderFrame.ts +++ b/src/services/engine/frame/renderFrame.ts @@ -126,7 +126,8 @@ export function renderFrame(input: RenderFrameInput): void { // Write the single shared cluster-focus uniform once per frame, before // any pass (points, impostor disks, and the later pick submit) reads it. // blend=0 at rest makes the per-vertex multiplier a no-op. - state.gpu.focusUniform?.write(settings.focus); + // ctx.focus is the per-frame FocusUniformsValue derived in deriveFrameContext. + state.gpu.focusUniform?.write(ctx.focus); // ── Encoder + HDR rendering ─────────────────────────────────────── // @@ -155,8 +156,8 @@ export function renderFrame(input: RenderFrameInput): void { ctx.postProcess.draw( encoder, swapView, - settings.exposure, - settings.toneMapCurve, + state.settings.tonemap.exposure, + state.settings.tonemap.curve, timingService.descriptorFor('tone-map'), ); encodeUiOverlay( @@ -171,7 +172,7 @@ export function renderFrame(input: RenderFrameInput): void { timingService.endFrame(timingCtx, encoder); } else { encodeHdrSingle(encoder, ctx, state, settings, deps); - ctx.postProcess.draw(encoder, swapView, settings.exposure, settings.toneMapCurve, undefined); + ctx.postProcess.draw(encoder, swapView, state.settings.tonemap.exposure, state.settings.tonemap.curve, undefined); encodeUiOverlay(encoder, swapView, ctx, state, settings, deps, undefined); } diff --git a/tests/services/engine/frame/renderFrame.test.ts b/tests/services/engine/frame/renderFrame.test.ts index 98f7337e..820113e8 100644 --- a/tests/services/engine/frame/renderFrame.test.ts +++ b/tests/services/engine/frame/renderFrame.test.ts @@ -324,6 +324,7 @@ function makeInput( realOnly: settings.realOnlyMode, depthFade: settings.depthFadeEnabled, }, + tonemap: { exposure: settings.exposure, curve: settings.toneMapCurve }, bias: { mode: settings.biasMode, absMagLimit: settings.absMagLimit }, thumbnails: { enabled: settings.galaxyTexturesEnabled }, milkyWay: { enabled: settings.milkyWayEnabled }, diff --git a/tests/services/engine/frame/renderFrame.timing.test.ts b/tests/services/engine/frame/renderFrame.timing.test.ts index 0fc91c31..fab19475 100644 --- a/tests/services/engine/frame/renderFrame.timing.test.ts +++ b/tests/services/engine/frame/renderFrame.timing.test.ts @@ -231,6 +231,7 @@ function makeMinimalInputWithTiming(timingService: GpuTimingService): { realOnly: settings.realOnlyMode, depthFade: settings.depthFadeEnabled, }, + tonemap: { exposure: settings.exposure, curve: settings.toneMapCurve }, bias: { mode: settings.biasMode, absMagLimit: settings.absMagLimit }, thumbnails: { enabled: settings.galaxyTexturesEnabled }, milkyWay: { enabled: settings.milkyWayEnabled }, diff --git a/tests/visual/renderFrameSplitBaseline.test.ts b/tests/visual/renderFrameSplitBaseline.test.ts index b3baff63..035f1834 100644 --- a/tests/visual/renderFrameSplitBaseline.test.ts +++ b/tests/visual/renderFrameSplitBaseline.test.ts @@ -352,6 +352,7 @@ describe('renderFrame visual baseline', () => { realOnly: settings.realOnlyMode, depthFade: settings.depthFadeEnabled, }, + tonemap: { exposure: settings.exposure, curve: settings.toneMapCurve }, bias: { mode: settings.biasMode, absMagLimit: settings.absMagLimit }, thumbnails: { enabled: settings.galaxyTexturesEnabled }, milkyWay: { enabled: settings.milkyWayEnabled }, From 0344fa9a9c5ec4015b5e65a14641e2521b3c46a4 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 21 Jun 2026 02:54:33 +0200 Subject: [PATCH 12/14] refactor(frame): drop the settings param and delete RenderFrameSettings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 sweep: the per-frame RenderFrameSettings bag is gone. Pass.enabled /draw and the four encode* functions no longer carry a settings parameter — every pass now reads from state/ctx/constants. RenderFrameInput drops the settings field; runFrame drops the bag-assembly literal (and its now-dead PROCEDURAL_DISK_FADE_* imports); RenderFrameSettings.d.ts is deleted. Mechanical care: settings sat 4th-of-5 in Pass.draw and encodeVolumePrepass, so deps / timingService shift left a slot — all 13 pass signatures and every call site updated in lockstep. Test fixtures keep their local settings bag as the derivation source for state.settings and drop it from the input object. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/@types/engine/frame/Pass.d.ts | 24 +++-- src/@types/engine/frame/RenderFrameInput.d.ts | 20 ++-- .../engine/frame/RenderFrameSettings.d.ts | 99 ------------------- src/@types/rendering/PointDrawSettings.d.ts | 4 +- src/services/engine/frame/encodeHdrSingle.ts | 8 +- src/services/engine/frame/encodeHdrSplit.ts | 8 +- src/services/engine/frame/encodeUiOverlay.ts | 6 +- .../engine/frame/encodeVolumePrepass.ts | 2 - .../engine/frame/passes/diskRadiusRingPass.ts | 4 +- .../engine/frame/passes/filamentsPass.ts | 4 +- .../engine/frame/passes/flowFieldPass.ts | 2 +- .../engine/frame/passes/horizonShellPass.ts | 2 +- .../engine/frame/passes/labelsPass.ts | 4 +- .../engine/frame/passes/markerLinesPass.ts | 4 +- .../engine/frame/passes/milkyWayPass.ts | 4 +- .../engine/frame/passes/pointSpritesPass.ts | 2 +- .../frame/passes/proceduralDisksPass.ts | 4 +- .../engine/frame/passes/selectionRingPass.ts | 4 +- .../frame/passes/structureMarkersPass.ts | 4 +- .../engine/frame/passes/texturedDisksPass.ts | 4 +- .../engine/frame/passes/volumeUpsamplePass.ts | 4 +- src/services/engine/frame/renderFrame.ts | 8 +- src/services/engine/frame/runFrame.ts | 32 ------ .../engine/frame/passes/filamentsPass.test.ts | 14 +-- .../engine/frame/passes/flowFieldPass.test.ts | 11 +-- .../engine/frame/passes/passes.test.ts | 73 ++++---------- .../frame/passes/proceduralDisksPass.test.ts | 15 +-- .../frame/passes/selectionRingPass.test.ts | 23 ++--- .../frame/passes/texturedDisksPass.test.ts | 17 ++-- .../frame/passes/volumeUpsamplePass.test.ts | 19 ++-- .../services/engine/frame/renderFrame.test.ts | 25 ++--- .../engine/frame/renderFrame.timing.test.ts | 2 - tests/visual/renderFrameSplitBaseline.test.ts | 1 - 33 files changed, 120 insertions(+), 337 deletions(-) delete mode 100644 src/@types/engine/frame/RenderFrameSettings.d.ts diff --git a/src/@types/engine/frame/Pass.d.ts b/src/@types/engine/frame/Pass.d.ts index f1cd089e..9405e6ca 100644 --- a/src/@types/engine/frame/Pass.d.ts +++ b/src/@types/engine/frame/Pass.d.ts @@ -11,7 +11,7 @@ * * ### Why an interface instead of free functions * - * The naive shape would be `(pass, ctx, state, settings, deps) => + * The naive shape would be `(pass, ctx, state, deps) => * boolean | void` — return `false` to skip, otherwise draw. That * works mechanically but loses two useful properties: * @@ -27,7 +27,7 @@ * ### Why a `const` object literal per file, not a class * * Passes are stateless across frames — every input is read fresh from - * `state` / `ctx` / `settings` / `deps` per call. A class adds the + * `state` / `ctx` / `deps` per call. A class adds the * "where do I instantiate this?" question and the inheritance escape * hatch that the project's `type` aliases convention (CLAUDE.md) * deliberately rejects. `export const xyzPass: Pass = { ... }` is @@ -49,26 +49,25 @@ import type { EngineState } from '../state/EngineState'; import type { ReadyFrameContext } from './ReadyFrameContext'; -import type { RenderFrameSettings } from './RenderFrameSettings'; import type { PassDeps } from './PassDeps'; /** * One discrete draw operation in the per-frame HDR render flow. * - * `enabled` is the gate predicate: a pure read of state + ctx + - * settings that returns true when the pass should run this frame. - * Tests can call it directly with stub state to assert the gate - * logic without standing up a GPU device. + * `enabled` is the gate predicate: a pure read of state + ctx that + * returns true when the pass should run this frame. Tests can call + * it directly with stub state to assert the gate logic without + * standing up a GPU device. * * `draw` records draw commands into the supplied HDR pass encoder. * Pre-condition: `enabled(...)` returned `true`. The function MUST * NOT call `pass.end()` — the encoder lifetime is owned by * `renderFrame`, which ends the pass once the for-loop completes. * - * Argument order is `(pass, ctx, state, settings, deps)` — the GPU - * encoder first because every implementation needs it, then the - * derived per-frame snapshot, then engine state, then settings, - * then the catch-all renderer dep bag. + * Argument order is `(pass, ctx, state, deps)` — the GPU encoder + * first because every implementation needs it, then the derived + * per-frame snapshot, then engine state, then the catch-all renderer + * dep bag. All settings are read directly from `state.settings.*`. */ export type Pass = { /** @@ -82,7 +81,7 @@ export type Pass = { * Whether this pass should record draw commands this frame. * Pure: no side effects. Reads only from arguments. */ - enabled(state: EngineState, ctx: ReadyFrameContext, settings: RenderFrameSettings): boolean; + enabled(state: EngineState, ctx: ReadyFrameContext): boolean; /** * Issue draw calls into the open HDR render pass. Called only * when `enabled` returned `true`. Must not call `pass.end()`. @@ -91,7 +90,6 @@ export type Pass = { pass: GPURenderPassEncoder, ctx: ReadyFrameContext, state: EngineState, - settings: RenderFrameSettings, deps: PassDeps, ): void; }; diff --git a/src/@types/engine/frame/RenderFrameInput.d.ts b/src/@types/engine/frame/RenderFrameInput.d.ts index ca13b796..ddf35f79 100644 --- a/src/@types/engine/frame/RenderFrameInput.d.ts +++ b/src/@types/engine/frame/RenderFrameInput.d.ts @@ -8,13 +8,11 @@ * ### `state` arrived in D.2 * * Pre-D.2, `renderFrame` consumed only the per-frame snapshot - * (`ctx`) plus settings — engine state was never read directly here. - * D.2's `Pass.draw` signature accepts `state` so that future passes - * can read engine-side data (selection, picking, sources) without a - * `RenderFrameSettings` field for every consumer. None of today's - * four passes actually read `state`, but the field is plumbed - * through so the type system supports passes that need it without - * a follow-up migration. + * (`ctx`) plus a flat settings bag — engine state was never read + * directly here. The flat bag is now dissolved: every pass reads + * `state.settings.*` directly, so the only non-state inputs are + * the GPU handles, the per-frame snapshot (`ctx`), and the timing + * service. */ import type { EngineState } from '../state/EngineState'; @@ -27,7 +25,6 @@ import type { VolumeFieldRenderer } from '../../rendering/VolumeFieldRenderer'; import type { FlowFieldRenderer } from '../../rendering/FlowFieldRenderer'; import type { GpuTimingService } from '../../gpu/timing/GpuTimingService'; import type { ReadyFrameContext } from './ReadyFrameContext'; -import type { RenderFrameSettings } from './RenderFrameSettings'; export type RenderFrameInput = { /** @@ -39,9 +36,7 @@ export type RenderFrameInput = { ctx: ReadyFrameContext; /** * Engine state — forwarded to each `Pass.draw` so per-pass logic - * can read selection / picking / source-state without going via - * settings. Today's four HDR passes don't read it (they consume - * settings + ctx + deps); the parameter exists for future passes. + * can read selection / picking / source-state / settings. */ state: EngineState; /** @@ -95,9 +90,6 @@ export type RenderFrameInput = { */ proceduralDiskRenderer: ProceduralDiskRenderer; - // ── Settings ────────────────────────────────────────────────────────── - settings: RenderFrameSettings; - /** * Per-pass GPU timing service (always non-null; check `.enabled` * before doing timing work). When enabled, `renderFrame` takes diff --git a/src/@types/engine/frame/RenderFrameSettings.d.ts b/src/@types/engine/frame/RenderFrameSettings.d.ts deleted file mode 100644 index f2fa4ea4..00000000 --- a/src/@types/engine/frame/RenderFrameSettings.d.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * RenderFrameSettings — settings consumed by the HDR passes and the - * tone-map post-process. - * - * Grouped into a single sub-struct rather than dumped into the top - * level of `RenderFrameInput` so the caller can pass `{ ...settings }` - * from a single closure-state snapshot, and so adding a new render- - * affecting setting is a one-line addition here. - */ - -import type { SelectionRef } from '../SelectionRef'; -import type { BiasMode } from '../../data/galaxyCatalog/BiasMode'; -import type { ToneMapCurve } from '../../data/ToneMapCurve'; -import type { FocusUniformsValue } from '../../rendering/FocusUniformsValue'; - -export type RenderFrameSettings = { - pointSizePx: number; - brightness: number; - /** - * Selected target identity, or `null` when nothing is selected. - * Galaxy refs (`type: 'galaxyCatalog'`) are translated inside - * `pointSpritesPass` to the packed u32 `(source << 27) | index` (or - * the `0xFFFFFFFF` "no selection" sentinel) the shader's halo path - * expects. Structure and milkyWay refs don't drive the point-sprite - * halo and are treated as "no galaxy selected" by that pass. - */ - selected: SelectionRef | null; - visibleSourceMask: number; - highlightFallback: boolean; - realOnlyMode: boolean; - biasMode: BiasMode; - absMagLimit: number; - depthFadeEnabled: boolean; - /** - * Procedural-disk crossfade-OUT thresholds for the points-pass - * fragment shader. Below `pxFadeStartPoints` points render at full - * alpha; above `pxFadeEndPoints` at zero alpha (handing off to the - * procedural-disk pass); inside the band a smoothstep complementary - * to the disk pass's fade-IN does a continuous crossfade. Both come - * from `PROCEDURAL_DISK_FADE_START_PX` / `_END_PX` in - * `subsystems/thumbnailSubsystem` so the two passes share one source - * of truth. - */ - pxFadeStartPoints: number; - pxFadeEndPoints: number; - /** - * Cluster focus-mode uniform for the points pass's @group(3) binding. - * Produced once per frame by `structureFocusSubsystem.produceFocusUniforms` - * in `runFrame` (so it shares the frame's single `nowMs`). At rest - * (`blend: 0`) the shader's per-vertex multiplier collapses to 1.0. - */ - focus: FocusUniformsValue; - exposure: number; - toneMapCurve: ToneMapCurve; - /** - * Whether to invoke the thumbnail subsystem's `runFrame` this tick. - * Lives in settings (not as a `subsystem | null` parameter) because - * the engine surfaces it as a user-facing toggle and re-enabling - * mid-session shouldn't tear down the subsystem. - */ - galaxyTexturesEnabled: boolean; - /** - * Whether to render the procedural Milky Way impostor at the world - * origin. See `services/gpu/milkyWayRenderer.ts` for the rationale. - * When false, the pass is skipped entirely (zero GPU cost beyond a - * branch in the host CPU code). - */ - milkyWayEnabled: boolean; - /** - * Whether to draw the cosmic-web filament-skeleton overlay (output of - * the optional `npm run build-filaments` pipeline; see - * `services/gpu/filamentRenderer.ts`). Default OFF — opt-in feature - * since the binary is not always present. When true but the - * renderer has no instance buffer (binary missing or still loading), - * the call is a cheap no-op. - */ - filamentsEnabled: boolean; - /** - * Multiplicative intensity scale for the filament overlay, in [0, 1]. - * Multiplied into the fragment-stage's final pre-multiplied alpha so - * the user can dim the cosmic-web skeleton against the bright HDR - * catalogue when high-σ datasets (longer, denser ridges) saturate - * to flat white under the tone-mapped pass. 1.0 = full strength; - * 0.0 = invisible (logically equivalent to filamentsEnabled=false). - */ - filamentIntensity: number; - /** - * Master gate for the 3D scalar-field volume overlay. When false, - * `volumeUpsamplePass.enabled` returns false before consulting the - * renderer, so no per-field checks or GPU work occurs — and the - * pre-HDR `encodeVolumes` step is also a no-op (it never reaches its - * draw because no field is active). When true, the pass also - * requires `volumeFieldRenderer.hasActiveFields()` to be true (at - * least one registered field is enabled with intensity > 0). See - * `volumeUpsamplePass.ts` and `EngineSettingsState.volumesEnabled` - * for the full gate rationale. - */ - volumesEnabled: boolean; -}; diff --git a/src/@types/rendering/PointDrawSettings.d.ts b/src/@types/rendering/PointDrawSettings.d.ts index 3ab4c6d7..d779438e 100644 --- a/src/@types/rendering/PointDrawSettings.d.ts +++ b/src/@types/rendering/PointDrawSettings.d.ts @@ -4,8 +4,8 @@ * A single record rather than positional args: callers fill named fields, * new knobs are one type-level edit, and TypeScript's structural matching * catches a missing field at compile time instead of a silent shifted- - * argument bug at draw time. Mirrors `RenderFrameSettings`'s naming so the - * engine-side pass code can pass `{ …settings, … }` without renames. + * argument bug at draw time. Field names match `state.settings.*` reads + * so the engine-side pass code can pluck them without renames. */ import type { Vec3 } from '../math/Vec3'; diff --git a/src/services/engine/frame/encodeHdrSingle.ts b/src/services/engine/frame/encodeHdrSingle.ts index 5ba9d00b..854e77cd 100644 --- a/src/services/engine/frame/encodeHdrSingle.ts +++ b/src/services/engine/frame/encodeHdrSingle.ts @@ -35,7 +35,6 @@ import type { ReadyFrameContext } from '../../../@types/engine/frame/ReadyFrameContext'; import type { EngineState } from '../../../@types/engine/state/EngineState'; import type { PassDeps } from '../../../@types/engine/frame/PassDeps'; -import type { RenderFrameSettings } from '../../../@types/engine/frame/RenderFrameSettings'; import { HDR_PASSES } from './passes'; import { encodeVolumePrepass } from './encodeVolumePrepass'; import { encodeFlowCompute } from './encodeFlowCompute'; @@ -45,7 +44,6 @@ export function encodeHdrSingle( encoder: GPUCommandEncoder, ctx: ReadyFrameContext, state: EngineState, - settings: RenderFrameSettings, deps: PassDeps, ): void { // ── Half-resolution scalar-volume pre-pass ──────────────────────────── @@ -60,7 +58,7 @@ export function encodeHdrSingle( // passes `null` for the timing service (no per-pass GPU timing in the // production single-pass branch), so the prepass's lazy // `timingService?.descriptorFor(...)` yields `undefined`. - encodeVolumePrepass(encoder, ctx, state, settings, null); + encodeVolumePrepass(encoder, ctx, state, null); // ── Flow-field compute pre-pass ─────────────────────────────────────── // Encodes the particle seed/integrate compute into this same encoder, @@ -92,9 +90,9 @@ export function encodeHdrSingle( // empty in production, so the membership lookup is in the noise. const disabledPasses = state.settings.debug.disabledPasses; for (const pass of HDR_PASSES) { - if (!pass.enabled(state, ctx, settings)) continue; + if (!pass.enabled(state, ctx)) continue; if (disabledPasses[pass.name] === true) continue; - pass.draw(hdrPass, ctx, state, settings, deps); + pass.draw(hdrPass, ctx, state, deps); } hdrPass.end(); diff --git a/src/services/engine/frame/encodeHdrSplit.ts b/src/services/engine/frame/encodeHdrSplit.ts index 639ace8a..0dcb8621 100644 --- a/src/services/engine/frame/encodeHdrSplit.ts +++ b/src/services/engine/frame/encodeHdrSplit.ts @@ -32,7 +32,6 @@ import type { ReadyFrameContext } from '../../../@types/engine/frame/ReadyFrameContext'; import type { EngineState } from '../../../@types/engine/state/EngineState'; import type { PassDeps } from '../../../@types/engine/frame/PassDeps'; -import type { RenderFrameSettings } from '../../../@types/engine/frame/RenderFrameSettings'; import type { GpuTimingService } from '../../../@types/gpu/timing/GpuTimingService'; import { HDR_PASSES } from './passes'; import { encodeVolumePrepass } from './encodeVolumePrepass'; @@ -43,7 +42,6 @@ export function encodeHdrSplit( encoder: GPUCommandEncoder, ctx: ReadyFrameContext, state: EngineState, - settings: RenderFrameSettings, deps: PassDeps, timingService: GpuTimingService, ): void { @@ -70,7 +68,7 @@ export function encodeHdrSplit( // the legacy `'scalar-volume'` slot — that's what the DebugPanel's GpuTimings row // reads, and keeping the slot name stable means the row's label and // historical samples line up. - encodeVolumePrepass(encoder, ctx, state, settings, timingService); + encodeVolumePrepass(encoder, ctx, state, timingService); // ── Flow-field compute pre-pass ─────────────────────────────────── // Same pre-HDR compute dispatch as the single-pass branch; runs before @@ -98,7 +96,7 @@ export function encodeHdrSplit( // `beginRenderPass` round-trip, no timestamp slot written). const disabledPasses = state.settings.debug.disabledPasses; for (const pass of HDR_PASSES) { - if (!pass.enabled(state, ctx, settings)) continue; + if (!pass.enabled(state, ctx)) continue; if (disabledPasses[pass.name] === true) continue; const timestampWrites = timingService.descriptorFor(pass.name); @@ -114,7 +112,7 @@ export function encodeHdrSplit( ], ...(timestampWrites ? { timestampWrites } : {}), }); - pass.draw(passEncoder, ctx, state, settings, deps); + pass.draw(passEncoder, ctx, state, deps); passEncoder.end(); } } diff --git a/src/services/engine/frame/encodeUiOverlay.ts b/src/services/engine/frame/encodeUiOverlay.ts index 49b849c4..d33d076f 100644 --- a/src/services/engine/frame/encodeUiOverlay.ts +++ b/src/services/engine/frame/encodeUiOverlay.ts @@ -56,7 +56,6 @@ import type { ReadyFrameContext } from '../../../@types/engine/frame/ReadyFrameContext'; import type { EngineState } from '../../../@types/engine/state/EngineState'; import type { PassDeps } from '../../../@types/engine/frame/PassDeps'; -import type { RenderFrameSettings } from '../../../@types/engine/frame/RenderFrameSettings'; import { UI_PASSES } from './passes'; export function encodeUiOverlay( @@ -64,7 +63,6 @@ export function encodeUiOverlay( swapView: GPUTextureView, ctx: ReadyFrameContext, state: EngineState, - settings: RenderFrameSettings, deps: PassDeps, timestampWrites: GPURenderPassTimestampWrites | undefined, ): void { @@ -73,7 +71,7 @@ export function encodeUiOverlay( // live settings snapshot; empty in production, so the check is in the noise. const disabledPasses = state.settings.debug.disabledPasses; const enabled = UI_PASSES.filter( - (p) => p.enabled(state, ctx, settings) && disabledPasses[p.name] !== true, + (p) => p.enabled(state, ctx) && disabledPasses[p.name] !== true, ); if (enabled.length === 0 && !timestampWrites) return; @@ -94,7 +92,7 @@ export function encodeUiOverlay( }); for (const p of enabled) { - p.draw(pass, ctx, state, settings, deps); + p.draw(pass, ctx, state, deps); } pass.end(); diff --git a/src/services/engine/frame/encodeVolumePrepass.ts b/src/services/engine/frame/encodeVolumePrepass.ts index 9326a49a..4852030b 100644 --- a/src/services/engine/frame/encodeVolumePrepass.ts +++ b/src/services/engine/frame/encodeVolumePrepass.ts @@ -42,7 +42,6 @@ import type { ReadyFrameContext } from '../../../@types/engine/frame/ReadyFrameContext'; import type { EngineState } from '../../../@types/engine/state/EngineState'; -import type { RenderFrameSettings } from '../../../@types/engine/frame/RenderFrameSettings'; import type { GpuTimingService } from '../../../@types/gpu/timing/GpuTimingService'; import type { VolumeFieldId } from '../../../@types/data/volume/VolumeFieldId'; import { encodeVolumes } from './encodeVolumes'; @@ -53,7 +52,6 @@ export function encodeVolumePrepass( encoder: GPUCommandEncoder, ctx: ReadyFrameContext, state: EngineState, - _settings: RenderFrameSettings, timingService: GpuTimingService | null, ): void { if (state.gpu.volumeFieldRenderer !== null) { diff --git a/src/services/engine/frame/passes/diskRadiusRingPass.ts b/src/services/engine/frame/passes/diskRadiusRingPass.ts index 26c96f59..6ac162bf 100644 --- a/src/services/engine/frame/passes/diskRadiusRingPass.ts +++ b/src/services/engine/frame/passes/diskRadiusRingPass.ts @@ -35,7 +35,7 @@ import type { Vec3 } from '../../../../@types/math/Vec3'; export const diskRadiusRingPass: Pass = { name: 'disk-radius-ring', - enabled(state, _ctx, _settings) { + enabled(state, _ctx) { // Handle check first: no ring renderer means nothing to draw, and // short-circuiting here keeps the gate robust against the not-yet- // constructed startup window (and partial test stubs) without @@ -46,7 +46,7 @@ export const diskRadiusRingPass: Pass = { return sel !== null && sel.type === 'galaxyCatalog'; }, - draw(pass, ctx, state, _settings, _deps) { + draw(pass, ctx, state, _deps) { const sel = state.selection.select; // `enabled()` proved a galaxy ref — narrow accordingly. if (sel === null || sel.type !== 'galaxyCatalog') return; diff --git a/src/services/engine/frame/passes/filamentsPass.ts b/src/services/engine/frame/passes/filamentsPass.ts index 03e22c8d..95405bba 100644 --- a/src/services/engine/frame/passes/filamentsPass.ts +++ b/src/services/engine/frame/passes/filamentsPass.ts @@ -70,7 +70,7 @@ export const filamentsPass: Pass = { // Update: the runtime `draw` short-circuits anyway via the // `filamentRenderer === null` early return; `enabled` returning // true with a null renderer is a self-correcting near-miss. - enabled(state, _ctx, _settings) { + enabled(state, _ctx) { // State boolean is the user's intent; opacityOf > 0 is the visual // state. We render whenever EITHER is true so a fade-out continues // drawing after the user toggles off (until opacity hits 0). The @@ -81,7 +81,7 @@ export const filamentsPass: Pass = { return state.subsystems.fades.opacityOf({ kind: 'filament' }, performance.now()) > 0; }, - draw(pass, ctx, state, _settings, deps) { + draw(pass, ctx, state, deps) { // Renderer-null check lives here rather than in `enabled` because // `enabled` doesn't receive `deps`. Keeping this as a defensive // early-return makes the `enabled === true → draw runs` invariant diff --git a/src/services/engine/frame/passes/flowFieldPass.ts b/src/services/engine/frame/passes/flowFieldPass.ts index 6f6d4498..0c960322 100644 --- a/src/services/engine/frame/passes/flowFieldPass.ts +++ b/src/services/engine/frame/passes/flowFieldPass.ts @@ -45,7 +45,7 @@ export const flowFieldPass: Pass = { return state.subsystems.fades.opacityOf({ kind: 'flow' }, performance.now()) > 0; }, - draw(pass, ctx, state, _settings, deps) { + draw(pass, ctx, state, deps) { // Renderer-null check here rather than in `enabled` because `enabled` // doesn't receive `deps` — same pattern as filamentsPass. The renderer's // own `draw` also early-returns until a field is set, so this is belt + diff --git a/src/services/engine/frame/passes/horizonShellPass.ts b/src/services/engine/frame/passes/horizonShellPass.ts index a15330ce..c17a2b8f 100644 --- a/src/services/engine/frame/passes/horizonShellPass.ts +++ b/src/services/engine/frame/passes/horizonShellPass.ts @@ -47,7 +47,7 @@ export const horizonShellPass: Pass = { return horizonShellFadeAlpha(camDistMpc, HORIZON_RADIUS_MPC) > 0; }, - draw(pass, ctx, _state, _settings, deps) { + draw(pass, ctx, _state, deps) { const { cam, canvasSize, drawCamPos } = ctx; const camDistMpc = Math.hypot(drawCamPos[0], drawCamPos[1], drawCamPos[2]); const fadeAlpha = horizonShellFadeAlpha(camDistMpc, HORIZON_RADIUS_MPC); diff --git a/src/services/engine/frame/passes/labelsPass.ts b/src/services/engine/frame/passes/labelsPass.ts index 1ab9c8c7..bca58e47 100644 --- a/src/services/engine/frame/passes/labelsPass.ts +++ b/src/services/engine/frame/passes/labelsPass.ts @@ -43,12 +43,12 @@ import type { Pass } from '../../../../@types/engine/frame/Pass'; export const labelsPass: Pass = { name: 'labels', - enabled(state, _ctx, _settings) { + enabled(state, _ctx) { if (state.gpu.labelRenderer === null) return false; return state.gpu.labelRenderer.glyphCount() > 0; }, - draw(pass, ctx, state, _settings, _deps) { + draw(pass, ctx, state, _deps) { state.gpu.labelRenderer!.render(pass, ctx.vp as Float32Array, [ ctx.canvasSize.width, ctx.canvasSize.height, diff --git a/src/services/engine/frame/passes/markerLinesPass.ts b/src/services/engine/frame/passes/markerLinesPass.ts index 8d45b3cf..81158fd7 100644 --- a/src/services/engine/frame/passes/markerLinesPass.ts +++ b/src/services/engine/frame/passes/markerLinesPass.ts @@ -42,12 +42,12 @@ import type { Pass } from '../../../../@types/engine/frame/Pass'; export const markerLinesPass: Pass = { name: 'marker-lines', - enabled(state, _ctx, _settings) { + enabled(state, _ctx) { if (state.gpu.markerLineRenderer === null) return false; return state.gpu.markerLineRenderer.lineCount() > 0; }, - draw(pass, ctx, state, _settings, _deps) { + draw(pass, ctx, state, _deps) { // `enabled()` proved markerLineRenderer is non-null and has at least // one line. The `!` assertion is safe: the pass framework only calls // `draw` when `enabled` returns true. diff --git a/src/services/engine/frame/passes/milkyWayPass.ts b/src/services/engine/frame/passes/milkyWayPass.ts index 5e75a9e7..1cadebf1 100644 --- a/src/services/engine/frame/passes/milkyWayPass.ts +++ b/src/services/engine/frame/passes/milkyWayPass.ts @@ -51,7 +51,7 @@ import { MILKY_WAY_CENTER_WORLD } from '../../../../data/milkyWay/galacticCenter export const milkyWayPass: Pass = { name: 'milky-way', - enabled(state, ctx, _settings) { + enabled(state, ctx) { // State boolean is the user's intent; opacityOf > 0 keeps the // pass alive through the ~100 ms toggle fade-out tail. The // distance-based milkyWayFadeAlpha still gates separately — if @@ -65,7 +65,7 @@ export const milkyWayPass: Pass = { return milkyWayFadeAlpha(camDistMpc) > 0; }, - draw(pass, ctx, state, _settings, deps) { + draw(pass, ctx, state, deps) { const { vp, canvasSize, drawCamPos } = ctx; const camDistMpc = Math.hypot(drawCamPos[0], drawCamPos[1], drawCamPos[2]); // Composite the distance-based fade with the registry-supplied diff --git a/src/services/engine/frame/passes/pointSpritesPass.ts b/src/services/engine/frame/passes/pointSpritesPass.ts index bfc718fb..e3f9c7cb 100644 --- a/src/services/engine/frame/passes/pointSpritesPass.ts +++ b/src/services/engine/frame/passes/pointSpritesPass.ts @@ -66,7 +66,7 @@ export const pointSpritesPass: Pass = { return true; }, - draw(pass, ctx, state, _settings, _deps) { + draw(pass, ctx, state, _deps) { const { renderer, vp, canvasSize, drawCamPos, drawPxPerRad } = ctx; const { width, height } = canvasSize; diff --git a/src/services/engine/frame/passes/proceduralDisksPass.ts b/src/services/engine/frame/passes/proceduralDisksPass.ts index 8066b371..0a12bd51 100644 --- a/src/services/engine/frame/passes/proceduralDisksPass.ts +++ b/src/services/engine/frame/passes/proceduralDisksPass.ts @@ -22,12 +22,12 @@ import type { Pass } from '../../../../@types/engine/frame/Pass'; export const proceduralDisksPass: Pass = { name: 'procedural-disks', - enabled(state, _ctx, _settings) { + enabled(state, _ctx) { if (!state.settings.thumbnails.enabled) return false; if (state.subsystems.proceduralDisks === null) return false; return state.subsystems.proceduralDisks.lastOutput.instances.length > 0; }, - draw(pass, ctx, state, _settings, deps) { + draw(pass, ctx, state, deps) { const subsys = state.subsystems.proceduralDisks; if (subsys === null) return; const instances = subsys.lastOutput.instances; diff --git a/src/services/engine/frame/passes/selectionRingPass.ts b/src/services/engine/frame/passes/selectionRingPass.ts index 809f5225..49e27687 100644 --- a/src/services/engine/frame/passes/selectionRingPass.ts +++ b/src/services/engine/frame/passes/selectionRingPass.ts @@ -35,14 +35,14 @@ import { selectionRingRadiusPx } from '../../helpers/selectionRingRadiusPx'; export const selectionRingPass: Pass = { name: 'selection-ring', - enabled(state, _ctx, _settings) { + enabled(state, _ctx) { if (state.gpu.selectionRingRenderer === null) return false; const row = state.selectionRows.select; // A row drives the halo iff the table yields a descriptor for its kind. return selectionHalo(row) !== null; }, - draw(pass, ctx, state, _settings, _deps) { + draw(pass, ctx, state, _deps) { const row = state.selectionRows.select; // A null descriptor is the structure arm (it renders its ring through the // cluster marker pass). The descriptor carries both the radius and the diff --git a/src/services/engine/frame/passes/structureMarkersPass.ts b/src/services/engine/frame/passes/structureMarkersPass.ts index 94eb4df4..05f6fded 100644 --- a/src/services/engine/frame/passes/structureMarkersPass.ts +++ b/src/services/engine/frame/passes/structureMarkersPass.ts @@ -26,12 +26,12 @@ import type { Pass } from '../../../../@types/engine/frame/Pass'; export const structureMarkersPass: Pass = { name: 'structure-markers', - enabled(state, _ctx, _settings) { + enabled(state, _ctx) { if (state.gpu.structureMarkerRenderer === null) return false; return state.gpu.structureMarkerRenderer.markerCount() > 0; }, - draw(pass, ctx, state, _settings, _deps) { + draw(pass, ctx, state, _deps) { // fadeOpacity = 1 at v1 — the structure-markers layer has no // FadeRegistry handle yet. The renderer still binds a real fade // group at @group(1) so the BGL matches what filaments (and other diff --git a/src/services/engine/frame/passes/texturedDisksPass.ts b/src/services/engine/frame/passes/texturedDisksPass.ts index ceb48bf9..ae63c8f6 100644 --- a/src/services/engine/frame/passes/texturedDisksPass.ts +++ b/src/services/engine/frame/passes/texturedDisksPass.ts @@ -14,12 +14,12 @@ import type { Pass } from '../../../../@types/engine/frame/Pass'; export const texturedDisksPass: Pass = { name: 'textured-disks', - enabled(state, _ctx, _settings) { + enabled(state, _ctx) { if (!state.settings.thumbnails.enabled) return false; if (state.subsystems.texturedDisks === null) return false; return state.subsystems.texturedDisks.lastOutput.disks.length > 0; }, - draw(pass, ctx, state, _settings, deps) { + draw(pass, ctx, state, deps) { const subsys = state.subsystems.texturedDisks; if (subsys === null) return; const { disks } = subsys.lastOutput; diff --git a/src/services/engine/frame/passes/volumeUpsamplePass.ts b/src/services/engine/frame/passes/volumeUpsamplePass.ts index ac5b5f39..d3dc9643 100644 --- a/src/services/engine/frame/passes/volumeUpsamplePass.ts +++ b/src/services/engine/frame/passes/volumeUpsamplePass.ts @@ -38,7 +38,7 @@ import type { VolumeFieldId } from '../../../../@types/data/volume/VolumeFieldId export const volumeUpsamplePass: Pass = { name: 'volume-upsample', - enabled(state, _ctx, _settings) { + enabled(state, _ctx) { // Pre-bootstrap window: either handle null means initGpu hasn't // finished. Same shape as the old scalarVolumePass gate. if (state.gpu.volumeFieldRenderer === null) return false; @@ -61,7 +61,7 @@ export const volumeUpsamplePass: Pass = { return false; }, - draw(pass, ctx, state, _settings, _deps) { + draw(pass, ctx, state, _deps) { // Defensive null-check — same pattern as filamentsPass / milkyWayPass: // the gate in `enabled` already proved the field is non-null, but // null-checking here too means future gate reorderings can't silently diff --git a/src/services/engine/frame/renderFrame.ts b/src/services/engine/frame/renderFrame.ts index 291d241c..aa28f5ac 100644 --- a/src/services/engine/frame/renderFrame.ts +++ b/src/services/engine/frame/renderFrame.ts @@ -103,7 +103,6 @@ export function renderFrame(input: RenderFrameInput): void { flowFieldRenderer, texturedDiskRenderer, proceduralDiskRenderer, - settings, timingService, } = input; @@ -152,7 +151,7 @@ export function renderFrame(input: RenderFrameInput): void { if (timingService.enabled) { const timingCtx = timingService.beginFrame(); - encodeHdrSplit(encoder, ctx, state, settings, deps, timingService); + encodeHdrSplit(encoder, ctx, state, deps, timingService); ctx.postProcess.draw( encoder, swapView, @@ -165,15 +164,14 @@ export function renderFrame(input: RenderFrameInput): void { swapView, ctx, state, - settings, deps, timingService.descriptorFor('ui-overlay'), ); timingService.endFrame(timingCtx, encoder); } else { - encodeHdrSingle(encoder, ctx, state, settings, deps); + encodeHdrSingle(encoder, ctx, state, deps); ctx.postProcess.draw(encoder, swapView, state.settings.tonemap.exposure, state.settings.tonemap.curve, undefined); - encodeUiOverlay(encoder, swapView, ctx, state, settings, deps, undefined); + encodeUiOverlay(encoder, swapView, ctx, state, deps, undefined); } device.queue.submit([encoder.finish()]); diff --git a/src/services/engine/frame/runFrame.ts b/src/services/engine/frame/runFrame.ts index 31f2805a..d2ef3d88 100644 --- a/src/services/engine/frame/runFrame.ts +++ b/src/services/engine/frame/runFrame.ts @@ -63,10 +63,6 @@ import { deriveSourceMasks } from './deriveSourceMasks'; import { renderFrame } from './renderFrame'; import { reevaluateDemand } from '../wiring/reevaluateDemand'; import { commitCameraPose, cancelCameraTween } from '../../../state/camera/cameraSlice'; -import { - PROCEDURAL_DISK_FADE_START_PX, - PROCEDURAL_DISK_FADE_END_PX, -} from '../subsystems/proceduralDiskSubsystem'; import { updateSelectionHover } from '../../../state/selection/selectionSlice'; /** @@ -350,34 +346,6 @@ export function runFrame(state: EngineState, deps: RunFrameDeps, nowMs: number): texturedDiskRenderer: deps.texturedDiskRenderer, proceduralDiskRenderer: deps.proceduralDiskRenderer, milkyWayITimeSec: (performance.now() - deps.milkyWayITimeEpochMs) * 0.001 * 0.25, - settings: { - pointSizePx: state.settings.galaxyCatalogs.sizePx, - brightness: state.settings.galaxyCatalogs.brightness, - selected: state.selection.select, - visibleSourceMask: masks.draw, - highlightFallback: state.settings.galaxyCatalogs.highlightFallback, - realOnlyMode: state.settings.galaxyCatalogs.realOnly, - biasMode: state.settings.bias.mode, - absMagLimit: state.settings.bias.absMagLimit, - depthFadeEnabled: state.settings.galaxyCatalogs.depthFade, - // Same crossfade band the procedural-disk pass fades IN over, so the two - // passes blend cleanly without a double-bright donut. Constants are the - // single source of truth in `proceduralDiskSubsystem.ts`. - pxFadeStartPoints: PROCEDURAL_DISK_FADE_START_PX, - pxFadeEndPoints: PROCEDURAL_DISK_FADE_END_PX, - // Live cluster-focus uniform (blend ramps 0↔1 over 400 ms; at rest - // blend=0 → shader no-op). Reuses the value computed once at the top of - // the frame — NOT a fresh produceFocusUniforms call, which would - // double-tick the fade controller. - focus: focusUniforms, - exposure: state.settings.tonemap.exposure, - toneMapCurve: state.settings.tonemap.curve, - galaxyTexturesEnabled: state.settings.thumbnails.enabled, - milkyWayEnabled: state.settings.milkyWay.enabled, - filamentsEnabled: state.settings.filaments.enabled, - filamentIntensity: state.settings.filaments.intensity, - volumesEnabled: state.settings.volumes.enabled, - }, timingService: deps.timingService, }); diff --git a/tests/services/engine/frame/passes/filamentsPass.test.ts b/tests/services/engine/frame/passes/filamentsPass.test.ts index 278d594e..1d7f3020 100644 --- a/tests/services/engine/frame/passes/filamentsPass.test.ts +++ b/tests/services/engine/frame/passes/filamentsPass.test.ts @@ -25,7 +25,6 @@ import { filamentsPass } from '../../../../../src/services/engine/frame/passes/f import { FILAMENT_RECESSION } from '../../../../../src/services/engine/presentation/focusRecession'; import type { EngineState } from '../../../../../src/@types/engine/state/EngineState'; import type { ReadyFrameContext } from '../../../../../src/@types/engine/frame/ReadyFrameContext'; -import type { RenderFrameSettings } from '../../../../../src/@types/engine/frame/RenderFrameSettings'; import type { PassDeps } from '../../../../../src/@types/engine/frame/PassDeps'; function makeCtx(focusBlend: number): ReadyFrameContext { @@ -74,11 +73,6 @@ function makeState( } as unknown as EngineState; } -/** Minimal settings stub — the pass no longer reads from this bag. */ -function makeSettings(): RenderFrameSettings { - return {} as RenderFrameSettings; -} - function makeDeps(drawSpy = vi.fn()): PassDeps { return { filamentRenderer: { draw: drawSpy } } as unknown as PassDeps; } @@ -88,7 +82,7 @@ const PASS_STUB = {} as GPURenderPassEncoder; describe('filamentsPass.draw focus recession', () => { it('passes plain opacityOf at blend 0', () => { const drawSpy = vi.fn(); - filamentsPass.draw(PASS_STUB, makeCtx(0), makeState(1), makeSettings(), makeDeps(drawSpy)); + filamentsPass.draw(PASS_STUB, makeCtx(0), makeState(1), makeDeps(drawSpy)); expect(drawSpy).toHaveBeenCalledTimes(1); // Args: (pass, vp, viewport, halfwidth, intensity, opacity). expect(drawSpy.mock.calls[0]![5]).toBe(1); @@ -96,7 +90,7 @@ describe('filamentsPass.draw focus recession', () => { it('passes opacityOf × FILAMENT_RECESSION at blend 1', () => { const drawSpy = vi.fn(); - filamentsPass.draw(PASS_STUB, makeCtx(1), makeState(1), makeSettings(), makeDeps(drawSpy)); + filamentsPass.draw(PASS_STUB, makeCtx(1), makeState(1), makeDeps(drawSpy)); expect(drawSpy).toHaveBeenCalledTimes(1); expect(drawSpy.mock.calls[0]![5]).toBeCloseTo(FILAMENT_RECESSION, 6); }); @@ -106,7 +100,7 @@ describe('filamentsPass.enabled is unaffected by focus recession', () => { it('returns false when the toggle is off and opacity is 0, regardless of blend', () => { // Pass enabled=false via state; settings arg is unused by the pass. const state = makeState(0, { enabled: false }); - expect(filamentsPass.enabled(state, makeCtx(0), makeSettings())).toBe(false); - expect(filamentsPass.enabled(state, makeCtx(1), makeSettings())).toBe(false); + expect(filamentsPass.enabled(state, makeCtx(0))).toBe(false); + expect(filamentsPass.enabled(state, makeCtx(1))).toBe(false); }); }); diff --git a/tests/services/engine/frame/passes/flowFieldPass.test.ts b/tests/services/engine/frame/passes/flowFieldPass.test.ts index 399dfaa8..7afb5179 100644 --- a/tests/services/engine/frame/passes/flowFieldPass.test.ts +++ b/tests/services/engine/frame/passes/flowFieldPass.test.ts @@ -11,7 +11,6 @@ import { describe, it, expect, vi } from 'vitest'; import { flowFieldPass } from '../../../../../src/services/engine/frame/passes/flowFieldPass'; import type { EngineState } from '../../../../../src/@types/engine/state/EngineState'; import type { ReadyFrameContext } from '../../../../../src/@types/engine/frame/ReadyFrameContext'; -import type { RenderFrameSettings } from '../../../../../src/@types/engine/frame/RenderFrameSettings'; import type { PassDeps } from '../../../../../src/@types/engine/frame/PassDeps'; import type { mat4 } from 'gl-matrix'; @@ -56,7 +55,6 @@ function makeState( } as unknown as EngineState; } -const SETTINGS = {} as RenderFrameSettings; const PASS_STUB = { setPipeline: vi.fn(), setBindGroup: vi.fn(), @@ -69,14 +67,13 @@ describe('flowFieldPass.enabled', () => { flowFieldPass.enabled( makeState({ enabled: true, loaded: false, opacity: 1 }), makeCtx(), - SETTINGS, ), ).toBe(false); }); it('returns true when enabled AND loaded', () => { expect( - flowFieldPass.enabled(makeState({ enabled: true, loaded: true }), makeCtx(), SETTINGS), + flowFieldPass.enabled(makeState({ enabled: true, loaded: true }), makeCtx()), ).toBe(true); }); @@ -85,7 +82,6 @@ describe('flowFieldPass.enabled', () => { flowFieldPass.enabled( makeState({ enabled: false, loaded: true, opacity: 0.3 }), makeCtx(), - SETTINGS, ), ).toBe(true); }); @@ -95,7 +91,6 @@ describe('flowFieldPass.enabled', () => { flowFieldPass.enabled( makeState({ enabled: false, loaded: true, opacity: 0 }), makeCtx(), - SETTINGS, ), ).toBe(false); }); @@ -106,7 +101,7 @@ describe('flowFieldPass.draw', () => { const drawSpy = vi.fn(); const deps = { flowFieldRenderer: { draw: drawSpy } } as unknown as PassDeps; const state = makeState({ opacity: 0.42 }); - flowFieldPass.draw(PASS_STUB, makeCtx(), state, SETTINGS, deps); + flowFieldPass.draw(PASS_STUB, makeCtx(), state, deps); expect(drawSpy).toHaveBeenCalledTimes(1); const call = drawSpy.mock.calls[0]!; expect(call[0]).toBe(PASS_STUB); @@ -119,7 +114,7 @@ describe('flowFieldPass.draw', () => { it('does not throw when flowFieldRenderer is null (defensive null-check)', () => { const deps = { flowFieldRenderer: null } as unknown as PassDeps; expect(() => - flowFieldPass.draw(PASS_STUB, makeCtx(), makeState(), SETTINGS, deps), + flowFieldPass.draw(PASS_STUB, makeCtx(), makeState(), deps), ).not.toThrow(); }); }); diff --git a/tests/services/engine/frame/passes/passes.test.ts b/tests/services/engine/frame/passes/passes.test.ts index 90a5e48f..b6310e1c 100644 --- a/tests/services/engine/frame/passes/passes.test.ts +++ b/tests/services/engine/frame/passes/passes.test.ts @@ -19,7 +19,6 @@ import type { mat4 } from 'gl-matrix'; import { Source } from '../../../../../src/data/sources'; import { BiasMode } from '../../../../../src/data/galaxyCatalog/biasMode'; -import { ToneMapCurve } from '../../../../../src/data/toneMapCurve'; import { HDR_PASSES, TIMED_SLOT_NAMES, @@ -31,7 +30,6 @@ import { } from '../../../../../src/services/engine/frame/passes'; import type { PassDeps } from '../../../../../src/@types/engine/frame/PassDeps'; import type { ReadyFrameContext } from '../../../../../src/@types/engine/frame/ReadyFrameContext'; -import type { RenderFrameSettings } from '../../../../../src/@types/engine/frame/RenderFrameSettings'; import type { EngineState } from '../../../../../src/@types/engine/state/EngineState'; import type { OrbitCamera } from '../../../../../src/@types/camera/OrbitCamera'; import type { SelectionRef } from '../../../../../src/@types/engine/SelectionRef'; @@ -91,31 +89,6 @@ function makeCtx(overrides: Partial = {}): ReadyFrameContext }; } -function makeSettings(overrides: Partial = {}): RenderFrameSettings { - return { - pointSizePx: 2.5, - brightness: 1.0, - selected: null, - visibleSourceMask: 0xffffffff, - highlightFallback: true, - realOnlyMode: false, - biasMode: BiasMode.None, - absMagLimit: -19, - depthFadeEnabled: true, - pxFadeStartPoints: 8, - pxFadeEndPoints: 14, - focus: { center: [0, 0, 0], apparentRadiusMpc: 0, physicalRadiusMpc: 0, blend: 0 }, - exposure: 1.0, - toneMapCurve: ToneMapCurve.Reinhard, - galaxyTexturesEnabled: true, - milkyWayEnabled: true, - filamentsEnabled: false, - filamentIntensity: 1, - volumesEnabled: false, - ...overrides, - }; -} - function makeDeps(overrides: Partial = {}): PassDeps { return { texturedDiskRenderer: { draw: vi.fn(), bindAtlas: vi.fn() } as any, @@ -205,14 +178,9 @@ describe('TIMED_SLOT_NAMES registry', () => { describe('pointSpritesPass.enabled', () => { it('always returns true (no user-facing toggle for point-sprites)', () => { - expect(pointSpritesPass.enabled(STATE_STUB, makeCtx(), makeSettings())).toBe(true); + expect(pointSpritesPass.enabled(STATE_STUB, makeCtx())).toBe(true); // Even when every other toggle is off, point-sprites still runs. - const off = makeSettings({ - galaxyTexturesEnabled: false, - milkyWayEnabled: false, - filamentsEnabled: false, - }); - expect(pointSpritesPass.enabled(STATE_STUB, makeCtx(), off)).toBe(true); + expect(pointSpritesPass.enabled(STATE_STUB, makeCtx())).toBe(true); }); }); @@ -225,7 +193,7 @@ describe('proceduralDisksPass.enabled', () => { settings: { thumbnails: { enabled: false } }, } as unknown as EngineState; expect( - proceduralDisksPass.enabled(state, makeCtx(), makeSettings()), + proceduralDisksPass.enabled(state, makeCtx()), ).toBe(false); }); @@ -235,7 +203,7 @@ describe('proceduralDisksPass.enabled', () => { settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; expect( - proceduralDisksPass.enabled(state, makeCtx(), makeSettings()), + proceduralDisksPass.enabled(state, makeCtx()), ).toBe(false); }); @@ -245,7 +213,7 @@ describe('proceduralDisksPass.enabled', () => { settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; expect( - proceduralDisksPass.enabled(state, makeCtx(), makeSettings()), + proceduralDisksPass.enabled(state, makeCtx()), ).toBe(false); }); @@ -255,7 +223,7 @@ describe('proceduralDisksPass.enabled', () => { settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; expect( - proceduralDisksPass.enabled(state, makeCtx(), makeSettings()), + proceduralDisksPass.enabled(state, makeCtx()), ).toBe(true); }); }); @@ -272,7 +240,7 @@ describe('filamentsPass.enabled', () => { settings: { filaments: { enabled: true, intensity: 1 } }, } as unknown as EngineState; expect( - filamentsPass.enabled(stateOn, makeCtx(), makeSettings()), + filamentsPass.enabled(stateOn, makeCtx()), ).toBe(true); }); @@ -284,7 +252,7 @@ describe('filamentsPass.enabled', () => { settings: { filaments: { enabled: false, intensity: 1 } }, } as unknown as EngineState; expect( - filamentsPass.enabled(stateZeroFade, makeCtx(), makeSettings()), + filamentsPass.enabled(stateZeroFade, makeCtx()), ).toBe(false); }); @@ -297,7 +265,7 @@ describe('filamentsPass.enabled', () => { settings: { filaments: { enabled: false, intensity: 1 } }, } as unknown as EngineState; expect( - filamentsPass.enabled(stateOffFading, makeCtx(), makeSettings()), + filamentsPass.enabled(stateOffFading, makeCtx()), ).toBe(true); }); }); @@ -317,7 +285,6 @@ describe('filamentsPass.draw', () => { PASS_STUB, makeCtx(), stateOn, - makeSettings(), deps, ), ).not.toThrow(); @@ -332,7 +299,7 @@ describe('filamentsPass.draw', () => { ...STATE_STUB, settings: { filaments: { enabled: true, intensity: 0.7 } }, } as unknown as EngineState; - filamentsPass.draw(PASS_STUB, ctx, stateWith07, makeSettings(), deps); + filamentsPass.draw(PASS_STUB, ctx, stateWith07, deps); expect(drawSpy).toHaveBeenCalledTimes(1); const args = drawSpy.mock.calls[0]!; expect(args[0]).toBe(PASS_STUB); @@ -352,7 +319,7 @@ describe('milkyWayPass.enabled', () => { settings: { milkyWay: { enabled: true } }, } as unknown as EngineState; expect( - milkyWayPass.enabled(stateOn, makeCtx(), makeSettings()), + milkyWayPass.enabled(stateOn, makeCtx()), ).toBe(true); }); @@ -364,7 +331,7 @@ describe('milkyWayPass.enabled', () => { settings: { milkyWay: { enabled: false } }, } as unknown as EngineState; expect( - milkyWayPass.enabled(stateOffZeroFade, makeCtx(), makeSettings()), + milkyWayPass.enabled(stateOffZeroFade, makeCtx()), ).toBe(false); }); @@ -377,7 +344,7 @@ describe('milkyWayPass.enabled', () => { settings: { milkyWay: { enabled: false } }, } as unknown as EngineState; expect( - milkyWayPass.enabled(stateOffFading, makeCtx(), makeSettings()), + milkyWayPass.enabled(stateOffFading, makeCtx()), ).toBe(true); }); @@ -392,7 +359,7 @@ describe('milkyWayPass.enabled', () => { const ctx = makeCtx({ drawCamPos: [1000, 0, 0] as Readonly<[number, number, number]>, }); - expect(milkyWayPass.enabled(stateOn, ctx, makeSettings())).toBe(false); + expect(milkyWayPass.enabled(stateOn, ctx)).toBe(false); }); }); @@ -403,7 +370,7 @@ describe('milkyWayPass.draw', () => { const drawSpy = vi.fn(); const deps = makeDeps({ milkyWayRenderer: { draw: drawSpy } as any, milkyWayITimeSec: 1.5 }); const ctx = makeCtx(); - milkyWayPass.draw(PASS_STUB, ctx, STATE_STUB, makeSettings(), deps); + milkyWayPass.draw(PASS_STUB, ctx, STATE_STUB, deps); expect(drawSpy).toHaveBeenCalledTimes(1); const args = drawSpy.mock.calls[0]!; expect(args[0]).toBe(PASS_STUB); @@ -421,7 +388,7 @@ describe('horizonShellPass.enabled', () => { // Camera at 5 Mpc is far below the shell's fade-in band (5% of // 14.3 Gpc ≈ 0.7 Gpc), so the pass is skipped — no empty // full-screen ray-march pass at galaxy-scale zoom. - expect(horizonShellPass.enabled(STATE_STUB, makeCtx(), makeSettings())).toBe(false); + expect(horizonShellPass.enabled(STATE_STUB, makeCtx())).toBe(false); }); it('returns true once the camera pulls back to cosmological scale', () => { @@ -429,7 +396,7 @@ describe('horizonShellPass.enabled', () => { const ctx = makeCtx({ drawCamPos: [0, 0, 8000] as Readonly<[number, number, number]>, }); - expect(horizonShellPass.enabled(STATE_STUB, ctx, makeSettings())).toBe(true); + expect(horizonShellPass.enabled(STATE_STUB, ctx)).toBe(true); }); }); @@ -440,7 +407,7 @@ describe('horizonShellPass.draw', () => { const ctx = makeCtx({ drawCamPos: [0, 0, 8000] as Readonly<[number, number, number]>, }); - horizonShellPass.draw(PASS_STUB, ctx, STATE_STUB, makeSettings(), deps); + horizonShellPass.draw(PASS_STUB, ctx, STATE_STUB, deps); expect(drawSpy).toHaveBeenCalledTimes(1); const args = drawSpy.mock.calls[0]!; expect(args[0]).toBe(PASS_STUB); @@ -485,7 +452,7 @@ describe('pointSpritesPass.draw', () => { settings: POINT_SPRITES_SETTINGS_STUB, } as unknown as EngineState; const deps = makeDeps(); - pointSpritesPass.draw(PASS_STUB, ctx, stateWithSelection, makeSettings(), deps); + pointSpritesPass.draw(PASS_STUB, ctx, stateWithSelection, deps); const drawSpy = ctx.renderer.draw as ReturnType; expect(drawSpy).toHaveBeenCalledTimes(1); // Selection lives on arg[3].selectedPacked (the PointDrawSettings @@ -504,7 +471,7 @@ describe('pointSpritesPass.draw', () => { selection: { select: null, hover: null, focus: null }, settings: POINT_SPRITES_SETTINGS_STUB, } as unknown as EngineState; - pointSpritesPass.draw(PASS_STUB, ctx, stateNullSelection, makeSettings(), makeDeps()); + pointSpritesPass.draw(PASS_STUB, ctx, stateNullSelection, makeDeps()); const drawSpy = ctx.renderer.draw as ReturnType; const drawSettings = drawSpy.mock.calls[0]![3] as Record; expect(drawSettings.selectedPacked).toBe(0xffffffff >>> 0); diff --git a/tests/services/engine/frame/passes/proceduralDisksPass.test.ts b/tests/services/engine/frame/passes/proceduralDisksPass.test.ts index 16568c10..c84b53c7 100644 --- a/tests/services/engine/frame/passes/proceduralDisksPass.test.ts +++ b/tests/services/engine/frame/passes/proceduralDisksPass.test.ts @@ -3,7 +3,6 @@ import type { mat4 } from 'gl-matrix'; import { proceduralDisksPass } from '../../../../../src/services/engine/frame/passes/proceduralDisksPass'; import type { PassDeps } from '../../../../../src/@types/engine/frame/PassDeps'; import type { ReadyFrameContext } from '../../../../../src/@types/engine/frame/ReadyFrameContext'; -import type { RenderFrameSettings } from '../../../../../src/@types/engine/frame/RenderFrameSettings'; import type { EngineState } from '../../../../../src/@types/engine/state/EngineState'; import type { OrbitCamera } from '../../../../../src/@types/camera/OrbitCamera'; @@ -51,10 +50,6 @@ function makeCtx(overrides: Partial = {}): ReadyFrameContext }; } -function makeSettings(): RenderFrameSettings { - return { galaxyTexturesEnabled: true } as RenderFrameSettings; -} - function makeDeps(): PassDeps { return { texturedQuadRenderer: { draw: vi.fn(), bindAtlas: vi.fn() } as any, @@ -81,7 +76,7 @@ describe('proceduralDisksPass', () => { subsystems: { proceduralDisks: null }, settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; - expect(proceduralDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(false); + expect(proceduralDisksPass.enabled(state, makeCtx())).toBe(false); }); it('enabled() returns false when state.settings.thumbnails.enabled is false', () => { @@ -89,7 +84,7 @@ describe('proceduralDisksPass', () => { subsystems: { proceduralDisks: { lastOutput: { instances: [{}] } } }, settings: { thumbnails: { enabled: false } }, } as unknown as EngineState; - expect(proceduralDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(false); + expect(proceduralDisksPass.enabled(state, makeCtx())).toBe(false); }); it('enabled() returns false when lastOutput.instances is empty', () => { @@ -97,7 +92,7 @@ describe('proceduralDisksPass', () => { subsystems: { proceduralDisks: { lastOutput: { instances: [] } } }, settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; - expect(proceduralDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(false); + expect(proceduralDisksPass.enabled(state, makeCtx())).toBe(false); }); it('enabled() returns true with a non-empty lastOutput', () => { @@ -105,7 +100,7 @@ describe('proceduralDisksPass', () => { subsystems: { proceduralDisks: { lastOutput: { instances: [{}] } } }, settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; - expect(proceduralDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(true); + expect(proceduralDisksPass.enabled(state, makeCtx())).toBe(true); }); it('draw() forwards instances to proceduralDiskRenderer.draw', () => { @@ -117,7 +112,7 @@ describe('proceduralDisksPass', () => { } as unknown as EngineState; const deps = makeDeps(); const pass = {} as GPURenderPassEncoder; - proceduralDisksPass.draw(pass, makeCtx(), state, makeSettings(), deps); + proceduralDisksPass.draw(pass, makeCtx(), state, deps); expect(deps.proceduralDiskRenderer.draw).toHaveBeenCalledTimes(1); const call = (deps.proceduralDiskRenderer.draw as any).mock.calls[0]; // Args: (pass, vp, viewport, camPos, pxPerRad, focusBindGroup, instances). diff --git a/tests/services/engine/frame/passes/selectionRingPass.test.ts b/tests/services/engine/frame/passes/selectionRingPass.test.ts index cf323f57..723b4ab5 100644 --- a/tests/services/engine/frame/passes/selectionRingPass.test.ts +++ b/tests/services/engine/frame/passes/selectionRingPass.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from 'vitest'; import { selectionRingPass } from '../../../../../src/services/engine/frame/passes/selectionRingPass'; import type { EngineState } from '../../../../../src/@types/engine/state/EngineState'; import type { ReadyFrameContext } from '../../../../../src/@types/engine/frame/ReadyFrameContext'; -import type { RenderFrameSettings } from '../../../../../src/@types/engine/frame/RenderFrameSettings'; import type { PassDeps } from '../../../../../src/@types/engine/frame/PassDeps'; import type { mat4 } from 'gl-matrix'; import { Source } from '../../../../../src/data/sources'; @@ -31,10 +30,6 @@ function makeCtx(): ReadyFrameContext { }; } -function makeSettings(overrides: Partial = {}): RenderFrameSettings { - return { pointSizePx: 4, ...overrides } as RenderFrameSettings; -} - function makeStateWithSizePx(row: SelectionRow | null, sizePx: number): EngineState { return { gpu: { selectionRingRenderer: makeRendererSpy() }, @@ -117,32 +112,32 @@ describe('selectionRingPass.enabled', () => { gpu: { selectionRingRenderer: null }, selectionRows: { select: null, focus: null, hover: null }, } as unknown as EngineState; - expect(selectionRingPass.enabled(state, makeCtx(), makeSettings())).toBe(false); + expect(selectionRingPass.enabled(state, makeCtx())).toBe(false); }); it('returns false when nothing is selected', () => { const state = makeStateWithSelection(null); - expect(selectionRingPass.enabled(state, makeCtx(), makeSettings())).toBe(false); + expect(selectionRingPass.enabled(state, makeCtx())).toBe(false); }); it('returns true when renderer is non-null and a galaxy row is selected', () => { const state = makeStateWithSelection(galaxyRow()); - expect(selectionRingPass.enabled(state, makeCtx(), makeSettings())).toBe(true); + expect(selectionRingPass.enabled(state, makeCtx())).toBe(true); }); it('is true when the Milky Way row is selected', () => { const state = makeStateWithSelection(MILKY_WAY_ROW); - expect(selectionRingPass.enabled(state, makeCtx(), makeSettings())).toBe(true); + expect(selectionRingPass.enabled(state, makeCtx())).toBe(true); }); it('stays false for a structure row (marker pass owns that halo)', () => { const state = makeStateWithSelection(structureRow() as SelectionRow); - expect(selectionRingPass.enabled(state, makeCtx(), makeSettings())).toBe(false); + expect(selectionRingPass.enabled(state, makeCtx())).toBe(false); }); it('stays true for a galaxy row (regression)', () => { const state = makeStateWithSelection(galaxyRow()); - expect(selectionRingPass.enabled(state, makeCtx(), makeSettings())).toBe(true); + expect(selectionRingPass.enabled(state, makeCtx())).toBe(true); }); }); @@ -155,7 +150,6 @@ describe('selectionRingPass.draw', () => { PASS_STUB, makeCtx(), state, - makeSettings(), DEPS_STUB, ); @@ -183,7 +177,6 @@ describe('selectionRingPass.draw', () => { PASS_STUB, makeCtx(), state, - makeSettings(), DEPS_STUB, ); const rendererSpy = state.gpu.selectionRingRenderer as unknown as ReturnType< @@ -197,7 +190,7 @@ describe('selectionRingPass.draw', () => { it('draws the ring at MILKY_WAY_CENTER_WORLD for a milkyWay row', () => { const state = makeStateWithSizePx(MILKY_WAY_ROW, 4); - selectionRingPass.draw(PASS_STUB, makeCtx(), state, makeSettings(), DEPS_STUB); + selectionRingPass.draw(PASS_STUB, makeCtx(), state, DEPS_STUB); const rendererSpy = state.gpu.selectionRingRenderer as unknown as ReturnType< typeof makeRendererSpy @@ -213,7 +206,7 @@ describe('selectionRingPass.draw', () => { it('calls renderer.draw() exactly once with viewProj + viewport', () => { const state = makeStateWithSizePx(galaxyRow(), 4); - selectionRingPass.draw(PASS_STUB, makeCtx(), state, makeSettings(), DEPS_STUB); + selectionRingPass.draw(PASS_STUB, makeCtx(), state, DEPS_STUB); const rendererSpy = state.gpu.selectionRingRenderer as unknown as ReturnType< typeof makeRendererSpy >; diff --git a/tests/services/engine/frame/passes/texturedDisksPass.test.ts b/tests/services/engine/frame/passes/texturedDisksPass.test.ts index 353e6aa7..3d2e4b6a 100644 --- a/tests/services/engine/frame/passes/texturedDisksPass.test.ts +++ b/tests/services/engine/frame/passes/texturedDisksPass.test.ts @@ -3,7 +3,6 @@ import type { mat4 } from 'gl-matrix'; import { texturedDisksPass } from '../../../../../src/services/engine/frame/passes/texturedDisksPass'; import type { PassDeps } from '../../../../../src/@types/engine/frame/PassDeps'; import type { ReadyFrameContext } from '../../../../../src/@types/engine/frame/ReadyFrameContext'; -import type { RenderFrameSettings } from '../../../../../src/@types/engine/frame/RenderFrameSettings'; import type { EngineState } from '../../../../../src/@types/engine/state/EngineState'; import type { OrbitCamera } from '../../../../../src/@types/camera/OrbitCamera'; @@ -49,10 +48,6 @@ function makeCtx(): ReadyFrameContext { }; } -function makeSettings(overrides: Partial = {}): RenderFrameSettings { - return { galaxyTexturesEnabled: true, ...overrides } as RenderFrameSettings; -} - function makeDeps(): PassDeps { return { texturedDiskRenderer: { draw: vi.fn(), bindAtlas: vi.fn() } as any, @@ -79,7 +74,7 @@ describe('texturedDisksPass', () => { settings: { thumbnails: { enabled: false } }, } as unknown as EngineState; expect( - texturedDisksPass.enabled(state, makeCtx(), makeSettings()), + texturedDisksPass.enabled(state, makeCtx()), ).toBe(false); }); @@ -88,7 +83,7 @@ describe('texturedDisksPass', () => { subsystems: { texturedDisks: null }, settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; - expect(texturedDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(false); + expect(texturedDisksPass.enabled(state, makeCtx())).toBe(false); }); it('enabled() returns false when disks array is empty', () => { @@ -96,7 +91,7 @@ describe('texturedDisksPass', () => { subsystems: { texturedDisks: { lastOutput: { disks: [] } } }, settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; - expect(texturedDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(false); + expect(texturedDisksPass.enabled(state, makeCtx())).toBe(false); }); it('enabled() returns true when disks array is non-empty', () => { @@ -104,7 +99,7 @@ describe('texturedDisksPass', () => { subsystems: { texturedDisks: { lastOutput: { disks: [{}] } } }, settings: { thumbnails: { enabled: true } }, } as unknown as EngineState; - expect(texturedDisksPass.enabled(state, makeCtx(), makeSettings())).toBe(true); + expect(texturedDisksPass.enabled(state, makeCtx())).toBe(true); }); it('draw() invokes texturedDiskRenderer.draw', () => { @@ -114,7 +109,7 @@ describe('texturedDisksPass', () => { gpu: { focusUniform: { bindGroup: {} as GPUBindGroup } }, } as unknown as EngineState; const deps = makeDeps(); - texturedDisksPass.draw({} as GPURenderPassEncoder, makeCtx(), state, makeSettings(), deps); + texturedDisksPass.draw({} as GPURenderPassEncoder, makeCtx(), state, deps); expect(deps.texturedDiskRenderer.draw).toHaveBeenCalledTimes(1); }); @@ -123,7 +118,7 @@ describe('texturedDisksPass', () => { subsystems: { texturedDisks: { lastOutput: { disks: [] } } }, } as unknown as EngineState; const deps = makeDeps(); - texturedDisksPass.draw({} as GPURenderPassEncoder, makeCtx(), state, makeSettings(), deps); + texturedDisksPass.draw({} as GPURenderPassEncoder, makeCtx(), state, deps); expect(deps.texturedDiskRenderer.draw).not.toHaveBeenCalled(); }); }); diff --git a/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts b/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts index d1ecb248..47f8b9f1 100644 --- a/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts +++ b/tests/services/engine/frame/passes/volumeUpsamplePass.test.ts @@ -26,7 +26,6 @@ import { describe, it, expect, vi } from 'vitest'; import { volumeUpsamplePass } from '../../../../../src/services/engine/frame/passes/volumeUpsamplePass'; import type { EngineState } from '../../../../../src/@types/engine/state/EngineState'; import type { ReadyFrameContext } from '../../../../../src/@types/engine/frame/ReadyFrameContext'; -import type { RenderFrameSettings } from '../../../../../src/@types/engine/frame/RenderFrameSettings'; import type { PassDeps } from '../../../../../src/@types/engine/frame/PassDeps'; import type { mat4 } from 'gl-matrix'; @@ -58,10 +57,6 @@ function makeCtx(offscreenView: GPUTextureView = {} as GPUTextureView): ReadyFra }; } -function makeSettings(overrides: Partial = {}): RenderFrameSettings { - return { volumesEnabled: true, ...(overrides as object) } as RenderFrameSettings; -} - const PASS_STUB = { setPipeline: vi.fn(), setBindGroup: vi.fn(), @@ -87,7 +82,7 @@ describe('volumeUpsamplePass.enabled', () => { settings: { volumes: { enabled: false } }, } as unknown as EngineState; expect( - volumeUpsamplePass.enabled(state, makeCtx(), makeSettings()), + volumeUpsamplePass.enabled(state, makeCtx()), ).toBe(false); }); @@ -106,7 +101,7 @@ describe('volumeUpsamplePass.enabled', () => { subsystems: { fades: { opacityOf: () => 0 } }, settings: { volumes: { enabled: true } }, } as unknown as EngineState; - expect(volumeUpsamplePass.enabled(state, makeCtx(), makeSettings())).toBe(false); + expect(volumeUpsamplePass.enabled(state, makeCtx())).toBe(false); }); it('returns false when volumeUpsample is null (pre-bootstrap)', () => { @@ -116,7 +111,7 @@ describe('volumeUpsamplePass.enabled', () => { volumeUpsample: null, }, } as unknown as EngineState; - expect(volumeUpsamplePass.enabled(state, makeCtx(), makeSettings())).toBe(false); + expect(volumeUpsamplePass.enabled(state, makeCtx())).toBe(false); }); it('returns false when volumeFieldRenderer is null (pre-bootstrap)', () => { @@ -126,7 +121,7 @@ describe('volumeUpsamplePass.enabled', () => { volumeUpsample: { draw: vi.fn(), destroy: vi.fn() }, }, } as unknown as EngineState; - expect(volumeUpsamplePass.enabled(state, makeCtx(), makeSettings())).toBe(false); + expect(volumeUpsamplePass.enabled(state, makeCtx())).toBe(false); }); it('returns true when every gate passes', () => { @@ -138,7 +133,7 @@ describe('volumeUpsamplePass.enabled', () => { subsystems: { fades: { opacityOf: () => 1 } }, settings: { volumes: { enabled: true } }, } as unknown as EngineState; - expect(volumeUpsamplePass.enabled(state, makeCtx(), makeSettings())).toBe(true); + expect(volumeUpsamplePass.enabled(state, makeCtx())).toBe(true); }); }); @@ -156,7 +151,7 @@ describe('volumeUpsamplePass.draw', () => { volumeUpsample: { draw: drawSpy, destroy: vi.fn() }, }, } as unknown as EngineState; - volumeUpsamplePass.draw(PASS_STUB, makeCtx(offscreenView), state, makeSettings(), DEPS_STUB); + volumeUpsamplePass.draw(PASS_STUB, makeCtx(offscreenView), state, DEPS_STUB); expect(drawSpy).toHaveBeenCalledTimes(1); expect((drawSpy as ReturnType).mock.calls[0]![0]).toBe(PASS_STUB); expect((drawSpy as ReturnType).mock.calls[0]![1]).toBe(offscreenView); @@ -170,7 +165,7 @@ describe('volumeUpsamplePass.draw', () => { }, } as unknown as EngineState; expect(() => - volumeUpsamplePass.draw(PASS_STUB, makeCtx(), state, makeSettings(), DEPS_STUB), + volumeUpsamplePass.draw(PASS_STUB, makeCtx(), state, DEPS_STUB), ).not.toThrow(); }); }); diff --git a/tests/services/engine/frame/renderFrame.test.ts b/tests/services/engine/frame/renderFrame.test.ts index 820113e8..682a620b 100644 --- a/tests/services/engine/frame/renderFrame.test.ts +++ b/tests/services/engine/frame/renderFrame.test.ts @@ -291,6 +291,10 @@ function makeInput( canvasWidth, canvasHeight, viewProj, + // Expose the local settings bag so tests can assert against it + // (e.g. exposure, toneMapCurve) without reaching into input.settings, + // which no longer exists on RenderFrameInput. + settings, input: { ctx, // Passes read engine state via `input.state`. The label + @@ -358,7 +362,6 @@ function makeInput( texturedQuadRenderer, texturedDiskRenderer, proceduralDiskRenderer, - settings, // Disabled stub (`service.enabled === false`) → renderFrame takes // the single-pass branch. Active-mode behaviour lives in // `renderFrame.timing.test.ts`. @@ -424,21 +427,21 @@ describe('renderFrame', () => { expect(args[1]).toBe(fx.viewProj); expect(args[2]).toEqual([fx.canvasWidth, fx.canvasHeight]); const drawSettings = args[3] as Record; - expect(drawSettings.pointSizePx).toBe(fx.input.settings.pointSizePx); - expect(drawSettings.brightness).toBe(fx.input.settings.brightness); + expect(drawSettings.pointSizePx).toBe(fx.settings.pointSizePx); + expect(drawSettings.brightness).toBe(fx.settings.brightness); // selected null → 0xffffffff packed sentinel expect(drawSettings.selectedPacked).toBe(0xffffffff >>> 0); - expect(drawSettings.visibleSourceMask).toBe(fx.input.settings.visibleSourceMask); + expect(drawSettings.visibleSourceMask).toBe(fx.settings.visibleSourceMask); // camPos is a 3-tuple snapshot from cam.position expect(Array.from(drawSettings.camPosWorld as ArrayLike)).toEqual([0, 0, 5]); // pxPerRad = h / (2 · tan(fovY/2)) const expectedPxPerRad = fx.canvasHeight / (2 * Math.tan(fx.cam.fovYRad / 2)); expect(drawSettings.pxPerRad as number).toBeCloseTo(expectedPxPerRad, 6); - expect(drawSettings.highlightFallback).toBe(fx.input.settings.highlightFallback); - expect(drawSettings.realOnlyMode).toBe(fx.input.settings.realOnlyMode); - expect(drawSettings.biasMode).toBe(fx.input.settings.biasMode); - expect(drawSettings.absMagLimit).toBe(fx.input.settings.absMagLimit); - expect(drawSettings.depthFadeEnabled).toBe(fx.input.settings.depthFadeEnabled); + expect(drawSettings.highlightFallback).toBe(fx.settings.highlightFallback); + expect(drawSettings.realOnlyMode).toBe(fx.settings.realOnlyMode); + expect(drawSettings.biasMode).toBe(fx.settings.biasMode); + expect(drawSettings.absMagLimit).toBe(fx.settings.absMagLimit); + expect(drawSettings.depthFadeEnabled).toBe(fx.settings.depthFadeEnabled); }); it('packs (source, index) into the selectedPacked u32 sent to pointRenderer.draw', () => { @@ -479,8 +482,8 @@ describe('renderFrame', () => { const args = draw.mock.calls[0]!; expect(args[0]).toBe(fx.env.encoder); expect(args[1]).toBe(fx.swapView); - expect(args[2]).toBe(fx.input.settings.exposure); - expect(args[3]).toBe(fx.input.settings.toneMapCurve); + expect(args[2]).toBe(fx.settings.exposure); + expect(args[3]).toBe(fx.settings.toneMapCurve); }); it('records full frame in the canonical order: createEncoder → HDR pass (begin + draws + end) → postProcess.draw → encoder.finish → submit', () => { diff --git a/tests/services/engine/frame/renderFrame.timing.test.ts b/tests/services/engine/frame/renderFrame.timing.test.ts index fab19475..2c13fecd 100644 --- a/tests/services/engine/frame/renderFrame.timing.test.ts +++ b/tests/services/engine/frame/renderFrame.timing.test.ts @@ -262,7 +262,6 @@ function makeMinimalInputWithTiming(timingService: GpuTimingService): { flowFieldRenderer: null, texturedDiskRenderer: texturedDiskRenderer as never, proceduralDiskRenderer: proceduralDiskRenderer as never, - settings: settings as never, timingService, }; @@ -363,7 +362,6 @@ describe('renderFrame — timing service hookup', () => { const { input, beginCalls } = makeMinimalInputWithTiming(svc); // Force volumes on with an active volumeFieldRenderer. - (input.settings as any).volumesEnabled = true; (input.state as any).settings.volumes = { enabled: true }; const drawSpy = vi.fn(); (input as any).volumeFieldRenderer = { diff --git a/tests/visual/renderFrameSplitBaseline.test.ts b/tests/visual/renderFrameSplitBaseline.test.ts index 035f1834..9c87b79a 100644 --- a/tests/visual/renderFrameSplitBaseline.test.ts +++ b/tests/visual/renderFrameSplitBaseline.test.ts @@ -389,7 +389,6 @@ describe('renderFrame visual baseline', () => { flowFieldRenderer: null, texturedDiskRenderer: texturedDiskRenderer as never, proceduralDiskRenderer: proceduralDiskRenderer as never, - settings: settings as never, // Disabled stub forces the single-pass path. The split-pass // (timing-on) shape is exercised in `renderFrame.timing.test.ts`. timingService: createDisabledGpuTimingService(), From 639fabc09d95d47d0705f4777f9d0db7e451958a Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 21 Jun 2026 02:59:58 +0200 Subject: [PATCH 13/14] test(frame): drop dead catalogs/famousMeta keys from two makeDeps helpers Final-review tidy: these per-pass test makeDeps() literals still built the catalogs/famousMeta keys that this branch deleted from PassDeps. They were inert (an `as PassDeps` cast suppressed the excess-property error). Removing them finishes the dead-key deletion across all fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/services/engine/frame/passes/proceduralDisksPass.test.ts | 2 -- tests/services/engine/frame/passes/texturedDisksPass.test.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/tests/services/engine/frame/passes/proceduralDisksPass.test.ts b/tests/services/engine/frame/passes/proceduralDisksPass.test.ts index c84b53c7..946d01cd 100644 --- a/tests/services/engine/frame/passes/proceduralDisksPass.test.ts +++ b/tests/services/engine/frame/passes/proceduralDisksPass.test.ts @@ -60,8 +60,6 @@ function makeDeps(): PassDeps { flowFieldRenderer: null, milkyWayRenderer: { draw: vi.fn() } as any, horizonShellRenderer: { draw: vi.fn() } as any, - catalogs: new Map(), - famousMeta: [], milkyWayITimeSec: 0, } as PassDeps; } diff --git a/tests/services/engine/frame/passes/texturedDisksPass.test.ts b/tests/services/engine/frame/passes/texturedDisksPass.test.ts index 3d2e4b6a..f86042c2 100644 --- a/tests/services/engine/frame/passes/texturedDisksPass.test.ts +++ b/tests/services/engine/frame/passes/texturedDisksPass.test.ts @@ -57,8 +57,6 @@ function makeDeps(): PassDeps { flowFieldRenderer: null, milkyWayRenderer: { draw: vi.fn() } as any, horizonShellRenderer: { draw: vi.fn() } as any, - catalogs: new Map(), - famousMeta: [], milkyWayITimeSec: 0, } as PassDeps; } From 7c06aa0f98d0e99e123c80f597e3def161124590 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 21 Jun 2026 03:00:42 +0200 Subject: [PATCH 14/14] docs(plan): mark dissolve-render-frame-settings complete DoD audit PASS: full suite 2978 green, typecheck clean (both tsconfigs), all 11 task checkboxes ticked, no unowned TODOs, no comment-style smells, visual-parity smoke test confirmed this session, whole-branch review APPROVE_WITH_MINOR (both Minor findings fixed). Relocates the plan + spec to completed/. Deferred (documented): selector/useSettings unification and the labels.declutterEnabled feature (next PR); GPU-handle nullability follow-up stays in BACKLOG. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-06-21-dissolve-render-frame-settings.md | 110 +++++++++--------- ...1-dissolve-render-frame-settings-design.md | 0 2 files changed, 56 insertions(+), 54 deletions(-) rename docs/superpowers/plans/{ => completed}/2026-06-21-dissolve-render-frame-settings.md (87%) rename docs/superpowers/specs/{ => completed}/2026-06-21-dissolve-render-frame-settings-design.md (100%) diff --git a/docs/superpowers/plans/2026-06-21-dissolve-render-frame-settings.md b/docs/superpowers/plans/completed/2026-06-21-dissolve-render-frame-settings.md similarity index 87% rename from docs/superpowers/plans/2026-06-21-dissolve-render-frame-settings.md rename to docs/superpowers/plans/completed/2026-06-21-dissolve-render-frame-settings.md index 048c3391..0a6ef1c8 100644 --- a/docs/superpowers/plans/2026-06-21-dissolve-render-frame-settings.md +++ b/docs/superpowers/plans/completed/2026-06-21-dissolve-render-frame-settings.md @@ -1,6 +1,8 @@ # Dissolve `RenderFrameSettings`: passes read from `state` + `ctx` > **Spec:** [`docs/superpowers/specs/2026-06-21-dissolve-render-frame-settings-design.md`](../specs/2026-06-21-dissolve-render-frame-settings-design.md). This plan implements exactly that spec — read it first; it carries the rationale and the Definition of Done. +> +> **Status: SHIPPED.** All 11 tasks executed via subagent-driven development; `RenderFrameSettings` deleted, every pass reads from `state`/`ctx`/constants. Typecheck clean (both tsconfigs), full suite 2978 green, manual visual-parity confirmed, whole-branch review APPROVE_WITH_MINOR (both Minor findings fixed). Behaviour-neutral. ## Goal @@ -49,7 +51,7 @@ TypeScript + Vitest. No new dependencies. The on-disk binary format is untouched | `filamentIntensity` | `state.settings.filaments.intensity` | | `volumesEnabled` | `state.settings.volumes.enabled` | -> **For agentic workers:** execute this plan with the `subagent-driven-development` workflow — a fresh implementer subagent per task, dispatched `run_in_background: true`. The main thread runs `npm test` / `npm run typecheck` and commits; implementers only edit. Tick each task's `- [ ]` to `- [x]` in the same response as the TaskUpdate. Front-load constraints in each dispatch (sequential bash, Read/Grep not sed, absolute worktree paths, typed `vi.fn`). Implementers: if a clean implementation is blocked, STOP and report — don't hack around it. +> **For agentic workers:** execute this plan with the `subagent-driven-development` workflow — a fresh implementer subagent per task, dispatched `run_in_background: true`. The main thread runs `npm test` / `npm run typecheck` and commits; implementers only edit. Tick each task's `- [x]` to `- [x]` in the same response as the TaskUpdate. Front-load constraints in each dispatch (sequential bash, Read/Grep not sed, absolute worktree paths, typed `vi.fn`). Implementers: if a clean implementation is blocked, STOP and report — don't hack around it. --- @@ -61,11 +63,11 @@ TypeScript + Vitest. No new dependencies. The on-disk binary format is untouched **Why pure deletion:** no pass or `encode*` reads `deps.catalogs` / `deps.famousMeta`. The thumbnail subsystem — the field's claimed consumer per the stale `PassDeps` docblock — reads `state.data.galaxies.catalogs` / `.famousMeta` **directly** at `runFrame.ts:303-308`. No consumer is re-pointed. -- [ ] Remove the two fields + their now-unused `GalaxyCatalog` / `SourceType` / `FamousMetaEntry` imports from `RenderFrameInput.d.ts` and `PassDeps.d.ts` (check which imports go unused after removal; `RenderFrameInput` keeps `GalaxyCatalog`/`SourceType` only if something else uses them — verify). -- [ ] Remove `catalogs` / `famousMeta` from the `deps` literal in `renderFrame.ts` and from the `renderFrame({ … })` call in `runFrame.ts`. -- [ ] Drop the fixture keys + unused `catalogs` local from `renderFrame.test.ts`. -- [ ] `npm run typecheck` clean; `npm test -- renderFrame` green. `grep -rn "catalogs\|famousMeta" src/@types/engine/frame/` shows neither. -- [ ] Commit (`git add` the specific paths). +- [x] Remove the two fields + their now-unused `GalaxyCatalog` / `SourceType` / `FamousMetaEntry` imports from `RenderFrameInput.d.ts` and `PassDeps.d.ts` (check which imports go unused after removal; `RenderFrameInput` keeps `GalaxyCatalog`/`SourceType` only if something else uses them — verify). +- [x] Remove `catalogs` / `famousMeta` from the `deps` literal in `renderFrame.ts` and from the `renderFrame({ … })` call in `runFrame.ts`. +- [x] Drop the fixture keys + unused `catalogs` local from `renderFrame.test.ts`. +- [x] `npm run typecheck` clean; `npm test -- renderFrame` green. `grep -rn "catalogs\|famousMeta" src/@types/engine/frame/` shows neither. +- [x] Commit (`git add` the specific paths). --- @@ -101,12 +103,12 @@ Set `visibleSourceMask` at construction (the ready-branch return literal, `frame Nothing reads the new ctx fields yet; `RenderFrameSettings` still carries them. Suite stays green. -- [ ] Add the two fields to `ReadyFrameContext.d.ts` with the `FocusUniformsValue` import. -- [ ] Add the `visibleSourceMask` arg to `deriveFrameContext`; set it at construction; seed `focus` (placeholder, mirroring `focusBlend`). -- [ ] Wire `runFrame`: `masks.draw` into the call; `ctx.focus = focusUniforms` at `:273`. -- [ ] In `frameContext.test.ts`, add a test `deriveFrameContext exposes visibleSourceMask and a seeded focus on the ready context` asserting `ctx.visibleSourceMask` equals the passed mask and `ctx.focus.blend === 0`. Update the existing `deriveFrameContext` call sites in that file to pass the new arg. -- [ ] `npm run typecheck` clean; `npm test -- frameContext runFrame` green. -- [ ] Commit. +- [x] Add the two fields to `ReadyFrameContext.d.ts` with the `FocusUniformsValue` import. +- [x] Add the `visibleSourceMask` arg to `deriveFrameContext`; set it at construction; seed `focus` (placeholder, mirroring `focusBlend`). +- [x] Wire `runFrame`: `masks.draw` into the call; `ctx.focus = focusUniforms` at `:273`. +- [x] In `frameContext.test.ts`, add a test `deriveFrameContext exposes visibleSourceMask and a seeded focus on the ready context` asserting `ctx.visibleSourceMask` equals the passed mask and `ctx.focus.blend === 0`. Update the existing `deriveFrameContext` call sites in that file to pass the new arg. +- [x] `npm run typecheck` clean; `npm test -- frameContext runFrame` green. +- [x] Commit. --- @@ -129,11 +131,11 @@ Update the module-header "What it reads" block (`:29-32`) to name the real sourc **Test:** the two `pointSpritesPass.draw` tests (`packs (source, index)…`, `translates null selection…`) currently set selection via `makeSettings({ selected })`. Drive selection via `state.selection.select` instead (extend `STATE_STUB` or pass an override-state). The `STATE_STUB` (`:137-149`) needs `selection`, `settings.{galaxyCatalogs,bias}`, and `ctx.visibleSourceMask` populated enough that the draw runs. Assertions on `drawSettings.selectedPacked` are unchanged. -- [ ] Re-source every `PointDrawSettings` field per the mapping; import the two fade constants. -- [ ] Update the module-header read-list comment. -- [ ] Rewrite the two draw tests to drive selection via `state.selection.select`; extend the state stub with the needed settings/selection/ctx fields. -- [ ] `npm test -- passes` green. -- [ ] Commit. +- [x] Re-source every `PointDrawSettings` field per the mapping; import the two fade constants. +- [x] Update the module-header read-list comment. +- [x] Rewrite the two draw tests to drive selection via `state.selection.select`; extend the state stub with the needed settings/selection/ctx fields. +- [x] `npm test -- passes` green. +- [x] Commit. ### Task 4: `milkyWayPass.enabled` reads `state.settings.milkyWay.enabled` @@ -141,10 +143,10 @@ Update the module-header "What it reads" block (`:29-32`) to name the real sourc `settings.milkyWayEnabled` → `state.settings.milkyWay.enabled` (`:61`). `draw` already ignores settings (`_settings`). Update the `### What it reads` note (`:35`). -- [ ] Re-source the gate; update the comment. -- [ ] Rewrite the three `milkyWayPass.enabled` tests + the `milkyWayPass.draw` test to set `state.settings.milkyWay.enabled` (extend the state stub) instead of `makeSettings({ milkyWayEnabled })`. -- [ ] `npm test -- passes` green. -- [ ] Commit. +- [x] Re-source the gate; update the comment. +- [x] Rewrite the three `milkyWayPass.enabled` tests + the `milkyWayPass.draw` test to set `state.settings.milkyWay.enabled` (extend the state stub) instead of `makeSettings({ milkyWayEnabled })`. +- [x] `npm test -- passes` green. +- [x] Commit. ### Task 5: `filamentsPass` reads `state.settings.filaments.{enabled,intensity}` @@ -152,10 +154,10 @@ Update the module-header "What it reads" block (`:29-32`) to name the real sourc `enabled`: `settings.filamentsEnabled` → `state.settings.filaments.enabled` (`:74`). `draw`: `settings.filamentIntensity` → `state.settings.filaments.intensity` (`:96`). -- [ ] Re-source both reads. -- [ ] Rewrite `filamentsPass.test.ts` (drop its local `makeSettings`, drive `enabled`/`intensity` via state) and the `filamentsPass.enabled`/`.draw` blocks in `passes.test.ts`. The `forwards correct args` assertion on `args[4] === 0.7` now comes from `state.settings.filaments.intensity = 0.7`. -- [ ] `npm test -- filamentsPass passes` green. -- [ ] Commit. +- [x] Re-source both reads. +- [x] Rewrite `filamentsPass.test.ts` (drop its local `makeSettings`, drive `enabled`/`intensity` via state) and the `filamentsPass.enabled`/`.draw` blocks in `passes.test.ts`. The `forwards correct args` assertion on `args[4] === 0.7` now comes from `state.settings.filaments.intensity = 0.7`. +- [x] `npm test -- filamentsPass passes` green. +- [x] Commit. ### Task 6: `texturedDisksPass` + `proceduralDisksPass` read `state.settings.thumbnails.enabled` @@ -163,10 +165,10 @@ Update the module-header "What it reads" block (`:29-32`) to name the real sourc Both `enabled` gates: `settings.galaxyTexturesEnabled` → `state.settings.thumbnails.enabled`. -- [ ] Re-source both gates. -- [ ] Rewrite the two per-pass tests + the `passes.test.ts` block to set `state.settings.thumbnails.enabled` (extend each test's state stub) instead of `makeSettings({ galaxyTexturesEnabled })`. -- [ ] `npm test -- texturedDisksPass proceduralDisksPass passes` green. -- [ ] Commit. +- [x] Re-source both gates. +- [x] Rewrite the two per-pass tests + the `passes.test.ts` block to set `state.settings.thumbnails.enabled` (extend each test's state stub) instead of `makeSettings({ galaxyTexturesEnabled })`. +- [x] `npm test -- texturedDisksPass proceduralDisksPass passes` green. +- [x] Commit. ### Task 7: `volumeUpsamplePass.enabled` reads `state.settings.volumes.enabled` @@ -174,10 +176,10 @@ Both `enabled` gates: `settings.galaxyTexturesEnabled` → `state.settings.thumb `settings.volumesEnabled` → `state.settings.volumes.enabled`. (`draw` already `_settings`.) -- [ ] Re-source the gate. -- [ ] Rewrite the test to set `state.settings.volumes.enabled` instead of `makeSettings({ volumesEnabled })`. -- [ ] `npm test -- volumeUpsamplePass` green. -- [ ] Commit. +- [x] Re-source the gate. +- [x] Rewrite the test to set `state.settings.volumes.enabled` instead of `makeSettings({ volumesEnabled })`. +- [x] `npm test -- volumeUpsamplePass` green. +- [x] Commit. ### Task 8: `selectionRingPass.draw` reads `state.settings.galaxyCatalogs.sizePx` @@ -185,10 +187,10 @@ Both `enabled` gates: `settings.galaxyTexturesEnabled` → `state.settings.thumb `settings.pointSizePx` → `state.settings.galaxyCatalogs.sizePx`. (`enabled` already `_settings`.) -- [ ] Re-source the `selectionRingRadiusPx` arg. -- [ ] Rewrite the draw tests to set `state.settings.galaxyCatalogs.sizePx = 4` instead of `makeSettings({ pointSizePx: 4 })`. -- [ ] `npm test -- selectionRingPass` green. -- [ ] Commit. +- [x] Re-source the `selectionRingRadiusPx` arg. +- [x] Rewrite the draw tests to set `state.settings.galaxyCatalogs.sizePx = 4` instead of `makeSettings({ pointSizePx: 4 })`. +- [x] `npm test -- selectionRingPass` green. +- [x] Commit. ### Task 9: `encodeVolumePrepass` reads `state.settings.volumes.enabled` @@ -196,9 +198,9 @@ Both `enabled` gates: `settings.galaxyTexturesEnabled` → `state.settings.thumb Update the gating-rationale comment (`:26`) that says "Master gate: `settings.volumesEnabled`". The `settings` param stays for now (Phase 3 drops it). -- [ ] Re-source the master gate; update the comment. -- [ ] No dedicated test file — covered by `renderFrame.test.ts`'s volume pre-pass tests (`:542-591`) and `encodeVolumes.test.ts`. Run `npm test -- renderFrame encodeVolumes` green. -- [ ] Commit. +- [x] Re-source the master gate; update the comment. +- [x] No dedicated test file — covered by `renderFrame.test.ts`'s volume pre-pass tests (`:542-591`) and `encodeVolumes.test.ts`. Run `npm test -- renderFrame encodeVolumes` green. +- [x] Commit. ### Task 10: `renderFrame` reads `ctx.focus` + `state.settings.tonemap.{exposure,curve}` @@ -211,10 +213,10 @@ The four `encode*` calls still receive `settings` here (Phase 3 drops the param) **Test:** `renderFrame.test.ts`'s `calls postProcess.draw … with exposure, curve…` (`:480-497`) asserts `args[2]/[3]` equal `fx.input.settings.exposure/toneMapCurve`. Move those values onto `state.settings.tonemap` in the fixture and assert against those. The fixture must populate `ctx.focus` (seed a `blend:0` value) and `state.settings.tonemap`. Mirror in `renderFrame.timing.test.ts`. -- [ ] Re-source the three reads in `renderFrame.ts`. -- [ ] Add `state.settings.tonemap` + `ctx.focus` to the `renderFrame.test.ts` / `renderFrame.timing.test.ts` fixtures; repoint the exposure/curve assertions. -- [ ] `npm test -- renderFrame` green. -- [ ] Commit. +- [x] Re-source the three reads in `renderFrame.ts`. +- [x] Add `state.settings.tonemap` + `ctx.focus` to the `renderFrame.test.ts` / `renderFrame.timing.test.ts` fixtures; repoint the exposure/curve assertions. +- [x] `npm test -- renderFrame` green. +- [x] Commit. --- @@ -250,16 +252,16 @@ Remove the `RenderFrameSettings` import + the argument-order docblock mentions o `npm run typecheck` is the safety net — a missed call site is a tsc error, not a silent pass. -- [ ] Drop the `settings` param from `Pass.d.ts` + remove the import + docblock mentions. -- [ ] Drop `settings` from the four `encode*` functions and their `pass.enabled`/`pass.draw` calls. -- [ ] Drop `settings` from `renderFrame.ts` (destructure + `encode*` calls) and `RenderFrameInput.d.ts` (field + import). -- [ ] Delete the `settings: { … }` literal from `runFrame.ts`; remove the now-dead `PROCEDURAL_DISK_FADE_*` import if unused there. -- [ ] Drop the `settings`/`_settings` param from all 13 pass files. -- [ ] Delete `src/@types/engine/frame/RenderFrameSettings.d.ts`. -- [ ] Update every test call site to drop the settings arg; remove the `makeSettings`/`SETTINGS` builders + `RenderFrameSettings` imports. -- [ ] `npm run typecheck` clean (both tsconfigs); `npm test` full suite green, no pass-count reduction, output pristine. -- [ ] `grep -rn RenderFrameSettings src tests` is empty; `grep -rn "settings" src/services/engine/frame/passes/` shows no `Pass`-param references (only `state.settings.…` reads). -- [ ] Commit. +- [x] Drop the `settings` param from `Pass.d.ts` + remove the import + docblock mentions. +- [x] Drop `settings` from the four `encode*` functions and their `pass.enabled`/`pass.draw` calls. +- [x] Drop `settings` from `renderFrame.ts` (destructure + `encode*` calls) and `RenderFrameInput.d.ts` (field + import). +- [x] Delete the `settings: { … }` literal from `runFrame.ts`; remove the now-dead `PROCEDURAL_DISK_FADE_*` import if unused there. +- [x] Drop the `settings`/`_settings` param from all 13 pass files. +- [x] Delete `src/@types/engine/frame/RenderFrameSettings.d.ts`. +- [x] Update every test call site to drop the settings arg; remove the `makeSettings`/`SETTINGS` builders + `RenderFrameSettings` imports. +- [x] `npm run typecheck` clean (both tsconfigs); `npm test` full suite green, no pass-count reduction, output pristine. +- [x] `grep -rn RenderFrameSettings src tests` is empty; `grep -rn "settings" src/services/engine/frame/passes/` shows no `Pass`-param references (only `state.settings.…` reads). +- [x] Commit. --- diff --git a/docs/superpowers/specs/2026-06-21-dissolve-render-frame-settings-design.md b/docs/superpowers/specs/completed/2026-06-21-dissolve-render-frame-settings-design.md similarity index 100% rename from docs/superpowers/specs/2026-06-21-dissolve-render-frame-settings-design.md rename to docs/superpowers/specs/completed/2026-06-21-dissolve-render-frame-settings-design.md