diff --git a/README.md b/README.md index db1d5f6..6dc47dd 100644 --- a/README.md +++ b/README.md @@ -131,32 +131,29 @@ need to find it. ## Current Phase -Stage 8 — Recording & Playback -- **reopened** 2026-09-09: an audit, -prompted by the maintainer's own suspicion that this stage "never +Stage 9 — Better Numerics -- not yet started. Stage 8 (Recording & +Playback) was **reopened and reclosed on the same day, 2026-09-09**: an +audit, prompted by the maintainer's own suspicion that it "never actually went through a design/planning session," found the suspicion correct. Its original five completion criteria (TASK-045/046/047, -2026-09-07) are still met; four more were added the same day they were +2026-09-07) had stayed met; four more were added the same day they were found missing -- the Goal's own "scrubbed to any point" had shipped with no seek mechanism at all, and two of the stage's own stated deferrals (declared-field playback, partial-overlap cache reuse) plus one gap nobody had named (checkpoint retention) were pulled forward -rather than left indefinitely deferred. TASK-049 closed the retention -gap, TASK-050 the cache-reuse one, and TASK-048 the scrub one, all the -same day; TASK-051 closes the last one. Its live status, generated -from the -roadmap rather than restated here: +rather than left indefinitely deferred. TASK-049/050/048/051 closed all +four, one branch each, the same day. Its live status, generated from +the roadmap rather than restated here: [Stage 8 in the status report](docs/planning/status.md#stage-8----recording--playback). -Stage 9 (Better Numerics) still follows, once Stage 8 closes again. **Stage 8's own record, for anyone tracking how reliably this section -stays current**: opened and closed in one day (2026-09-07), then -reopened two days later by an audit this same session's own change is -keeping in sync -- the multi-day staleness windows the two paragraphs -below describe for Stages 7 and 8's own *earlier* drafts of this -section didn't recur here, because this edit landed in the same change -that reopened the stage rather than after. Don't read this as the -pattern solved; read whichever of Stage 8's closure or Stage 9's own -eventual entry comes next as the next real test of it. +stays current**: opened and closed in one day (2026-09-07), reopened +two days later, and reclosed the same day it was reopened -- each edit +to this paragraph has so far landed in the same change as the roadmap +event it describes, unlike the multi-day staleness windows the two +paragraphs below describe for Stages 7 and 8's own *earlier* drafts. +Don't read this as the pattern solved; read Stage 9's own eventual +entry here as the next real test of it. **This sentence said "not yet started" for Stage 8 itself, twice, while that stage was still open** -- once for the same reason a fourth time as @@ -188,10 +185,8 @@ golden demo renders a *solved* velocity field live. **Stage 6 is the proof that the engine underneath it is field-centric**: four named physical fields, added by configuration. -Stages 0 through 7 are complete, and Stage 8 is reopened (eight of nine -criteria met, see below) -- each closed, or in Stage 8's case pending -re-closure, against its own written completion criteria -(`docs/planning/roadmap.md`): +Stages 0 through 8 are complete, each closed against its own written +completion criteria (`docs/planning/roadmap.md`): - Stage 0 — planning system, capability map, repository structure, development tooling, CI. Deliberately no CFD functionality. @@ -254,25 +249,27 @@ re-closure, against its own written completion criteria added 93 step definitions, 28% of the repository's whole step vocabulary, which is evidence against its own claim rather than for it. -**Stage 8 (Recording & Playback) is reopened -- eight of nine criteria -met.** `pyflow record`/`pyflow resume`/`pyflow play` (TASK-045/046/047, -all 2026-09-07): record a run headlessly, resume it from any -checkpoint, or watch it back in a real window with live pause and speed -control -- no rendering window ever needed for the first two, and no -simulation code re-run for the third. Its own Golden Demo is Lid-Driven -Cavity (moved there from an earlier Heat Diffusion choice once playback --- which renders a solved velocity field -- turned out incompatible -with a demo that has none; see `docs/planning/roadmap.md`'s own Stage 8 -Status section for the full account). **Reopened 2026-09-09** for four -more criteria an audit found the Goal itself already promised: live -scrub (keyboard and a mouse-draggable bar), combined solved-velocity + -declared-field playback (grounded in Smoke Transport), opt-in checkpoint -retention, and partial-overlap cache reuse. Three are done (TASK-049, -`--max-checkpoints-retained` on `record`/`resume`; TASK-050, `pyflow -play --cache DIR` now reuses a full-subset request from a wider cached -window with no re-simulation; TASK-048, Left/Right/Home/End and a +**Stage 8 (Recording & Playback) is complete -- reopened and reclosed +the same day, 2026-09-09.** `pyflow record`/`pyflow resume`/`pyflow +play` (TASK-045/046/047, all 2026-09-07): record a run headlessly, +resume it from any checkpoint, or watch it back in a real window with +live pause, speed, and seek control -- no rendering window ever needed +for the first two, and no simulation code re-run for the third. Its own +Golden Demo is Lid-Driven Cavity (moved there from an earlier Heat +Diffusion choice once playback -- which renders a solved velocity field +-- turned out incompatible with a demo that has none; see +`docs/planning/roadmap.md`'s own Stage 8 Status section for the full +account). **Reopened and reclosed 2026-09-09** for four more criteria +an audit found the Goal itself already promised: opt-in checkpoint +retention (TASK-049, `--max-checkpoints-retained` on `record`/ +`resume`), partial-overlap cache reuse (TASK-050, `pyflow play --cache +DIR` now reuses a full-subset request from a wider cached window with +no re-simulation), live scrub (TASK-048, Left/Right/Home/End and a draggable scrub bar, verified against a real window to never pan the -camera underneath a drag); TASK-051 is not yet built. +camera underneath a drag), and combined solved-velocity + declared-field +playback (TASK-051, grounded in Smoke Transport -- `pyflow play` no +longer rejects a config just because it declares fields alongside a +solved velocity). Try the whole pipeline as it stands today: ```bash @@ -325,10 +322,15 @@ already-cached range reuses it too, sliced directly, even if its own exact range was never cached before (TASK-050); add `--backend offscreen --max-frames N` for a headless/CI-safe run with no window at all (what `tests/integration/ -test_playback_cli.py`'s own subprocess tests use). `pyflow play` only -supports a solved-velocity config for now (`simulation.velocity_solved: -true`, no declared `fields`) -- Lid-Driven Cavity's own shape; -declared-field/scalar-colormap playback is real, deferred future work. +test_playback_cli.py`'s own subprocess tests use). `pyflow play` +requires a solved velocity field (`simulation.velocity_solved: true`) +-- declared `fields` alongside it are rendered too, each its own +colour-mapped panel (TASK-051; try `--config +examples/golden-demos/smoke_transport.yaml` above instead of +Lid-Driven Cavity to see both a solved flow and a declared field +together). A config with no solved velocity at all (Heat Diffusion's +own shape) still has nothing for this to render, and is rejected the +same way it always was. Stage 9 (Better Numerics) follows Stage 8 (Recording & Playback, added 2026-09-07) -- better advection and diffusion diff --git a/docs/implementation/golden-demos.md b/docs/implementation/golden-demos.md index 085146f..44170e3 100644 --- a/docs/implementation/golden-demos.md +++ b/docs/implementation/golden-demos.md @@ -556,6 +556,18 @@ now also carrying a declared `smoke` field. that proof; - it runs headlessly via `--backend offscreen`, same as every other demo. +**Also this stage's own combined-rendering demo for `pyflow play` +(TASK-051, Stage 8 reopening, added 2026-09-09).** Solved velocity plus +a declared field is exactly the config shape playback used to reject +outright; `uv run python -m pyflow record --config +examples/golden-demos/smoke_transport.yaml --max-frames N` then `uv run +python -m pyflow play --checkpoints-dir checkpoints --to-frame N` now +renders both the flow's own arrows and the `smoke` field's two +configured panels together, verified directly against a real rendered +scene (`tests/integration/test_playback_cli.py:: +test_play_renders_both_arrows_and_a_panel_for_smoke_transport`), not +only that the CLI exits cleanly. + ## Future Demos Add an entry here when a new capability is implemented, per diff --git a/docs/planning/roadmap.md b/docs/planning/roadmap.md index d7b554d..116b4ab 100644 --- a/docs/planning/roadmap.md +++ b/docs/planning/roadmap.md @@ -306,7 +306,19 @@ This paragraph previously said `make install` and `make test` were still expected to fail, pending `uv.lock` and a test suite (B2/C1) -- stale since 2026-08-16 and corrected 2026-08-19. Both now succeed: `uv.lock` is committed (B2) and `make test` runs the suite with coverage -(C1a/C1b): **1196 tests as of 2026-09-09**, up from 1185 the same day +(C1a/C1b): **1199 tests as of 2026-09-09**, up from 1196 the same day +(TASK-051, Combined Solved-Velocity + Declared-Field Playback: 1 in +`tests/unit/test_playback.py` (a materialized frame's own panel colours +matching an independently live-stepped run's, `rtol=0, atol=0`), 2 in +`tests/integration/test_playback_cli.py` (a real CLI record-then-play +round trip against Smoke Transport, and a real `window.scene` check +that both an arrow object and a field panel are present, distinguished +by geometry shape) -- the panel-rendering extraction itself +(`bootstrap.py`'s own former private helpers, moved to +`field_visualization.py`) added no new tests, verified instead by the +117 pre-existing tests across `test_bootstrap.py`/ +`test_field_visualization.py`/`tests/golden/` passing unmodified), 1196 +itself up from 1185 the same day (TASK-048, Live Scrub: 7 in `tests/unit/test_playback.py` for `seek_relative`/`seek_to`/`frame_index_from_fraction` and `PlaybackState.dragging`'s own default, 2 in `tests/unit/ @@ -11394,7 +11406,7 @@ them, which had not been drafted yet when these were written. | 8. Checkpoint retention, opt-in, frame 0 never pruned | TASK-049 | | 9. Partial-overlap (subset) cache reuse | TASK-050 | -### Status as of 2026-09-09: Stage 8 reopened, eight of nine criteria met +### Status as of 2026-09-09: Stage 8 complete, nine of nine criteria met **This stage was audited 2026-09-09, at the maintainer's own request, against the suspicion that it "never actually went through a @@ -11414,9 +11426,9 @@ recorded-but-deferred indefinitely: declared-field/scalar-colormap playback, partial-overlap cache reuse, and checkpoint retention (a gap in what "bounded footprint" means that nobody had named as a gap at all, until this audit). Criteria 6-9, above, are the four; TASK-048-051 -will discharge them, one branch each, in that numeric order except -049/050 (the two library-only changes) landing before 048/051 (the two -that touch rendering). +discharged them, one branch each, in that numeric order except 049/050 +(the two library-only changes) landing before 048/051 (the two that +touch rendering). **"Complete" reopened, not "complete" corrected** -- 2026-09-07's own five criteria were genuinely met by what TASK-045/046/047 built; nothing @@ -11433,20 +11445,30 @@ what shipped rather than against the criteria that were meant to operationalise | 4. A checkpoint file is self-contained | **Met** -- TASK-045 | | 5. Golden Demo runs end to end, both halves | **Met** -- TASK-045 (record), TASK-046/047 (playback), against Lid-Driven Cavity | | 6. Live scrub, keyboard and mouse | **Met** -- TASK-048, verified against a real window | -| 7. Combined solved-velocity + declared-field playback | **Open** -- TASK-051, drafted, not started | +| 7. Combined solved-velocity + declared-field playback | **Met** -- TASK-051, verified against a real Smoke Transport run | | 8. Checkpoint retention, opt-in, frame 0 never pruned | **Met** -- TASK-049, mutation-tested | | 9. Partial-overlap (subset) cache reuse | **Met** -- TASK-050, mutation-tested | -Eight of nine criteria are met; the stage is **in progress**, not -complete, until TASK-051 closes the last one. **One real -course-correction happened during the original build, recorded rather -than smoothed over**: TASK-045's own original Golden Demo choice (Heat -Diffusion) turned out incompatible with TASK-047's own scope decision -(playback renders a solved velocity field; Heat Diffusion has none) -- -found when TASK-047 was actually scoped, not anticipated in advance, and -resolved by reconciling the whole stage onto one demo (Lid-Driven -Cavity) rather than carrying two. See the stage's own **Golden Demo** -entry above for the full account. +**All nine criteria are met; the stage is complete again, reclosed +2026-09-09** -- TASK-048/049/050/051 discharging Criteria 6-9 +respectively, each on its own branch, in the order the reopening's own +Status text above named (049/050 first, the two library-only changes; +then 048/051, the two that touch rendering). **One real +course-correction happened during the original 2026-09-07 build, +recorded rather than smoothed over**: TASK-045's own original Golden +Demo choice (Heat Diffusion) turned out incompatible with TASK-047's +own scope decision (playback renders a solved velocity field; Heat +Diffusion has none) -- found when TASK-047 was actually scoped, not +anticipated in advance, and resolved by reconciling the whole stage onto +one demo (Lid-Driven Cavity) rather than carrying two. See the stage's +own **Golden Demo** entry above for the full account. **A second, +smaller one happened during the reopening**: TASK-051 found that +reusing `bootstrap.py`'s own panel-rendering helpers required +refactoring one of them (`_add_panel_legend`) into a pure builder first, +since its original shape (taking `window`, mutating `window.scene` +directly) would have violated `field_visualization.py`'s own standing +"owns no window" rule if moved as-is -- see that task's own Design +decision 3. --- @@ -12335,8 +12357,7 @@ Completion Criterion 6 in full. ## TASK-051 — Combined Solved-Velocity + Declared-Field Playback -**Status: Not started, drafted 2026-09-09.** Discharges Completion -Criterion 7. +**Status: Done, 2026-09-09.** Discharges Completion Criterion 7. ### Purpose @@ -12348,9 +12369,9 @@ rather than rejecting it outright with `UnsupportedPlaybackConfigError`. `playback.py` (TASK-047). `bootstrap.py`'s `_add_declared_field_transport` (TASK-030/042), whose panel-rendering half (`_PanelRenderState`/ -`_panel_colors`/`_add_panel_legend`/`_panel_caption`) needs extracting +`_panel_colors`/`_add_panel_legend`/`_panel_caption`) needed extracting into `rendering/field_visualization.py` as public functions before -`playback.py` can reuse it, rather than reaching into another module's +`playback.py` could reuse it, rather than reaching into another module's private helpers -- the same "extract before reusing" precedent TASK-045 already set for `simulation_run.py`. @@ -12362,12 +12383,94 @@ already set for `simulation_run.py`. rather than a synthetic fixture, per the maintainer's own choice when this stage reopened. 2. **The extraction is verified behaviour-preserving by the full - existing test suite passing unmodified**, before anything new is + existing test suite passing unmodified**, before anything new was added, the same way TASK-045's `simulation_run.py` extraction was - verified. + verified (117 tests across `test_bootstrap.py`/ + `test_field_visualization.py`/`tests/golden/`, unchanged, all green + after the move). +3. **The extracted `build_panel_legend` is a pure builder (no `window` + parameter), not a direct move.** `_add_panel_legend` took `window` + and mutated `window.scene` directly -- exactly the shape + `field_visualization.py`'s own standing rule forbids ("it owns no + camera, no render loop... doesn't own a window", + `rendering/CLAUDE.md`). Refactored to return the built objects + instead, the same "pure builder in, `window.scene.add` in the + caller" shape `build_vector_field_arrows`/`build_scalar_field_mesh` + already establish; both `bootstrap.py` and `playback.py` now add the + returned objects to their own `window.scene` themselves. +4. **Playback renders both arrows and panels together, not panels + alone** -- a real, deliberate widening beyond what `bootstrap.py`'s + own live `_add_declared_field_transport` currently does for the + identical config shape (that path draws only the declared fields' + panels, never arrows for the solved velocity alongside them, + `src/pyflow/CLAUDE.md`'s own stated gap). Flagged as a separate, + out-of-scope follow-up rather than fixed here or silently matched -- + `pyflow play` is not obligated to reproduce a gap in `pyflow run` + just because they share a code path's history. -Artifacts, Acceptance Criteria and Discharges are written when this task -is actually built, the same as every other entry in this file. +### Artifacts Produced + +- `src/pyflow/rendering/field_visualization.py` -- `LEGEND_HEIGHT_ + FRACTION`/`LEGEND_GAP_FRACTION` (moved from `bootstrap.py`'s own + private constants), `panel_colors`, `panel_caption`, `PanelRenderState`, + `build_panel_legend` (all moved from `bootstrap.py`'s own private + `_panel_colors`/`_panel_caption`/`_PanelRenderState`/ + `_add_panel_legend`, the last refactored into a pure builder per + Design decision 3). +- `src/pyflow/bootstrap.py` -- calls the extracted names in place of its + own former private ones; `_add_legend` (the static path) now imports + `LEGEND_HEIGHT_FRACTION`/`LEGEND_GAP_FRACTION` rather than defining + them; `_add_declared_field_transport` adds `build_panel_legend`'s + returned objects to `window.scene` itself. No behaviour change. +- `src/pyflow/playback.py` -- `_declared_field_from_frame` (the scalar + counterpart to `_velocity_field_from_frame`); `play()` widened to + accept `config.fields` non-empty (still requires `velocity_solved`); + builds and rebuilds one panel (mesh + legend) per + `config.field_display.panels` entry, the same "remove old, build new" + convention `_rebuild_arrows`/`_rebuild_thumb` already use. + `UnsupportedPlaybackConfigError`'s own message and this module's + docstring updated to describe the new, wider scope. +- Tests: 1 in `tests/unit/test_playback.py` (a materialized frame's own + panel colours match an independently live-stepped run's, `rtol=0, + atol=0`), 2 in `tests/integration/test_playback_cli.py` (a real CLI + record-then-play round trip against Smoke Transport; a real `play()` + call proving both an arrow object and a field panel are present in + `window.scene`, distinguished by geometry shape from the scrub bar's + track and the legend's own gradient strip respectively), plus + `test_play_rejects_a_config_it_does_not_support` renamed and + re-targeted to the real remaining boundary (no solved velocity at + all, not "any declared field"). + +### Acceptance Criteria + +- A config with `simulation.velocity_solved: true` and one or more + declared `fields` is accepted, not rejected -- checked against a real + Smoke Transport record-then-play round trip through the actual CLI. +- A config with no solved velocity at all is still rejected, unchanged + -- checked against Heat Diffusion, the same negative case this task's + own predecessor test already used. +- Both the velocity arrows and every configured panel render from the + same materialized frame, checked directly in `window.scene` by + geometry shape, not merely "something was added to the scene". +- A panel's own colours, reconstructed from a materialized frame, match + what an independently live-stepped `SimulationState` produces at the + same step -- checked at `rtol=0, atol=0`, not a visual approximation. +- The panel-rendering extraction changed no existing behaviour -- the + full pre-existing test suite most likely to be affected + (`test_bootstrap.py`, `test_field_visualization.py`, every + `tests/golden/` module) passes unmodified. +- Verified by hand against the real CLI and the real rendered scene + (root `CLAUDE.md`'s Feature Verification rule), not only the test + suite: a real Smoke Transport record-then-play round trip produced + 259,649 non-background pixels across 1,719 distinct colours, with + `window.scene` holding exactly the expected shapes -- 2 legends (128 + vertices each, matching the two configured panels), 2 field panel + meshes (1,024 vertices each, matching the 256-cell mesh), a 2-vertex + scrub-bar track, and a 1,536-vertex arrow object. + +### Discharges + +Completion Criterion 7 in full. --- diff --git a/docs/planning/status.md b/docs/planning/status.md index e2e0d16..70c4028 100644 --- a/docs/planning/status.md +++ b/docs/planning/status.md @@ -17,14 +17,14 @@ demand, not part of this file. ## Progress -**51/52 tasks complete (98%)** across 16 planned stages. For the full plan, including +**52/52 tasks complete (100%)** across 16 planned stages. For the full plan, including stages below not yet broken into tasks: [roadmap.md](roadmap.md). ```mermaid pie showData title "Tasks across the roadmap" - "Done" : 51 - "Not started" : 1 + "Done" : 52 + "Not started" : 0 ``` ### Milestones @@ -37,15 +37,16 @@ pie showData - **Stage 5 -- First Fluid Solver** complete (2026-08-29) - **Stage 6 -- Additional Physical Fields** complete (2026-08-31) - **Stage 7 -- Rendering Annotations** complete (2026-09-03) +- **Stage 8 -- Recording & Playback** complete (2026-09-09) ### Up next -**Stage 8 -- Recording & Playback** is next, starting with TASK-051 (Combined Solved-Velocity + Declared-Field Playback). +**Stage 9 -- Better Numerics** is next, and has not been broken into tasks yet. ## Live repository facts - **49** `CLAUDE.md` files -- **1196** tests collected +- **1199** tests collected - **144** Gherkin scenarios (`tests/features/*.feature`) ## Stages @@ -153,7 +154,7 @@ pie showData ### Stage 8 -- Recording & Playback -**no status recorded** -- `█████████░` 6/7 tasks; 9 criteria defined, no status line yet +**complete, as of 2026-09-09** -- `██████████` 7/7 tasks; 9/9 criteria met | Task | Status | Date | Artifact | |------|--------|------|----------| @@ -163,7 +164,7 @@ pie showData | TASK-049 -- Checkpoint Retention Policy | Done | 2026-09-09 | `checkpoint.py` | | TASK-050 -- Partial-Overlap Cache Reuse, Subset Only | Done | 2026-09-09 | `replay.py` | | TASK-048 -- Live Scrub | Done | 2026-09-09 | `playback.py` | -| TASK-051 -- Combined Solved-Velocity + Declared-Field Playback | Not started | | | +| TASK-051 -- Combined Solved-Velocity + Declared-Field Playback | Done | 2026-09-09 | `playback.py` | ### Stage 9 -- Better Numerics diff --git a/src/pyflow/CLAUDE.md b/src/pyflow/CLAUDE.md index c9630f7..41f99f8 100644 --- a/src/pyflow/CLAUDE.md +++ b/src/pyflow/CLAUDE.md @@ -366,3 +366,40 @@ hindsight, but the first draft of the keyboard test assumed the latter and failed against real logged frame/position/paused values -- caught by logging every frame rather than guessing at the right frame number to assert against. + +**`play()` now renders declared fields alongside the solved velocity it +already required, not only arrows (TASK-051, Stage 8 reopening, added +2026-09-09) -- Smoke Transport's own shape, no longer rejected.** +`_declared_field_from_frame` is `_velocity_field_from_frame`'s scalar +counterpart; one `field_visualization.PanelRenderState` per +`config.field_display.panels` entry, built (mesh + legend, frame 0) and +rebuilt (mesh + equalized labels only, per frame) the same "remove old, +build new" way `_rebuild_arrows`/`_rebuild_thumb` already are. +`UnsupportedPlaybackConfigError` now names the real remaining boundary +-- no solved velocity at all (Heat Diffusion's own shape) -- not "any +declared field", which TASK-046/047's own first cut used to reject too. + +**This is a real, deliberate widening beyond what `bootstrap.py`'s own +live `_add_declared_field_transport` currently does for the identical +config shape** -- that path draws only the declared fields' panels, +never arrows for a solved velocity alongside them (see this file's own +entry for it, above: "smoke_transport.yaml/thermal_buoyancy.yaml... +don't [set vector_label], because that path has never drawn velocity +as arrows at all"). `playback.py` is not obligated to reproduce a gap +in `pyflow run` just because the two share history; flagged separately +as a possible follow-up rather than fixed here (out of this task's own +scope) or silently matched (worse for a viewer, for no real reason). + +**`panel_colors`/`panel_caption`/`build_panel_legend`/`PanelRenderState` +were extracted from `bootstrap.py`'s own private `_panel_colors`/ +`_panel_caption`/`_add_panel_legend`/`_PanelRenderState` into +`rendering/field_visualization.py` for this reuse** -- see that file's +own `CLAUDE.md` entry for the mechanics, in particular why +`build_panel_legend` had to become a pure builder (no `window` +parameter) rather than move as-is. `bootstrap.py` itself calls the +extracted versions in place of its own former private ones; the +extraction was verified behaviour-preserving by the full pre-existing +test suite most likely to be affected (`test_bootstrap.py`, +`test_field_visualization.py`, every `tests/golden/` module -- 117 +tests) passing unmodified, the same discipline TASK-045's own +`simulation_run.py` extraction used, before anything new was added. diff --git a/src/pyflow/bootstrap.py b/src/pyflow/bootstrap.py index 468a8b2..993095e 100644 --- a/src/pyflow/bootstrap.py +++ b/src/pyflow/bootstrap.py @@ -84,14 +84,12 @@ from collections.abc import Callable from pathlib import Path -import numpy as np import pygfx as gfx from pyflow import __version__ from pyflow.configuration import load_config from pyflow.configuration.schema import ( FieldDisplayConfig, - FieldPanelConfig, PyFlowConfig, RenderBackend, UnitsConfig, @@ -102,10 +100,15 @@ from pyflow.engine.vector_field import VectorField from pyflow.rendering import RenderWindow from pyflow.rendering.field_visualization import ( + LEGEND_GAP_FRACTION, + LEGEND_HEIGHT_FRACTION, + PanelRenderState, build_field_legend, + build_panel_legend, build_scalar_field_mesh, build_vector_field_arrows, - rank_scalar_field_colors, + panel_caption, + panel_colors, scalar_field_colors, ) from pyflow.rendering.hud import ( @@ -134,8 +137,10 @@ # the mesh's own bounding box, so the camera must be framed on the # combined box `_add_field_display` returns, not the mesh's bounds alone # (`fit_camera_to_bounds`, not TASK-013's mesh-only `fit_camera_to_mesh`). -_LEGEND_HEIGHT_FRACTION = 0.12 -_LEGEND_GAP_FRACTION = 0.08 +# `LEGEND_HEIGHT_FRACTION`/`LEGEND_GAP_FRACTION` themselves moved to +# `field_visualization.py` (TASK-051, Stage 8 reopening, 2026-09-09) so +# `build_panel_legend` reads the same values this module's own +# `_add_legend` does, rather than two copies that could drift. # 0.04 until 2026-09-03, when the Stage 7 (Rendering Annotations) exit # audit rendered the demos and looked at them. The legend caption # (`field_label`) is anchored `bottom-center` on the strip's *top* edge @@ -213,24 +218,25 @@ def _vector_display_initializer( def _add_legend( window: RenderWindow, field_display: FieldDisplayConfig, mesh_bounds: _Bounds ) -> _Bounds | None: - """The colour-ramp legend strip, below `mesh_bounds` -- shared by - every path that colour-maps a scalar field, static - (`_add_field_display`) or live (`_add_declared_field_transport`). + """The colour-ramp legend strip, below `mesh_bounds`, for the + *static* `scalar_pattern` display path (`_add_field_display`) only. Returns the strip's own bounds (for `_add_hud`'s numeric labels), or `None` if `field_display.show_legend` is false. - Factored out (Stage 7, Rendering Annotations) from what used to be - `_add_field_display`'s own inline block: the live-stepping path drew - a colour-mapped field with no legend at all before this, which is - exactly the gap this stage exists to close -- watching a live run is - the case a legend matters most for, not only a static demo frame. + **No longer shared with the live panel paths, since 2026-09-07's + modular panel list** (`src/pyflow/rendering/CLAUDE.md`'s "Equalized + (rank-based) field panel" entry) -- every live panel + (`_add_declared_field_transport`) builds its own legend directly via + `field_visualization.build_panel_legend` instead, since a run can + declare several panels with no single "the" legend left for one + shared function to build. """ if not field_display.show_legend: return None min_x, min_y, max_x, max_y = mesh_bounds mesh_height = max_y - min_y - legend_height = mesh_height * _LEGEND_HEIGHT_FRACTION - gap = mesh_height * _LEGEND_GAP_FRACTION + legend_height = mesh_height * LEGEND_HEIGHT_FRACTION + gap = mesh_height * LEGEND_GAP_FRACTION legend_bottom = min_y - gap - legend_height legend_bounds = (min_x, legend_bottom, max_x, min_y - gap) legend = build_field_legend( @@ -244,144 +250,6 @@ def _add_legend( return legend_bounds -def _panel_caption(panel: FieldPanelConfig) -> str: - """A panel's own legend caption -- `panel.label` if set, explicitly; - otherwise `panel.field`'s own name for a `"linear"` panel (the same - fallback `field_label`/`render_field` used to give one top-level - caption), or the plain constant `"equalized"` for an `"equalized"` - one, never the field name repeated with a suffix. Deliberately not - `f"{panel.field} (equalized)"`: an early cut of the equalized panel - captioned itself that way and it risked exactly the wrapped-caption- - drawn-over-the-mesh defect this file's HUD history already hit once, - the moment a real demo's own field name/label got long enough - (`src/pyflow/rendering/CLAUDE.md`'s "Equalized (rank-based) field - panel" entry). A viewer looking at several panels of related fields - only needs telling what's different about each one, not the full - name repeated -- and an explicit `panel.label` always overrides this - default outright, so nothing stops a config author choosing a longer - caption deliberately. - """ - if panel.label is not None: - return panel.label - return panel.field if panel.mode == "linear" else "equalized" - - -def _add_panel_legend( - window: RenderWindow, - low_color: str, - high_color: str, - show_legend: bool, - mesh_bounds: _Bounds, - offset_x: float, - caption: str, - initial_min: float, - initial_max: float, -) -> tuple[_Bounds | None, Callable[[float, float], None] | None]: - """One live panel's own legend -- a gradient strip below that - panel's own field mesh, shifted `offset_x` to the right of the - mesh's own left edge, captioned `caption`. Every live panel - (`FieldDisplayConfig.panels`) builds its own legend this way, - regardless of `mode` -- generalised from what used to be two - separate functions (`_add_legend`'s own live-path use, for the one - linear panel a run could have; `_add_equalized_panel_legend`, for - the one optional second panel) into one, now that any number of - panels can exist side by side. - - For a `"linear"` panel, `initial_min`/`initial_max` are - `panel.value_range`'s own fixed bounds -- the legend never needs - updating after the first frame, so callers simply never invoke the - returned `update_labels` again. For an `"equalized"` panel, there is - no fixed `(min, max)` the ramp actually means (colour depends on - *rank*, not magnitude) -- what gets labelled instead is the field's - own current min/max *value*, purely for context, and `update_labels` - is what keeps those two numbers honest as the field's own live - spread moves. - - **The gradient strip itself is built once, not per frame, even for - an equalized panel whose labelled min/max changes every frame** -- - `build_field_legend`'s own colour ramp is a pure `low_color`-to- - `high_color` interpolation over whatever range it's given, so its - *rendered pixels* are identical for every valid `(min, max)` pair; - only what the two ends are *labelled* as changes. Built here with a - placeholder `(0.0, 1.0)` range for exactly that reason -- rebuilding - a mesh whose own appearance provably never changes would be pure - waste, the same "don't do work whose result can't differ" reasoning - the returned update closure applies to the labels, which *do* need - it for an equalized panel. - - Returns `(legend_bounds, update_labels)`, or `(None, None)` if - `show_legend` is false. `gfx.Text.set_text` mutates in place, the - same per-frame-update mechanism `_add_hud`'s own stats block already - uses, so this needs no rebuild-the-object dance the field mesh - itself can't avoid (its *positions*, not just text, change frame to - frame). - """ - if not show_legend: - return None, None - min_x, min_y, max_x, max_y = mesh_bounds - mesh_height = max_y - min_y - legend_height = mesh_height * _LEGEND_HEIGHT_FRACTION - gap = mesh_height * _LEGEND_GAP_FRACTION - legend_bottom = min_y - gap - legend_height - legend_bounds = (min_x + offset_x, legend_bottom, max_x + offset_x, min_y - gap) - legend = build_field_legend( - low_color, - high_color, - (0.0, 1.0), # placeholder -- see docstring: the ramp's own pixels don't depend on this - legend_bounds, - ) - legend.local.position = (0.0, 0.0, _LEGEND_Z) - window.scene.add(legend) - - font_size = mesh_height * 0.05 - low_text, high_text, *_rest = build_legend_labels( - f"{initial_min:.3g}", - f"{initial_max:.3g}", - caption, - legend_bounds, - font_size=font_size, - max_width=max_x - min_x, - ) - for label in (low_text, high_text, *_rest): - label.local.position = (label.local.position[0], label.local.position[1], _HUD_Z) - window.scene.add(label) - - def _update_labels(field_min: float, field_max: float) -> None: - low_text.set_text(f"{field_min:.3g}") - high_text.set_text(f"{field_max:.3g}") - - return legend_bounds, _update_labels - - -class _PanelRenderState: - """Mutable per-panel render state `_add_declared_field_transport` - threads through its own initial build and `_advance`'s per-frame - rebuild -- one instance per `FieldDisplayConfig.panels` entry. - `mesh_object`/`update_labels` start `None` and are filled in by the - initial build below; kept as a small object rather than parallel - lists so each panel's own state stays together under one name. - """ - - def __init__(self, panel: FieldPanelConfig, offset_x: float) -> None: - self.panel = panel - self.offset_x = offset_x - self.mesh_object: gfx.Mesh | None = None - self.update_labels: Callable[[float, float], None] | None = None - - -def _panel_colors( - field: ScalarField, panel: FieldPanelConfig, low_color: str, high_color: str -) -> np.ndarray: - """A panel's own colour array, dispatched by `panel.mode` -- - `"linear"` (`scalar_field_colors`, `panel.value_range` fixed) or - `"equalized"` (`rank_scalar_field_colors`, no range needed, and - `panel.value_range` ignored). - """ - if panel.mode == "equalized": - return rank_scalar_field_colors(field, low_color, high_color) - return scalar_field_colors(field, low_color, high_color, panel.value_range) - - def _add_declared_field_transport( window: RenderWindow, mesh: Mesh, config: PyFlowConfig ) -> tuple[Callable[[], None], _Bounds]: @@ -464,18 +332,27 @@ def _add_declared_field_transport( already fall into) draws any number of colour-mapped panels side by side, each its own `FieldPanelConfig`.** Every panel is rebuilt every frame the same way (remove old, `build_scalar_field_mesh` a new one - from `_panel_colors`, shift right via `.local.position`); each - panel's own legend and numeric labels (`_add_panel_legend`) are - built once, not per frame -- an equalized panel's own labels are - then kept current by its returned `update_labels` closure, since - only its *labels* change frame to frame, never its gradient strip's - own rendered pixels (see `_add_panel_legend`'s own docstring). + from `field_visualization.panel_colors`, shift right via + `.local.position`); each panel's own legend and numeric labels + (`field_visualization.build_panel_legend`) are built once, not per + frame -- an equalized panel's own labels are then kept current by + its returned `update_labels` closure, since only its *labels* + change frame to frame, never its gradient strip's own rendered + pixels (see `build_panel_legend`'s own docstring). + **`panel_colors`/`panel_caption`/`build_panel_legend`/ + `PanelRenderState` moved to `rendering/field_visualization.py` + (TASK-051, Stage 8 reopening, 2026-09-09)** so `playback.py` could + reuse them too, rather than reaching into this module's own private + helpers -- `build_panel_legend` is now a pure builder (no `window` + parameter); this function adds the objects it returns to + `window.scene` itself, the same way it already does for + `build_scalar_field_mesh`'s own result. Returns the overall bounds (mesh, widened right by every panel drawn) as its second value, since `bootstrap()`'s own camera framing needs to know about it. **No longer returns a `legend_bounds` at all** (previously a second, middle value -- the primary panel's own strip bounds, for `_add_hud`'s generic numeric-label code to - caption): every live panel captions itself via `_add_panel_legend` + caption): every live panel captions itself via `build_panel_legend` directly now, so there is no single "the" legend left for `_add_hud`'s generic block to caption -- that block only ever fires for the static `scalar_pattern` path, which still returns its own @@ -507,7 +384,7 @@ def _add_declared_field_transport( mesh_width = bounds[2] - bounds[0] mesh_height = bounds[3] - bounds[1] panel_states = [ - _PanelRenderState(panel, index * mesh_width * (1.0 + _PANEL_GAP_FRACTION)) + PanelRenderState(panel, index * mesh_width * (1.0 + _PANEL_GAP_FRACTION)) for index, panel in enumerate(config.field_display.panels) ] overall_bounds = bounds @@ -515,7 +392,7 @@ def _add_declared_field_transport( panel = panel_state.panel rendered_field = state.fields[panel.field] assert isinstance(rendered_field, ScalarField) - colors = _panel_colors( + colors = panel_colors( rendered_field, panel, config.field_display.low_color, config.field_display.high_color ) panel_state.mesh_object = build_scalar_field_mesh(rendered_field, colors) @@ -526,17 +403,24 @@ def _add_declared_field_transport( initial_max = float(rendered_field.values.max()) else: initial_min, initial_max = panel.value_range - panel_legend_bounds, panel_state.update_labels = _add_panel_legend( - window, - config.field_display.low_color, - config.field_display.high_color, - config.field_display.show_legend, - bounds, - panel_state.offset_x, - _panel_caption(panel), - initial_min, - initial_max, + legend_mesh, legend_labels, panel_legend_bounds, panel_state.update_labels = ( + build_panel_legend( + config.field_display.low_color, + config.field_display.high_color, + config.field_display.show_legend, + bounds, + panel_state.offset_x, + panel_caption(panel), + initial_min, + initial_max, + ) ) + if legend_mesh is not None: + legend_mesh.local.position = (0.0, 0.0, _LEGEND_Z) + window.scene.add(legend_mesh) + for label in legend_labels: + label.local.position = (label.local.position[0], label.local.position[1], _HUD_Z) + window.scene.add(label) overall_bounds = ( overall_bounds[0], overall_bounds[1], @@ -589,7 +473,7 @@ def _advance() -> None: panel = panel_state.panel rendered_field = state.fields[panel.field] assert isinstance(rendered_field, ScalarField) - colors = _panel_colors( + colors = panel_colors( rendered_field, panel, config.field_display.low_color, @@ -963,8 +847,9 @@ def _add_hud( # `legend_bounds` only ever reaches here from the *static* # `scalar_pattern` path (`_add_field_display`) since 2026-09-07 -- # the live per-field panels now caption themselves directly - # (`_add_declared_field_transport`'s own `_add_panel_legend` - # calls) and always return `None` here, because a config can + # (`_add_declared_field_transport`'s own `field_visualization. + # build_panel_legend` calls) and always return `None` here, because + # a config can # declare several panels of several different fields and there # is no longer one single field name to fall back to. No # fallback needed for that reason: a static display has no diff --git a/src/pyflow/playback.py b/src/pyflow/playback.py index 573785a..7c6aa5d 100644 --- a/src/pyflow/playback.py +++ b/src/pyflow/playback.py @@ -10,15 +10,21 @@ the one module in Stage 8 whose whole job is putting pixels on screen, so there is nothing to keep headless here. -**Scoped to solved-velocity (arrows-only) rendering for this first -cut** -- exactly what the chosen Golden Demo (Lid-Driven Cavity) needs -(`config.simulation.velocity_solved` true, no declared `fields`). -Declared-field/scalar-colormap playback is a real, stated future -extension, not built now -- the same "scope to what a demo genuinely -needs first, revisit when one needs more" precedent -`_add_solved_velocity_rendering`'s own history in `bootstrap.py` already -set for TASK-031/034. `UnsupportedPlaybackConfigError` names the gap -loudly rather than silently rendering nothing. +**Requires a solved velocity field; declared fields are optional +(TASK-051, Stage 8 reopening, widened 2026-09-09 from the original +"arrows-only, no declared fields" first cut).** +`config.simulation.velocity_solved` must be true -- +`UnsupportedPlaybackConfigError` names the gap loudly rather than +silently rendering nothing for a config with no solved velocity at all +(Heat Diffusion's own shape). `config.fields`, if declared, each get a +colour-mapped panel via `config.field_display.panels`, reusing +`field_visualization.panel_colors`/`panel_caption`/`build_panel_legend`/ +`PanelRenderState` -- extracted from `bootstrap.py`'s own private +helpers for exactly this reuse, verified behaviour-preserving by the +full existing test suite passing unmodified before this module's own +combined path was added. Grounded in Smoke Transport +(`examples/golden-demos/smoke_transport.yaml`): solved velocity plus a +declared `smoke` field, two configured panels. **Live scrub (TASK-048, Stage 8 reopening, added 2026-09-09): Left/ Right step one frame, Home/End jump to the loaded window's own edges, @@ -63,7 +69,14 @@ from pyflow.engine.scalar_field import ScalarField from pyflow.engine.vector_field import VectorField from pyflow.rendering import RenderWindow -from pyflow.rendering.field_visualization import build_vector_field_arrows +from pyflow.rendering.field_visualization import ( + PanelRenderState, + build_panel_legend, + build_scalar_field_mesh, + build_vector_field_arrows, + panel_caption, + panel_colors, +) from pyflow.rendering.hud import build_stats_text, build_title_text from pyflow.rendering.mesh_visualization import ( build_mesh_grid_line, @@ -85,10 +98,20 @@ # scene, so these are the same "fixed, generous guess, not measured" # shape those constants already establish, not re-derived independently. _ARROWS_Z = 0.01 +_LEGEND_Z = 0.02 _HUD_Z = 0.03 _TITLE_MARGIN_FRACTION = 0.12 _STATS_MARGIN_FRACTION = 0.20 +# Declared-field panels (TASK-051, Stage 8 reopening): same values as +# `bootstrap.py`'s own `_PANEL_GAP_FRACTION`/`_LEGEND_LABEL_MARGIN_ +# FRACTION`, duplicated here rather than imported -- this file already +# keeps its own private copies of every other layout/depth constant +# `bootstrap.py` also has (`_ARROWS_Z`, `_HUD_Z`, `_TITLE_MARGIN_ +# FRACTION`, `_STATS_MARGIN_FRACTION`), the same precedent. +_PANEL_GAP_FRACTION = 0.15 +_LEGEND_LABEL_MARGIN_FRACTION = 0.10 + # The scrub bar's own layout (TASK-048, Stage 8 reopening) -- same # fixed-fraction-of-mesh-height shape as the constants above, for the # same reason (nothing here can be measured before it's drawn). @@ -107,11 +130,14 @@ class UnsupportedPlaybackConfigError(ValueError): - """Raised by `play` when the materialized window's own config is not - the one shape this first cut of playback supports: solved velocity, - no declared fields (`config.simulation.velocity_solved` true, - `config.fields` empty) -- the Lid-Driven Cavity golden demo's own - shape. Named loudly rather than silently rendering an empty scene. + """Raised by `play` when the materialized window's own config has no + solved velocity field at all (`config.simulation.velocity_solved` + false) -- Heat Diffusion's own shape, and the one case this module + cannot render, since there is nothing to draw arrows for. A config + with `velocity_solved` true and declared `fields` (Smoke Transport's + own shape) is supported, not rejected -- see this module's own + docstring. Named loudly rather than silently rendering an empty + scene. """ @@ -227,6 +253,17 @@ def _velocity_field_from_frame( return VectorField.assemble(components, "velocity") +def _declared_field_from_frame( + mesh: StructuredCartesianMesh, frame: dict[str, torch.Tensor], name: str +) -> ScalarField: + """One declared field's own raw tensor, wrapped back into a + `ScalarField` -- the scalar-field counterpart to + `_velocity_field_from_frame` above, for TASK-051's own panel + rendering (Stage 8 reopening, added 2026-09-09). + """ + return ScalarField(mesh, name, initial_value=frame[name]) + + def _playback_stats_lines(state: PlaybackState, frame_number: int) -> list[str]: """The one stats line this first cut shows -- frame number (the real materialized simulation frame, not a local playback-loop count) plus @@ -273,19 +310,19 @@ def play( `.speed` and `window.renderer.snapshot()` frame to frame, which nothing outside this function could otherwise see. - Raises `UnsupportedPlaybackConfigError` if the window's own config is - not solved-velocity-only (see this module's own docstring for why). + Raises `UnsupportedPlaybackConfigError` if the window's own config has + no solved velocity field at all (see this module's own docstring for + why declared fields alongside it are fine). """ window_data: MaterializedWindow = materialize_or_load_window( checkpoints_dir, from_frame=from_frame, to_frame=to_frame, cache_dir=cache_dir ) config: PyFlowConfig = window_data.config - if not (config.simulation.velocity_solved and not config.fields): + if not config.simulation.velocity_solved: raise UnsupportedPlaybackConfigError( - "pyflow play only supports a solved-velocity-only config for now " - "(simulation.velocity_solved: true, no declared fields) -- " - f"got velocity_solved={config.simulation.velocity_solved!r}, " - f"fields={[f.name for f in config.fields]!r}" + "pyflow play requires a solved velocity field " + "(simulation.velocity_solved: true) -- " + f"got velocity_solved={config.simulation.velocity_solved!r}" ) if backend is not None: @@ -323,7 +360,94 @@ def _rebuild_arrows(index: int) -> None: window.scene.add(rendered_object) _rebuild_arrows(0) - min_y = mesh_min_y + + # Declared-field panels (TASK-051, Stage 8 reopening, added + # 2026-09-09) -- built once here (mesh + legend, frame 0), rebuilt + # per frame by `_rebuild_panels` below (mesh + equalized labels + # only, never the legend itself, the same "the ramp's own rendered + # pixels never change" reasoning `build_panel_legend`'s own + # docstring gives). Empty `config.field_display.panels` (Lid-Driven + # Cavity's own shape) means this loop does nothing at all. + panel_states = [ + PanelRenderState(panel, index * mesh_width * (1.0 + _PANEL_GAP_FRACTION)) + for index, panel in enumerate(config.field_display.panels) + ] + for panel_state in panel_states: + panel = panel_state.panel + rendered_field = _declared_field_from_frame(mesh, window_data.frames[0], panel.field) + colors = panel_colors( + rendered_field, panel, config.field_display.low_color, config.field_display.high_color + ) + panel_state.mesh_object = build_scalar_field_mesh(rendered_field, colors) + panel_state.mesh_object.local.position = (panel_state.offset_x, 0.0, 0.0) + window.scene.add(panel_state.mesh_object) + if panel.mode == "equalized": + initial_min = float(rendered_field.values.min()) + initial_max = float(rendered_field.values.max()) + else: + initial_min, initial_max = panel.value_range + legend_mesh, legend_labels, panel_legend_bounds, panel_state.update_labels = ( + build_panel_legend( + config.field_display.low_color, + config.field_display.high_color, + config.field_display.show_legend, + mesh_bounds, + panel_state.offset_x, + panel_caption(panel), + initial_min, + initial_max, + ) + ) + if legend_mesh is not None: + legend_mesh.local.position = (0.0, 0.0, _LEGEND_Z) + window.scene.add(legend_mesh) + for label in legend_labels: + label.local.position = (label.local.position[0], label.local.position[1], _HUD_Z) + window.scene.add(label) + bounds = ( + bounds[0], + bounds[1], + max(bounds[2], mesh_bounds[2] + panel_state.offset_x), + bounds[3], + ) + if panel_legend_bounds is not None: + # Mirrors `bootstrap.py`'s own identical widening + # (`_add_declared_field_transport`) -- every panel's own + # legend sits at the same height, so this converges to one + # value across the loop, and without it a stats block placed + # below would draw straight over the legend/caption. + bounds = ( + bounds[0], + min( + bounds[1], panel_legend_bounds[1] - mesh_height * _LEGEND_LABEL_MARGIN_FRACTION + ), + bounds[2], + bounds[3], + ) + + def _rebuild_panels(index: int) -> None: + for panel_state in panel_states: + panel = panel_state.panel + rendered_field = _declared_field_from_frame( + mesh, window_data.frames[index], panel.field + ) + colors = panel_colors( + rendered_field, + panel, + config.field_display.low_color, + config.field_display.high_color, + ) + assert panel_state.mesh_object is not None + window.scene.remove(panel_state.mesh_object) + panel_state.mesh_object = build_scalar_field_mesh(rendered_field, colors) + panel_state.mesh_object.local.position = (panel_state.offset_x, 0.0, 0.0) + window.scene.add(panel_state.mesh_object) + if panel.mode == "equalized" and panel_state.update_labels is not None: + panel_state.update_labels( + float(rendered_field.values.min()), float(rendered_field.values.max()) + ) + + min_y = bounds[1] if config.rendering.show_title and config.rendering.title: title = build_title_text( @@ -405,6 +529,7 @@ def _on_frame() -> None: index = advance_playback_position(playback_state, max_index=max_index) if index != last_index: _rebuild_arrows(index) + _rebuild_panels(index) _rebuild_thumb(index) last_index = index if stats_text is not None: diff --git a/src/pyflow/rendering/CLAUDE.md b/src/pyflow/rendering/CLAUDE.md index a370d52..635ec0f 100644 --- a/src/pyflow/rendering/CLAUDE.md +++ b/src/pyflow/rendering/CLAUDE.md @@ -783,7 +783,7 @@ FieldPanelConfig]` replaces both: each panel is a full, independent declaration (`field`, `mode`, `value_range`, `label`), drawn left to right in list order, any number of them, each naming its own field. `bootstrap.py`'s own `_PanelRenderState`/`_panel_colors`/ -`_add_panel_legend` are the generalised mechanism -- one panel-building +`_add_panel_legend` were the generalised mechanism -- one panel-building loop instead of one hardcoded linear-panel block plus one hardcoded equalized-panel block. `[]` (the default) draws nothing, the same as `render_field: null` used to. See `src/pyflow/configuration/CLAUDE.md`'s @@ -791,6 +791,13 @@ equalized-panel block. `[]` (the default) draws nothing, the same as migration, including the 6 golden demos it required migrating and the load-error a config still setting either retired field now gets. +**Moved to this module as `PanelRenderState`/`panel_colors`/ +`build_panel_legend` (TASK-051, Stage 8 reopening, 2026-09-09)** so +`playback.py` could reuse them for its own combined solved-velocity + +declared-field rendering -- see this file's own entry below for the one +real shape change the move required (`build_panel_legend` becoming a +pure builder), and `src/pyflow/CLAUDE.md`'s `playback.py` entry for why. + ## `screen_to_world`, added 2026-09-09 (TASK-048, Stage 8 reopening) `window.py`'s `visible_world_size` (above) answers "how much world is @@ -829,3 +836,37 @@ gesture with no change to `RenderWindow`/`window.py` itself -- the `_pan_drag_start_screen is None` guard `_update_pan` already had made suppressing `pointer_down` alone sufficient. See `src/pyflow/CLAUDE.md`'s `playback.py` entry for the full wiring. + +## Panel-rendering helpers moved here, added 2026-09-09 (TASK-051, Stage 8 reopening) + +`PanelRenderState`/`panel_colors`/`panel_caption`/`build_panel_legend` +(`LEGEND_HEIGHT_FRACTION`/`LEGEND_GAP_FRACTION` alongside them) were +`bootstrap.py`'s own private `_PanelRenderState`/`_panel_colors`/ +`_panel_caption`/`_add_panel_legend` -- see the "Equalized (rank-based) +field panel" entry above for their own original history. Moved here so +`playback.py` could reuse them for its own combined solved-velocity + +declared-field rendering, rather than reaching into another module's +private helpers (the "extract before reusing" precedent TASK-045 set +for `simulation_run.py`). + +**One real shape change, not a straight move: `build_panel_legend` is +now a pure builder, taking no `window` and adding nothing to a scene +itself.** `_add_panel_legend`'s original form took `window` and called +`window.scene.add(...)` directly -- exactly the shape this module's own +opening lines forbid ("it doesn't own a render loop or a camera"). +Moving it as-is would have imported that violation into the one module +that has never had one. It now returns `(legend_mesh, labels, +legend_bounds, update_labels)`, and every caller (`bootstrap.py`, +`playback.py`) adds the returned objects to its own `window.scene` and +sets its own z-depth -- the same "pure builder in, `window.scene.add` +in the caller" shape `build_vector_field_arrows`/`build_scalar_field_mesh` +already establish, applied here for the first time to something that +used to mutate a scene directly. + +`bootstrap.py`'s own `_add_declared_field_transport` calls the extracted +names in place of its former private ones, with no behaviour change -- +verified by the full pre-existing test suite most likely to be affected +(`test_bootstrap.py`, `test_field_visualization.py`, every +`tests/golden/` module, 117 tests) passing unmodified before +`playback.py`'s own new combined-rendering code was written. See +`src/pyflow/CLAUDE.md`'s `playback.py` entry for what it built on top. diff --git a/src/pyflow/rendering/field_visualization.py b/src/pyflow/rendering/field_visualization.py index a5993ef..cb38090 100644 --- a/src/pyflow/rendering/field_visualization.py +++ b/src/pyflow/rendering/field_visualization.py @@ -21,13 +21,25 @@ from __future__ import annotations import math +from collections.abc import Callable import numpy as np import pygfx as gfx +from pyflow.configuration.schema import FieldPanelConfig from pyflow.engine.mesh import Mesh from pyflow.engine.scalar_field import ScalarField from pyflow.engine.vector_field import VectorField +from pyflow.rendering.hud import build_legend_labels + +# A live panel's own legend strip, as a fraction of mesh height -- the +# same constants `bootstrap.py`'s `_add_legend` (the static +# `scalar_pattern` path) also uses, moved here (TASK-051, Stage 8 +# reopening, 2026-09-09) so both that function and `build_panel_legend` +# below read one shared value rather than two copies that could drift +# (this project's own P-011). +LEGEND_HEIGHT_FRACTION = 0.12 +LEGEND_GAP_FRACTION = 0.08 def _hex_to_rgba_uint8(hex_color: str) -> np.ndarray: @@ -323,3 +335,143 @@ def build_field_legend( corners[:, 3] = np.stack([edges[:-1], np.full(num_samples, y1)], axis=1) return _quads_to_mesh(corners, colors) + + +def panel_colors( + field: ScalarField, panel: FieldPanelConfig, low_color: str, high_color: str +) -> np.ndarray: + """A panel's own colour array, dispatched by `panel.mode` -- + `"linear"` (`scalar_field_colors`, `panel.value_range` fixed) or + `"equalized"` (`rank_scalar_field_colors`, no range needed, and + `panel.value_range` ignored). + + Moved here from `bootstrap.py`'s own private `_panel_colors` + (TASK-051, Stage 8 reopening, 2026-09-09) so `playback.py` can reuse + it too, rather than reaching into another module's private helper + -- the same "extract before reusing" precedent TASK-045 already set + for `simulation_run.py`. + """ + if panel.mode == "equalized": + return rank_scalar_field_colors(field, low_color, high_color) + return scalar_field_colors(field, low_color, high_color, panel.value_range) + + +def panel_caption(panel: FieldPanelConfig) -> str: + """A panel's own legend caption -- `panel.label` if set, explicitly; + otherwise `panel.field`'s own name for a `"linear"` panel, or the + plain constant `"equalized"` for an `"equalized"` one, never the + field name repeated with a suffix. + + Moved here from `bootstrap.py`'s own private `_panel_caption` + (TASK-051, Stage 8 reopening, 2026-09-09) -- see that history for + why an explicit `f"{panel.field} (equalized)"` default was rejected + (`src/pyflow/rendering/CLAUDE.md`'s "Equalized (rank-based) field + panel" entry). + """ + if panel.label is not None: + return panel.label + return panel.field if panel.mode == "linear" else "equalized" + + +class PanelRenderState: + """Mutable per-panel render state a caller threads through its own + initial build and per-frame rebuild -- one instance per + `FieldDisplayConfig.panels` entry. `mesh_object`/`update_labels` + start `None` and are filled in by the caller's own initial build; + kept as a small object rather than parallel lists so each panel's + own state stays together under one name. + + Moved here from `bootstrap.py`'s own private `_PanelRenderState` + (TASK-051, Stage 8 reopening, 2026-09-09) -- `playback.py` needs the + identical per-panel bookkeeping for its own combined solved-velocity + + declared-field rendering. + """ + + def __init__(self, panel: FieldPanelConfig, offset_x: float) -> None: + self.panel = panel + self.offset_x = offset_x + self.mesh_object: gfx.Mesh | None = None + self.update_labels: Callable[[float, float], None] | None = None + + +def build_panel_legend( + low_color: str, + high_color: str, + show_legend: bool, + mesh_bounds: tuple[float, float, float, float], + offset_x: float, + caption: str, + initial_min: float, + initial_max: float, +) -> tuple[ + gfx.Mesh | None, + list[gfx.Text], + tuple[float, float, float, float] | None, + Callable[[float, float], None] | None, +]: + """One live panel's own legend -- a gradient strip below that + panel's own field mesh, shifted `offset_x` to the right of the + mesh's own left edge, captioned `caption`. Returns `(legend_mesh, + labels, legend_bounds, update_labels)`, or `(None, [], None, None)` + if `show_legend` is false. + + For a `"linear"` panel, `initial_min`/`initial_max` are + `panel.value_range`'s own fixed bounds -- the legend never needs + updating after the first frame, so callers simply never invoke the + returned `update_labels` again. For an `"equalized"` panel, there is + no fixed `(min, max)` the ramp actually means (colour depends on + *rank*, not magnitude) -- what gets labelled instead is the field's + own current min/max *value*, purely for context, and `update_labels` + is what keeps those two numbers honest as the field's own live + spread moves. + + **The gradient strip itself is built once, not per frame, even for + an equalized panel whose labelled min/max changes every frame** -- + `build_field_legend`'s own colour ramp is a pure `low_color`-to- + `high_color` interpolation over whatever range it's given, so its + *rendered pixels* are identical for every valid `(min, max)` pair; + only what the two ends are *labelled* as changes. Built here with a + placeholder `(0.0, 1.0)` range for exactly that reason. + + **Pure builder -- returns objects rather than adding them to a + scene**, unlike `bootstrap.py`'s own private `_add_panel_legend` + this was extracted from (TASK-051, Stage 8 reopening, 2026-09-09): + this module's own standing rule is that it owns no camera, no + render loop, and no window (`rendering/CLAUDE.md`). Every caller + (`bootstrap.py`, `playback.py`) adds the returned objects to its own + `window.scene` and sets whatever z-depth its own HUD elements use, + the same "pure builder in, `window.scene.add` in the caller" shape + `build_vector_field_arrows`/`build_scalar_field_mesh` already + establish. + """ + if not show_legend: + return None, [], None, None + min_x, min_y, max_x, max_y = mesh_bounds + mesh_height = max_y - min_y + legend_height = mesh_height * LEGEND_HEIGHT_FRACTION + gap = mesh_height * LEGEND_GAP_FRACTION + legend_bottom = min_y - gap - legend_height + legend_bounds = (min_x + offset_x, legend_bottom, max_x + offset_x, min_y - gap) + legend = build_field_legend( + low_color, + high_color, + (0.0, 1.0), # placeholder -- see docstring: the ramp's own pixels don't depend on this + legend_bounds, + ) + + font_size = mesh_height * 0.05 + labels = build_legend_labels( + f"{initial_min:.3g}", + f"{initial_max:.3g}", + caption, + legend_bounds, + font_size=font_size, + max_width=max_x - min_x, + ) + low_text, high_text = labels[0], labels[1] + + def _update_labels(field_min: float, field_max: float) -> None: + low_text.set_text(f"{field_min:.3g}") + high_text.set_text(f"{field_max:.3g}") + + return legend, labels, legend_bounds, _update_labels diff --git a/tests/integration/test_playback_cli.py b/tests/integration/test_playback_cli.py index 9dcc1ad..3a73023 100644 --- a/tests/integration/test_playback_cli.py +++ b/tests/integration/test_playback_cli.py @@ -103,10 +103,91 @@ def test_play_renders_a_real_recorded_run_headlessly(tmp_path: Path) -> None: assert result.returncode == 0, result.stderr -def test_play_rejects_a_config_it_does_not_support(tmp_path: Path) -> None: - """The scope boundary `playback.py`'s own docstring names -- - solved-velocity only -- checked against a real declared-field - recording (Heat Diffusion), not a velocity-only one. +def _record_smoke_transport(output_dir: Path, *, max_frames: int, checkpoint_interval: int) -> None: + result = subprocess.run( + [ + sys.executable, + "-m", + "pyflow", + "record", + "--config", + "examples/golden-demos/smoke_transport.yaml", + "--max-frames", + str(max_frames), + "--output-dir", + str(output_dir), + "--checkpoint-interval", + str(checkpoint_interval), + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_play_renders_smoke_transport_through_the_real_cli(tmp_path: Path) -> None: + """TASK-051's own Golden Demo grounding: Smoke Transport combines a + solved velocity field with a declared `smoke` field, the config + shape `play()` used to reject outright before this task. + """ + checkpoints_dir = tmp_path / "checkpoints" + _record_smoke_transport(checkpoints_dir, max_frames=10, checkpoint_interval=10) + + result = subprocess.run( + [ + sys.executable, + "-m", + "pyflow", + "play", + "--checkpoints-dir", + str(checkpoints_dir), + "--to-frame", + "10", + "--backend", + "offscreen", + "--max-frames", + "1", + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_play_renders_both_arrows_and_a_panel_for_smoke_transport(tmp_path: Path) -> None: + """The literal claim this task exists to prove -- through the public + Python API (`play()` directly) rather than the CLI, since that is + the only way to inspect what actually landed in `window.scene`. + Distinguishes real content from coincidence by shape, not merely by + type: the scrub bar's own track is always exactly a 2-point line + segment (`playback.py`'s own track geometry), so any longer `gfx. + Line` must be the velocity arrows; a panel's own field mesh has one + quad (4 vertices) per mesh cell, far more than a legend's fixed + 32-quad gradient strip (also a `gfx.Mesh`), so a mesh with more than + 128 vertices must be a real field panel, not just a legend. + """ + checkpoints_dir = tmp_path / "checkpoints" + _record_smoke_transport(checkpoints_dir, max_frames=10, checkpoint_interval=10) + + window = play(checkpoints_dir, from_frame=0, to_frame=10, backend="offscreen", max_frames=1) + + lines = [child for child in window.scene.children if isinstance(child, gfx.Line)] + meshes = [child for child in window.scene.children if isinstance(child, gfx.Mesh)] + + assert any(len(line.geometry.positions.data) > 2 for line in lines), "no arrows found" + assert any(len(mesh.geometry.positions.data) > 128 for mesh in meshes), "no field panel found" + + +def test_play_rejects_a_config_with_no_solved_velocity(tmp_path: Path) -> None: + """The scope boundary `playback.py`'s own docstring names -- a + solved velocity field is required -- checked against a real + declared-field-only recording (Heat Diffusion, no + `simulation.velocity_solved`), not a config combining a solved + velocity field with declared fields (Smoke Transport's own shape, + which TASK-051 made supported rather than rejected). """ checkpoints_dir = tmp_path / "checkpoints" result = subprocess.run( @@ -151,7 +232,7 @@ def test_play_rejects_a_config_it_does_not_support(tmp_path: Path) -> None: ) assert play_result.returncode != 0 - assert "solved-velocity-only" in play_result.stderr + assert "requires a solved velocity field" in play_result.stderr def test_play_requires_checkpoints_dir_and_to_frame() -> None: diff --git a/tests/unit/test_field_declaration_configuration.py b/tests/unit/test_field_declaration_configuration.py index c8f8615..ab95f77 100644 --- a/tests/unit/test_field_declaration_configuration.py +++ b/tests/unit/test_field_declaration_configuration.py @@ -289,10 +289,11 @@ def _then_panel_field_selected(ctx: _Context) -> None: assert ctx.late_window is not None assert ctx.alternate_window is not None # Two meshes each since Stage 7 (Rendering Annotations): the field - # fill and the legend strip (`bootstrap._add_panel_legend`, on by - # default -- `field_display.show_legend` is not set in this - # scenario's own config). Was exactly one before that stage added a - # legend to this live-stepping path. + # fill and the legend strip (`field_visualization.build_panel_legend`, + # moved out of `bootstrap.py`'s own private `_add_panel_legend` by + # TASK-051 -- on by default, `field_display.show_legend` is not set + # in this scenario's own config). Was exactly one before Stage 7 + # added a legend to this live-stepping path. assert len(_rendered_meshes(ctx.late_window)) == 2 assert len(_rendered_meshes(ctx.alternate_window)) == 2 # Different declared fields (a gaussian blob vs. a sinusoidal mode) on diff --git a/tests/unit/test_playback.py b/tests/unit/test_playback.py index 792b9e6..60e27c5 100644 --- a/tests/unit/test_playback.py +++ b/tests/unit/test_playback.py @@ -9,10 +9,18 @@ from __future__ import annotations +from pathlib import Path + +import numpy as np + +from pyflow.configuration import load_config +from pyflow.engine.mesh import StructuredCartesianMesh +from pyflow.engine.scalar_field import ScalarField from pyflow.playback import ( MAX_SPEED, MIN_SPEED, PlaybackState, + _declared_field_from_frame, advance_playback_position, decrease_speed, frame_index_from_fraction, @@ -21,6 +29,14 @@ seek_to, toggle_pause, ) +from pyflow.recording import record +from pyflow.rendering.field_visualization import panel_colors +from pyflow.replay import materialize_window +from pyflow.simulation_run import ( + advance_simulation_state, + assembled_numerics_for, + build_simulation_state, +) def test_advance_playback_position_moves_forward_by_speed() -> None: @@ -164,3 +180,82 @@ def test_frame_index_from_fraction_clamps_outside_zero_to_one() -> None: def test_playback_state_defaults_to_not_dragging() -> None: assert PlaybackState().dragging is False + + +# -- combined solved-velocity + declared-field playback (TASK-051) ------- + +_VELOCITY_PLUS_FIELD_CONFIG = """\ +mesh: + extent: [4, 4] + spacing: [0.25, 0.25] + +numerics: + timestep: 0.01 + boundary_conditions: + north: + type: dirichlet + field_values: + velocity.0: 1.0 + velocity.1: 0.0 + south: + type: dirichlet + east: + type: dirichlet + west: + type: dirichlet + +simulation: + velocity_solved: true + +fluid: + viscosity: 0.01 + +fields: + - name: smoke + initial_condition: gaussian_blob + +field_display: + panels: + - field: smoke + value_range: [0.0, 1.0] +""" + + +def test_declared_field_from_materialized_frame_matches_a_live_stepped_run( + tmp_path: Path, +) -> None: + """The claim TASK-051 exists to make true: a declared field's own + panel, rendered from a *materialized* frame (record -> replay -> + `_declared_field_from_frame` -> `panel_colors`), produces exactly + the colours a *live* `bootstrap.py`-style run would show at the same + step -- checked against an independently live-stepped + `SimulationState` (via `simulation_run` directly, not by re-reading + the same checkpoint pipeline under test), at the same tolerance + (`rtol=0, atol=0`) TASK-046's own determinism tests already use. + """ + config_file = tmp_path / "config.yaml" + config_file.write_text(_VELOCITY_PLUS_FIELD_CONFIG) + output_dir = tmp_path / "checkpoints" + record(config_file, max_frames=10, output_dir=output_dir, checkpoint_interval=10) + + config = load_config(config_file) + panel = config.field_display.panels[0] + low, high = config.field_display.low_color, config.field_display.high_color + + live_mesh = StructuredCartesianMesh.from_config(config.mesh) + numerics = assembled_numerics_for(config) + built_state = build_simulation_state(live_mesh, config) + assert built_state is not None + state = built_state + for _ in range(10): + state = advance_simulation_state(state, numerics, config.numerics.timestep) + live_field = state.fields["smoke"] + assert isinstance(live_field, ScalarField) + expected_colors = panel_colors(live_field, panel, low, high) + + window_data = materialize_window(output_dir, from_frame=10, to_frame=10) + replayed_mesh = StructuredCartesianMesh.from_config(config.mesh) + materialized_field = _declared_field_from_frame(replayed_mesh, window_data.frames[0], "smoke") + actual_colors = panel_colors(materialized_field, panel, low, high) + + np.testing.assert_array_equal(expected_colors, actual_colors)