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>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
//! **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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user