Morph: align an outline morph once per transition, not every frame

Follow-up to #27. render_blend built a GeometryMorph (contours, alignment)
for every morphing shape on every frame, though only t and the current box
change while a transition plays. The Renderer now keeps the aligned morphs,
keyed by both geometries and their own sizes, and drops those a frame did
not use, so the cache ends with the transition.

- render: morph_path::Cache replaces morph_path::geometry; blend sweeps it.
- test: one alignment per transition, same geometry as a fresh one.

generated with Claude Code
This commit is contained in:
Alexander Alber
2026-10-10 12:51:31 +02:00
parent d556ff98b5
commit a6000f18fd
3 changed files with 93 additions and 10 deletions
+65 -8
View File
@@ -29,12 +29,69 @@ fn ratio(now: f64, own: f64) -> f64 {
if own.abs() > 1e-9 && r.is_finite() { r } else { 1.0 }
}
/// The geometry of frame shape `s` at its current size `w` x `h`: both outlines are built at their
/// own sizes (so the alignment does not change while the box grows), then scaled into the box.
/// With a [`Fade`] per sub-path.
pub(crate) fn geometry(pm: &PathMorph, s: &Shape, w: f64, h: f64) -> (Geometry, Vec<Fade>) {
let a = shape_geometry(&Shape { geom: pm.from.clone(), ..Shape::default() }, pm.from_box.w, pm.from_box.h);
let b = shape_geometry(s, pm.to_box.w, pm.to_box.h);
let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h));
GeometryMorph::new(&a, &b).at(pm.t, scale(pm.from_box), scale(pm.to_box))
/// What a [`GeometryMorph`] is built from: both outlines at their own sizes. None of it changes
/// while a transition plays, only `t` and the current box do.
#[derive(Debug)]
struct Source {
from: Geom,
from_wh: (f64, f64),
to: Geom,
to_wh: (f64, f64),
}
impl Source {
fn of(pm: &PathMorph, s: &Shape) -> Self {
Source { from: pm.from.clone(), from_wh: (pm.from_box.w, pm.from_box.h), to: s.geom.clone(), to_wh: (pm.to_box.w, pm.to_box.h) }
}
fn is(&self, pm: &PathMorph, s: &Shape) -> bool {
self.from == pm.from && self.from_wh == (pm.from_box.w, pm.from_box.h) && self.to == s.geom && self.to_wh == (pm.to_box.w, pm.to_box.h)
}
fn build(&self) -> GeometryMorph {
let geo = |geom: &Geom, (w, h): (f64, f64)| shape_geometry(&Shape { geom: geom.clone(), ..Shape::default() }, w, h);
GeometryMorph::new(&geo(&self.from, self.from_wh), &geo(&self.to, self.to_wh))
}
}
/// The aligned outlines of the morphs being played, kept across frames: aligning is the expensive
/// part, blending them at `t` is cheap.
#[derive(Debug, Default)]
pub(crate) struct Cache {
/// With whether the current frame used it.
morphs: Vec<(Source, GeometryMorph, bool)>,
}
impl Cache {
/// The geometry of frame shape `s` at its current size `w` x `h`: both outlines are built at
/// their own sizes (so the alignment does not change while the box grows), then scaled into the
/// box. With a [`Fade`] per sub-path.
pub(crate) fn geometry(&mut self, pm: &PathMorph, s: &Shape, w: f64, h: f64) -> (Geometry, Vec<Fade>) {
let k = match self.morphs.iter().position(|(src, ..)| src.is(pm, s)) {
Some(k) => k,
None => {
let src = Source::of(pm, s);
let m = src.build();
self.morphs.push((src, m, false));
self.morphs.len() - 1
}
};
let (_, m, used) = &mut self.morphs[k];
*used = true;
let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h));
m.at(pm.t, scale(pm.from_box), scale(pm.to_box))
}
/// Ends a frame: forgets the morphs it did not use (their transition is over).
pub(crate) fn sweep(&mut self) {
self.morphs.retain(|(.., used)| *used);
for (.., used) in &mut self.morphs {
*used = false;
}
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.morphs.len()
}
}