Skip to content

feat(engine): per-subtree time remapping - #64

Merged
LeadcodeDev merged 1 commit into
mainfrom
feat/time-remapping
Jul 19, 2026
Merged

feat(engine): per-subtree time remapping#64
LeadcodeDev merged 1 commit into
mainfrom
feat/time-remapping

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Closes #53.

Goal: slow-motion/replay of a group without touching each child — time_scale/time_offset on containers remap time for their whole subtree.

  • Semantics: t_local = (t_global − time_offset) × time_scale, composed in cascade as an affine (scale, shift) transform. The animation ctx stays global through the recursion; each node derives its local time from the composed remap — the pre-remapped-ctx variant double-applies the transform (a sub-agent introduced exactly that bug; caught and fixed during the workstream).
  • Everything time-driven follows: CSS animation overrides, timeline style states, audio-reactive bindings, motion-blur ghost offsets. start_at/end_at windows are shifted by stagger in local time, then converted back to global ((t_local − shift)/scale) for the paint-pass check.
  • BuiltScene.time_params (per-node, stagger_delays pattern) feeds the dispatcher: internal animations (counter, draw_in, typewriter) and PaintCtx.time advance at local time; scene_duration stays global (documented in code + skill).
  • validate rejects time_scale ≤ 0; the builder clamps defensively. HTML: <rm-flex time_scale="0.5"> already works via the generic rm-* mapping (snake_case attrs; documented in the new skill rule).

Tests: 7 new — five pixel tests (slowed fade, delayed start, window under scale, 0.5×0.5 cascade, internal draw_in through the dispatcher path) + two validator tests. 339 total, all green. Process caveat recorded: tests were written alongside the implementation (parallel sub-agents), not observed red against the final code — they did catch the double-remap bug.

Verify: cargo test --workspace → 339 passed ✓ · cargo fmt --check ✓ · clippy gated ≤1 ✓

time_scale/time_offset on containers (flex/grid/card/div/positioned)
remap time for their whole subtree: t_local = (t - offset) * scale,
composed in cascade as an affine (scale, shift) transform threaded
through the builder recursion. The animation ctx stays GLOBAL down
the recursion — each node derives its local time from the composed
remap (a pre-remapped ctx double-applies; caught in review).
Animations, timeline states, audio-reactive bindings and motion-blur
ghosts follow the local time; start_at/end_at windows convert back to
global after local stagger shifting; BuiltScene.time_params feeds the
dispatcher so internal animations and PaintCtx.time advance at local
time (scene_duration stays global, documented). validate rejects
time_scale <= 0; builder clamps defensively. Skill rule added.
@LeadcodeDev
LeadcodeDev merged commit d5365f5 into main Jul 19, 2026
@LeadcodeDev
LeadcodeDev deleted the feat/time-remapping branch July 19, 2026 09:11
LeadcodeDev added a commit that referenced this pull request Aug 11, 2026
)

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(engine): per-subtree time remapping

1 participant