mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
@@ -221,11 +221,13 @@ pub struct Renderer {
|
||||
resources: Resources,
|
||||
/// Outlines morphing in the frame being drawn by `blend`.
|
||||
paths: Vec<PathMorph>,
|
||||
/// Their aligned outlines, kept from frame to frame.
|
||||
morphs: morph_path::Cache,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
fn default() -> Self {
|
||||
Renderer { resources: Resources::new(), paths: Vec::new() }
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), morphs: morph_path::Cache::default() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,6 +541,7 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
self.paths.clear();
|
||||
self.morphs.sweep();
|
||||
self.finish(ctx, w, h)
|
||||
}
|
||||
|
||||
@@ -654,7 +657,7 @@ impl Renderer {
|
||||
|
||||
fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) {
|
||||
let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) {
|
||||
Some(pm) => morph_path::geometry(pm, s, w, h),
|
||||
Some(pm) => self.morphs.geometry(pm, s, w, h),
|
||||
None => (shape_geometry(s, w, h), Vec::new()),
|
||||
};
|
||||
let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 });
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,6 +417,29 @@ fn morph_path_fades_a_fill_only_one_side_has() {
|
||||
assert!(half[0] > full[0] + 40 && half[0] < 215, "{half:?} vs {full:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_path_aligns_once_per_transition() {
|
||||
let (from, to) = (Xfrm::new(0.0, 0.0, 200.0, 200.0), Xfrm::new(0.0, 0.0, 300.0, 150.0));
|
||||
let star = Shape { geom: Geom::preset("star5"), ..Default::default() };
|
||||
let pm = |t: f64| PathMorph { id: ShapeId(10), from: Geom::preset("ellipse"), from_box: from, to_box: to, t };
|
||||
let els = |(g, f): (deckcraft_geom::preset::Geometry, Vec<_>)| (g.paths.iter().map(|p| p.path.elements().to_vec()).collect::<Vec<_>>(), f);
|
||||
let mut cache = morph_path::Cache::default();
|
||||
for t in [0.0, 0.3, 0.7, 1.0] {
|
||||
let got = els(cache.geometry(&pm(t), &star, 250.0, 175.0));
|
||||
// The same as aligning afresh.
|
||||
assert_eq!(got, els(morph_path::Cache::default().geometry(&pm(t), &star, 250.0, 175.0)));
|
||||
cache.sweep();
|
||||
}
|
||||
assert_eq!(cache.len(), 1);
|
||||
// Another target outline is another morph.
|
||||
let _ = cache.geometry(&pm(0.5), &Shape { geom: Geom::preset("rect"), ..Default::default() }, 250.0, 175.0);
|
||||
assert_eq!(cache.len(), 2);
|
||||
// A frame without morphs ends the transition: the cache empties.
|
||||
cache.sweep();
|
||||
cache.sweep();
|
||||
assert_eq!(cache.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_transform_unmirrors_flips() {
|
||||
// Asymmetric text rect in a 100×50 box: flips move the rect, never mirror the text.
|
||||
|
||||
Reference in New Issue
Block a user