Files
llimphi/llimphi-compositor/src/hero.rs
T
Sergio ccab39f140 refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la
estructura vieja de eventloop) y suma el 3D:

- bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 /
  accesskit_winit 0.33 / vello_hybrid 0.0.9.
- nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido,
  montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel
  (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox).
- README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif).
- excluido modules/allichay (arrastra deps fuera del alcance del front-door).
- cargo check --workspace: verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:40:00 +00:00

274 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! **Hero / shared-element transitions** — un mismo nodo lógico (key estable)
//! que aparece en posiciones distintas entre frames "vuela" del rect anterior
//! al actual en vez de saltar. Es el Hero de Flutter auténtico.
//!
//! Modelo:
//! - El caller marca un nodo con [`View::hero(key, duration)`](crate::View::hero).
//! `key` enlaza "el mismo nodo lógico" entre dos `view()` distintos (entre
//! rutas, paneles, layouts) — dos nodos con la misma `key` en frames distintos
//! son la misma identidad para el runtime.
//! - El runtime mantiene una instancia de [`HeroRegistry`] entre frames y llama
//! [`HeroRegistry::reconcile`] DESPUÉS de `compute` y ANTES de `paint`. Por
//! cada nodo hero:
//! - Lee su rect absoluto del [`ComputedLayout`].
//! - Si en el frame anterior la misma `key` vivió en un rect distinto,
//! arranca un tween: durante `duration`, escribe en `node.transform` una
//! afín que "lleva visualmente" el nodo del rect actual al rect anterior y
//! converge a `IDENTITY`. El nodo se ve VOLAR del rect anterior al actual.
//! - Mientras el tween esté vivo, devuelve `true` y el runtime pide otro
//! frame (ticker autodetenido).
//! - Al asentarse, deja `node.transform = None`: cero costo de transform
//! residual en frames posteriores.
//!
//! No depende de [`crate::AnimRegistry`] — el wiring es independiente; sólo
//! reusa el campo `transform` del [`MountedNode`](crate::MountedNode), que el
//! `paint` ya respeta como cualquier otro afín.
//!
//! ## Reglas de uso
//!
//! - `key` debe ser estable y **única** entre los nodos hero presentes en un
//! mismo frame. Dos hero con la misma key en el mismo árbol generan
//! ambigüedad; el runtime se queda con la última que recorra.
//! - El rect "anterior" es el del frame anterior — no funciona como
//! shared-element entre dos *vistas montadas a la vez* (eso requeriría dos
//! rect simultáneos por key). Funciona entre transiciones de rutas.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use llimphi_layout::{ComputedLayout, Rect};
use vello::kurbo::Affine;
/// Declara un nodo como **hero**: la `key` enlaza la identidad entre frames; si
/// el rect cambia, el runtime anima la transición.
#[derive(Clone, Copy, Debug)]
pub struct Hero {
pub key: u64,
pub duration: Duration,
/// Easing aplicado a `t ∈ [0,1]`. Por defecto, los setters de [`View`]
/// usan un ease-out cúbico (igual que las animaciones implícitas).
pub easing: fn(f32) -> f32,
}
/// Registro de heroes, vivo entre frames. Guarda el último rect por `key` para
/// detectar el delta y un tween activo si está animando.
#[derive(Default)]
pub struct HeroRegistry {
/// Último rect donde se pintó un nodo con esta `key`. Se actualiza en cada
/// `reconcile`. Es contra esto que detectamos el cambio que dispara el
/// tween.
last: HashMap<u64, Rect>,
/// Tweens en curso. Cada uno conoce su `from_rect`, el reloj y el easing.
/// Una key con tween activo NO arranca uno nuevo si vuelve a moverse —
/// reusamos el `from_rect` original para que la trayectoria sea continua
/// (si el target cambia a mitad, vuela hacia el nuevo destino, no cambia
/// el origen).
tweens: HashMap<u64, Tween>,
}
struct Tween {
from_rect: Rect,
start: Instant,
duration: Duration,
easing: fn(f32) -> f32,
}
impl HeroRegistry {
pub fn new() -> Self {
Self::default()
}
/// Reconcilia heroes con el árbol montado. Para cada nodo con [`Hero`]:
/// - Si el rect cambió respecto del frame anterior, arranca tween.
/// - Si hay tween activo y vivo, escribe `node.transform` con la afín
/// interpolada (cur → from).
/// - Cuando el tween termina, lo limpia y deja `node.transform = None`.
///
/// Llamar DESPUÉS de `compute` y ANTES de `paint`. Devuelve `true` si
/// algún tween sigue en curso → el runtime pide otro frame.
pub fn reconcile<Msg>(
&mut self,
mounted: &mut crate::Mounted<Msg>,
computed: &ComputedLayout,
now: Instant,
) -> bool {
let mut animating = false;
let mut seen: Vec<u64> = Vec::new();
for node in &mut mounted.nodes {
let Some(hero) = node.hero else { continue };
let Some(cur) = computed.get(node.id) else { continue };
seen.push(hero.key);
// ¿Cambió el rect respecto del último frame? Arrancar tween (si no
// hay uno activo; si lo hay, no re-resetamos el origen).
if let Some(last) = self.last.get(&hero.key).copied() {
if last != cur && !self.tweens.contains_key(&hero.key) {
self.tweens.insert(
hero.key,
Tween {
from_rect: last,
start: now,
duration: hero.duration,
easing: hero.easing,
},
);
}
}
// Aplicar tween si está vivo. Calcula la afín que mapea `cur` al
// `from_rect` y la interpola hacia identidad a medida que `t` crece.
if let Some(tw) = self.tweens.get(&hero.key) {
let elapsed = now.saturating_duration_since(tw.start).as_secs_f32();
let raw = (elapsed / tw.duration.as_secs_f32().max(1e-6)).clamp(0.0, 1.0);
if raw >= 1.0 {
// Aterrizó: dejamos el nodo sin transform y limpiamos.
node.transform = None;
self.tweens.remove(&hero.key);
} else {
let t = (tw.easing)(raw);
let back = back_transform(cur, tw.from_rect);
let xf = lerp_affine(back, Affine::IDENTITY, t);
node.transform = Some(xf);
animating = true;
}
}
self.last.insert(hero.key, cur);
}
// Las keys que no aparecieron este frame se descartan (un hero que se
// va deja de recordarse; si vuelve, su rect "anterior" será el nuevo
// primero — no anima desde el último que tuvo hace varios frames).
if self.last.len() != seen.len() {
self.last.retain(|k, _| seen.contains(k));
self.tweens.retain(|k, _| seen.contains(k));
}
animating
}
}
/// Afín local que, aplicada con [`View::transform`]'s convención (alrededor
/// del centro del rect actual), mapea visualmente cada punto del `cur_rect`
/// al punto correspondiente del `from_rect`. Es la base de un "fly":
/// el nodo se pinta en `cur` pero con esta xf VOLVIÓ a `from` —
/// interpolando hacia identidad, "vuela" de `from` a `cur`.
fn back_transform(cur: Rect, from: Rect) -> Affine {
// El compositor aplica xf como `T(centro_cur) · xf_local · T(-centro_cur)`,
// así que xf_local debe ser `scale + translate` que mapea:
// esquina superior izquierda de cur → esquina superior izquierda de from.
//
// Si scale = (from.w/cur.w, from.h/cur.h) y t = (cx_from - cx_cur,
// cy_from - cy_cur), entonces `T(t) · S` cumple esa propiedad (despejo en
// los comentarios del módulo).
let sx = (from.w as f64) / (cur.w as f64).max(1e-6);
let sy = (from.h as f64) / (cur.h as f64).max(1e-6);
let cx_cur = (cur.x + cur.w * 0.5) as f64;
let cy_cur = (cur.y + cur.h * 0.5) as f64;
let cx_from = (from.x + from.w * 0.5) as f64;
let cy_from = (from.y + from.h * 0.5) as f64;
Affine::translate((cx_from - cx_cur, cy_from - cy_cur)) * Affine::scale_non_uniform(sx, sy)
}
/// Lerp componente-a-componente de las 6 coefs del afín. Idéntica al helper
/// privado de [`crate::anim`] — vive separada para mantener el módulo `hero`
/// auto-contenido (sin acoplar a Anim).
fn lerp_affine(a: Affine, b: Affine, t: f32) -> Affine {
let p = a.as_coeffs();
let q = b.as_coeffs();
let ft = t as f64;
Affine::new([
p[0] + (q[0] - p[0]) * ft,
p[1] + (q[1] - p[1]) * ft,
p[2] + (q[2] - p[2]) * ft,
p[3] + (q[3] - p[3]) * ft,
p[4] + (q[4] - p[4]) * ft,
p[5] + (q[5] - p[5]) * ft,
])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{mount, View};
use llimphi_layout::{LayoutTree, Style};
use llimphi_layout::taffy::prelude::length;
use llimphi_layout::taffy::Size;
/// Monta un único nodo hero con su Style + key=1 + dur=200ms. Devuelve
/// `Mounted` y el `ComputedLayout` ya resuelto contra un viewport de
/// 1000×1000 — los rects salen del propio Style.
fn one(x: f32, y: f32, w: f32, h: f32) -> (crate::Mounted<()>, ComputedLayout) {
let v = View::<()>::new(Style {
size: Size { width: length(w), height: length(h) },
inset: llimphi_layout::taffy::Rect {
left: length(x),
top: length(y),
right: llimphi_layout::taffy::prelude::auto(),
bottom: llimphi_layout::taffy::prelude::auto(),
},
position: llimphi_layout::taffy::Position::Absolute,
..Default::default()
})
.hero(1, Duration::from_millis(200));
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, v);
let computed = layout
.compute(mounted.root, (1000.0_f32, 1000.0_f32))
.expect("layout");
(mounted, computed)
}
#[test]
fn primera_aparicion_no_anima() {
let mut reg = HeroRegistry::new();
let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
let animating = reg.reconcile(&mut m, &c, Instant::now());
assert!(!animating, "primera aparición no debe animar");
assert!(m.nodes[0].transform.is_none(), "sin xf en primer frame");
}
#[test]
fn cambio_de_rect_arranca_tween_y_aplica_xf() {
let mut reg = HeroRegistry::new();
let t0 = Instant::now();
// Frame 1: rect (10, 10, 50, 50).
let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
reg.reconcile(&mut m, &c, t0);
// Frame 2: el nodo ahora vive en (200, 200, 100, 100) → arranca tween.
let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
let animating = reg.reconcile(&mut m, &c, t0 + Duration::from_millis(50));
assert!(animating, "cambio de rect → tween");
let xf = m.nodes[0].transform.expect("xf");
// A 50ms en una anim de 200ms, raw ≈ 0.25; con ease-out cúbico t > 0.25.
// La afín NO debe ser identidad (algún coef se ve).
let c = xf.as_coeffs();
assert!(c[0] != 1.0 || c[3] != 1.0 || c[4] != 0.0 || c[5] != 0.0,
"xf no debe ser identidad a mitad del tween: {:?}", c);
}
#[test]
fn al_terminar_limpia_la_xf() {
let mut reg = HeroRegistry::new();
let t0 = Instant::now();
let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
reg.reconcile(&mut m, &c, t0);
let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
reg.reconcile(&mut m, &c, t0 + Duration::from_millis(10));
// Pasada la duración: el tween se descarta y deja el nodo sin xf.
let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
let animating = reg.reconcile(&mut m, &c, t0 + Duration::from_millis(500));
assert!(!animating);
assert!(m.nodes[0].transform.is_none());
}
#[test]
fn back_transform_es_identidad_si_los_rects_coinciden() {
let r = Rect { x: 50.0, y: 50.0, w: 100.0, h: 100.0 };
let xf = back_transform(r, r);
let c = xf.as_coeffs();
assert!((c[0] - 1.0).abs() < 1e-9);
assert!((c[3] - 1.0).abs() < 1e-9);
assert!(c[4].abs() < 1e-9);
assert!(c[5].abs() < 1e-9);
}
}