Skip to content

fix(render): make freeze_at impossible for a render path to forget - #171

Merged
LeadcodeDev merged 1 commit into
mainfrom
feat/time-container
Aug 11, 2026
Merged

fix(render): make freeze_at impossible for a render path to forget#171
LeadcodeDev merged 1 commit into
mainfrom
feat/time-container

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Closes #164.

PR #152 fixed a real bug — scene.freeze_at was read by no code path in the world view — by adding a fifth hand-written copy of the clamp, "for parity with the other four". The cause it left untouched: no single place computes a scene's render time, so every new render path has to remember to redo it.

Writing the test first paid for itself twice

The brief asked for a parameterized test across all five render paths, written before any fix — none existed, since #152 tested only the path it was repairing. Either outcome would have been informative.

Four of the five paths agreed. The fifth did not, and not for the cosmetic .min()-versus-if drift already noted on the issue:

thread '...freeze_at_produces_the_same_frame_on_every_render_path' panicked:
render_world_frame_scaled: frames 45 and 55 (both past freeze_at) must be pixel-identical

render_world_frame_scaled painted the active scene's animated background from the raw world clock, never clamped — only the scene tree and camera were. PR #152's fifth copy was itself incomplete, and nothing had caught it.

The structural answer

RenderContext::time is now a SceneTime, not an f64. Its single field is private to a submodule, and its only two constructors both apply the clamp. It cannot be built anywhere else — verified by trying rather than asserting:

error[E0423]: cannot initialize a tuple struct which contains private fields
  --> crates/rustmotion/src/engine/render/scene.rs:69:46

render_with_new_pipeline — the only function that paints a scene's component tree — requires a &RenderContext. A sixth render path is therefore held to the clamp by the compiler, not by discipline.

To be precise about what that does and does not buy: it does not stop someone writing frame_index as f64 / fps as f64 from scratch. It removes the failure mode actually observed here — copying an existing path for parity. Every path left to copy now goes through SceneTime.

Two findings worth recording

My brief's premise was wrong, and the agent checked instead of complying. I wrote that time_scale/time_offset were confined to card and flex. They are on card, flex, grid, container and positioned — PR #64 generalized them long before this chantier, with the affine composition, its tests and rules/time-remapping.md. Nothing here needed to change, and nothing was changed. (My preparation grepped only the two files I already suspected: I confirmed a hypothesis instead of testing it.)

freeze_at does not belong in that composition. Because time_scale is validated strictly positive, every composed remap is monotonic, so clamping global time before composing is equivalent to clamping after, at any nesting depth. That is why box_builder.rs needed no change. A test pins the brief's own example — a card at time_scale: 2 over a flex at time_offset: -1 — rather than leaving it as an argument.

Verification

Not covered

  • A world crossfade with two scenes visible keeps the raw world clock for the background. Freezing it means deciding which scene wins during the transition — a design question, not an oversight. The comment says so where it applies.
  • freeze_at as a field on containers, the literal reading of "time container on any node". Doing it properly means carrying the freeze into BuiltScene.time_params, consumed by legacy_dispatch.rs, outside this change's scope. Half-doing it — freezing resolved CSS but not internal animations like counters and typewriters — would be worse than not doing it.
  • --strict-anim samples past freeze_at. grep -n freeze_at in commands/geometry.rs returns nothing: it can evaluate a transform at an instant the render never reaches. Pre-existing, unrelated to this change, and that file was read-only here. Suggested patch, not applied:
let sample_until = scene.freeze_at.map_or(scene_duration, |f| f.min(scene_duration));
for time in anim_sample_times(sample_until) {
    // body unchanged — `scene_duration` still goes to BuildAnimationCtx so
    // duration-relative effects keep their real window; only the sampling
    // ceiling moves.
}

Closes #164. PR #152 fixed a real bug — `scene.freeze_at` was read by no
code path in the `world` view — by adding a fifth hand-written copy of the
clamp "for parity with the other four". The cause it left untouched was that
no single place computes a scene's render time, so every new render path has
to remember to redo it.

Writing the parameterized test first, before any fix, paid for itself twice.
Four of the five paths agreed; the fifth did not, and the reason was not the
cosmetic `.min()`-versus-`if` drift already noted on the issue:
`render_world_frame_scaled` painted the active scene's animated background
from the raw world clock, never clamped. PR #152's fifth copy was itself
incomplete, and nothing had caught it.

`RenderContext::time` is now a `SceneTime` rather than an `f64`. Its single
field is private to a submodule and its only two constructors both apply the
clamp, so it cannot be built anywhere else — verified by trying:

    error[E0423]: cannot initialize a tuple struct which contains private fields

Since `render_with_new_pipeline` — the only function that paints a scene's
component tree — requires a `&RenderContext`, a sixth render path is held to
the clamp by the compiler rather than by discipline. That does not stop
someone writing `frame_index as f64 / fps as f64` from scratch, but it
removes the failure mode actually observed here: copying an existing path
for parity. Every path left to copy now goes through `SceneTime`.

Two findings worth recording rather than burying:

- `time_scale`/`time_offset` were already available on `card`, `flex`,
  `grid`, `container` and `positioned` — PR #64 generalized them long before
  this chantier, with the affine composition, its tests and its rule
  documentation. Nothing here needed to change.
- `freeze_at` does not need to enter that composition. Because `time_scale`
  is validated strictly positive, every composed remap is monotonic, so
  clamping the global time before composing is equivalent to clamping after,
  at any nesting depth. A test pins the brief's own example — a `card` at
  `time_scale: 2` over a `flex` at `time_offset: -1` — rather than leaving
  that as an argument.

Not covered: a world crossfade with two scenes visible keeps the raw world
clock for the background. Freezing it means deciding which scene wins during
the transition, which is a design question, not an oversight; the comment
says so where it applies.
@LeadcodeDev LeadcodeDev added the bug Something isn't working label Aug 11, 2026
@LeadcodeDev LeadcodeDev self-assigned this Aug 11, 2026
@LeadcodeDev
LeadcodeDev merged commit 511381d into main Aug 11, 2026
3 checks passed
@LeadcodeDev
LeadcodeDev deleted the feat/time-container branch August 11, 2026 09:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

freeze_at is reapplied by hand in all five render paths: the next path added will ignore it again

1 participant