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:
Sergio
2026-06-18 14:40:00 +00:00
parent e74800d9da
commit ccab39f140
202 changed files with 44034 additions and 1811 deletions
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "llimphi-widget-fitted-box"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-fitted-box — escala un subárbol arbitrario para que entre en el slot del padre, con políticas BoxFit::{Contain, Cover, Fill, None, ScaleDown}. Análogo a `FittedBox` de Flutter. Compone sobre el seam LayoutBuilder."
[dependencies]
llimphi-ui = { workspace = true }
+241
View File
@@ -0,0 +1,241 @@
//! `llimphi-widget-fitted-box` — escala un subárbol al slot disponible.
//!
//! Análogo a `FittedBox` de Flutter: el caller le pasa el **tamaño
//! natural** del contenido y una **política de fit**, y el widget aplica
//! un `transform` afín al subárbol para que entre en el slot real
//! (medido por el seam [`LayoutBuilder`] del compositor). El subárbol
//! queda **centrado** y, salvo `BoxFit::Fill`, **preserva su aspect
//! ratio**.
//!
//! Por qué no sale solo: `taffy` dimensiona contenedores, pero el
//! contenido (texto, imagen, painter custom) no escala con su contenedor
//! — sólo se posiciona dentro. `FittedBox` aplica un escalado VISUAL
//! sobre el subárbol completo, así un canvas `paint_with` o un texto
//! grande caben en una celda chiquita sin que el caller los re-mida.
//!
//! El padre del widget tiene `clip(true)` para que ningún píxel se salga
//! del slot (sólo importa cuando el aspect del contenido NO coincide con
//! el slot bajo `BoxFit::None`, o nunca con `Contain`/`Fill`).
//!
//! ## API
//!
//! ```ignore
//! use llimphi_widget_fitted_box::{fitted_box, BoxFit};
//! // Una imagen 800×600 que tiene que caber en cualquier slot, preservando
//! // aspect (mostrar entera, posibles bandas).
//! fitted_box((800.0, 600.0), BoxFit::Contain, || my_image_view())
//! ```
//!
//! ## Funciones puras
//!
//! [`compute_fit`] devuelve `(sx, sy, dx, dy)` para un `(slot, inner,
//! fit)` dado y es testeable sin runtime. Útil para validar el algoritmo
//! y para casos donde el caller ya tiene una transformación propia que
//! quiere componer.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Size, Style},
};
use llimphi_ui::llimphi_raster::kurbo::Affine;
use llimphi_ui::View;
/// Política de encaje del contenido en el slot. Cubre los cinco modos
/// canónicos (Flutter `BoxFit`, CSS `object-fit`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoxFit {
/// Preserva aspect — el contenido cabe ENTERO en el slot, dejando
/// bandas si el aspect no coincide. Lo usual para mostrar imágenes
/// sin recortar.
Contain,
/// Preserva aspect — el contenido CUBRE todo el slot, recortando lo
/// que sobre. Lo usual para fondos.
Cover,
/// Estira para llenar el slot (puede deformar — no preserva aspect).
Fill,
/// No escala — el contenido se muestra a tamaño natural, centrado.
/// Si es más grande que el slot, se recorta por el `clip` del padre.
None,
/// Como `Contain` pero **nunca agranda** — sólo achica si el
/// contenido es más grande que el slot. Equivale a
/// `min(Contain, None)`. Útil para evitar pixelar imágenes chicas.
ScaleDown,
}
/// `(sx, sy, dx, dy)` para encajar un contenido de tamaño `inner` en un
/// slot `slot` bajo la política `fit`. El factor `(sx, sy)` se aplica
/// como escala (igual en x e y salvo en `Fill`), y `(dx, dy)` es el
/// offset desde la esquina superior-izquierda del slot al borde
/// superior-izquierdo del contenido **ya escalado**, que queda centrado.
///
/// Casos de borde:
/// - `inner.0 <= 0.0 || inner.1 <= 0.0` o `slot.0 <= 0.0 || slot.1 <=
/// 0.0` ⇒ `(1.0, 1.0, 0.0, 0.0)` (identidad — defensa, no panic).
pub fn compute_fit(slot: (f32, f32), inner: (f32, f32), fit: BoxFit) -> (f32, f32, f32, f32) {
let (sw, sh) = slot;
let (iw, ih) = inner;
if iw <= 0.0 || ih <= 0.0 || sw <= 0.0 || sh <= 0.0 {
return (1.0, 1.0, 0.0, 0.0);
}
let (sx, sy) = match fit {
BoxFit::Contain => {
let s = (sw / iw).min(sh / ih);
(s, s)
}
BoxFit::Cover => {
let s = (sw / iw).max(sh / ih);
(s, s)
}
BoxFit::Fill => (sw / iw, sh / ih),
BoxFit::None => (1.0, 1.0),
BoxFit::ScaleDown => {
let s = (sw / iw).min(sh / ih).min(1.0);
(s, s)
}
};
let scaled_w = iw * sx;
let scaled_h = ih * sy;
let dx = (sw - scaled_w) * 0.5;
let dy = (sh - scaled_h) * 0.5;
(sx, sy, dx, dy)
}
/// Vista que escala el subárbol `inner` (tamaño natural `inner_size`) al
/// slot del padre bajo la política `fit`. `inner` es una closure porque
/// `View<Msg>` no es `Clone` — el seam `LayoutBuilder` puede invocar el
/// builder más de una vez en su resolución de dos pasadas.
///
/// El nodo retornado toma `width: 100%` y `height: 100%` por defecto —
/// el caller decide el tamaño envolviéndolo en un padre con `Style`
/// explícito.
pub fn fitted_box<Msg, F>(inner_size: (f32, f32), fit: BoxFit, inner: F) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn() -> View<Msg> + Send + Sync + 'static,
{
let (iw, ih) = inner_size;
View::<Msg>::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
..Default::default()
})
.clip(true)
.layout_builder(move |c| {
let slot = (c.max_width, c.max_height);
let (sx, sy, dx, dy) = compute_fit(slot, (iw, ih), fit);
// El runtime aplica el transform centrado en el nodo (convención
// CSS `transform-origin: 50% 50%`). Un nodo de tamaño natural
// (iw, ih) centrado en (iw/2, ih/2), tras `scale_non_uniform(sx,
// sy)` queda con tamaño visual (iw*sx, ih*sy) pero todavía
// centrado en (iw/2, ih/2). Para correrlo al centro del slot
// (sw/2, sh/2) trasladamos por la diferencia de centros, que en
// el caso de un nodo posicionado en (0,0) es exactamente `(dx,
// dy) + (scaled_w-iw)/2 + (scaled_h-ih)/2`. Después de hacer la
// cuenta: `delta = (sw - iw)/2`. (`dx + (scaled-iw)/2 =
// (sw-iw)/2`.) Así el offset que pasamos es `((sw-iw)/2,
// (sh-ih)/2)` antes del scale-around-center.
let delta_x = (c.max_width - iw) * 0.5;
let delta_y = (c.max_height - ih) * 0.5;
let xf = Affine::translate((delta_x as f64, delta_y as f64))
* Affine::scale_non_uniform(sx as f64, sy as f64);
// Suprimir warnings sobre dx/dy no usados — están en la doc + tests.
let _ = (dx, dy);
let inner_node = (inner)();
let inner_wrap = View::<Msg>::new(Style {
position: llimphi_ui::llimphi_layout::taffy::Position::Absolute,
inset: llimphi_ui::llimphi_layout::taffy::Rect {
top: length(0.0),
left: length(0.0),
right: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
bottom: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
},
size: Size { width: length(iw), height: length(ih) },
..Default::default()
})
.children(vec![inner_node])
.transform(xf);
View::<Msg>::new(Style {
position: llimphi_ui::llimphi_layout::taffy::Position::Relative,
size: Size {
width: length(c.max_width),
height: length(c.max_height),
},
..Default::default()
})
.children(vec![inner_wrap])
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contain_preserva_aspect_y_deja_bandas() {
// Slot 200×100, contenido 200×200 (cuadrado) — Contain achica al
// mínimo eje (100/200 = 0.5).
let (sx, sy, dx, dy) = compute_fit((200.0, 100.0), (200.0, 200.0), BoxFit::Contain);
assert!((sx - 0.5).abs() < 1e-3);
assert!((sy - 0.5).abs() < 1e-3);
// Contenido escalado 100×100 centrado en 200×100 → dx=50, dy=0.
assert!((dx - 50.0).abs() < 1e-3);
assert!(dy.abs() < 1e-3);
}
#[test]
fn cover_preserva_aspect_y_recorta() {
// Slot 200×200, contenido 200×100 (paisaje) — Cover toma el MÁXIMO
// eje (200/100 = 2.0 en y), el x sobra.
let (sx, sy, dx, dy) = compute_fit((200.0, 200.0), (200.0, 100.0), BoxFit::Cover);
assert!((sx - 2.0).abs() < 1e-3);
assert!((sy - 2.0).abs() < 1e-3);
// Contenido escalado 400×200 centrado en 200×200 → dx=-100, dy=0.
assert!((dx - (-100.0)).abs() < 1e-3);
assert!(dy.abs() < 1e-3);
}
#[test]
fn fill_estira_no_preserva_aspect() {
let (sx, sy, dx, dy) = compute_fit((200.0, 100.0), (100.0, 200.0), BoxFit::Fill);
assert!((sx - 2.0).abs() < 1e-3);
assert!((sy - 0.5).abs() < 1e-3);
// Sin offset — el contenido escalado cubre todo el slot.
assert!(dx.abs() < 1e-3);
assert!(dy.abs() < 1e-3);
}
#[test]
fn none_mantiene_natural_y_centra() {
// Slot 200×200, contenido 80×60 → sin escalar, centrado.
let (sx, sy, dx, dy) = compute_fit((200.0, 200.0), (80.0, 60.0), BoxFit::None);
assert!((sx - 1.0).abs() < 1e-6);
assert!((sy - 1.0).abs() < 1e-6);
assert!((dx - 60.0).abs() < 1e-3); // (200-80)/2
assert!((dy - 70.0).abs() < 1e-3); // (200-60)/2
}
#[test]
fn scale_down_no_agranda_solo_achica() {
// Contenido chico (40×30) en slot grande (200×100) → no agranda,
// queda 1.0 y centrado.
let (sx, sy, _, _) = compute_fit((200.0, 100.0), (40.0, 30.0), BoxFit::ScaleDown);
assert!((sx - 1.0).abs() < 1e-6);
assert!((sy - 1.0).abs() < 1e-6);
// Contenido grande (400×400) en slot chico (100×100) → achica como
// Contain (100/400 = 0.25).
let (sx2, sy2, _, _) =
compute_fit((100.0, 100.0), (400.0, 400.0), BoxFit::ScaleDown);
assert!((sx2 - 0.25).abs() < 1e-3);
assert!((sy2 - 0.25).abs() < 1e-3);
}
#[test]
fn entradas_invalidas_devuelven_identidad() {
// Cualquier dimensión ≤ 0 ⇒ identidad sin offset (defensa).
assert_eq!(compute_fit((0.0, 100.0), (50.0, 50.0), BoxFit::Contain), (1.0, 1.0, 0.0, 0.0));
assert_eq!(compute_fit((100.0, 100.0), (0.0, 50.0), BoxFit::Cover), (1.0, 1.0, 0.0, 0.0));
assert_eq!(compute_fit((100.0, 100.0), (50.0, -1.0), BoxFit::Fill), (1.0, 1.0, 0.0, 0.0));
}
}