chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros

El repo publico se habia quedado en julio: el README anunciaba vello 0.7 +
wgpu 27 (dos bumps atras) y faltaban los crates del ultimo mes.

- refresco con scripts/actualizar-standalone.py --llimphi (cargo check OK)
- llegan llimphi-cpu, llimphi-hybrid, llimphi-glifos y llimphi-wire-escena:
  los cuatro caminos a pixeles con un solo compositor
- MANUAL.md / SDD.md / LEEME.md sincronizados con el monorepo
- README: versiones al dia, install por crates.io (cargo add llimphi),
  seccion "beyond the 2D widget kit" y el indice de crates que apuntaba
  al propio README ahora apunta a MANUAL.md §19
This commit is contained in:
Sergio
2026-08-06 17:26:11 +00:00
parent c5a232cf24
commit d251eb32b6
410 changed files with 41697 additions and 3532 deletions
Generated
+346 -517
View File
File diff suppressed because it is too large Load Diff
+96 -7
View File
@@ -14,9 +14,12 @@ members = [
"llimphi-anim",
"llimphi-anim-studio",
"llimphi-compositor",
"llimphi-cpu",
"llimphi-gallery",
"llimphi-glifos",
"llimphi-gpu-bench",
"llimphi-hal",
"llimphi-hybrid",
"llimphi-icons",
"llimphi-image",
"llimphi-layer",
@@ -37,6 +40,7 @@ members = [
"llimphi-wasm-demo-counter",
"llimphi-wasm-demo-form",
"llimphi-wasm-wasi",
"llimphi-wire-escena",
"llimphi-wire-view",
"llimphi-workspace",
"modules/bookmarks",
@@ -51,6 +55,7 @@ members = [
"shared/foreign-lottie",
"shared/foreign-vox",
"shared/grafo",
"widgets/accordion",
"widgets/app-header",
"widgets/avatar",
"widgets/badge",
@@ -64,6 +69,7 @@ members = [
"widgets/clipboard",
"widgets/color-picker",
"widgets/context-menu",
"widgets/datetime-picker",
"widgets/detail-table",
"widgets/dock-rail",
"widgets/edit-menu",
@@ -71,6 +77,7 @@ members = [
"widgets/fab",
"widgets/field",
"widgets/fitted-box",
"widgets/form",
"widgets/gallery",
"widgets/gauge",
"widgets/grid",
@@ -101,6 +108,7 @@ members = [
"widgets/splitter",
"widgets/stat-card",
"widgets/status-bar",
"widgets/stepper",
"widgets/switch",
"widgets/table",
"widgets/tabs",
@@ -127,9 +135,35 @@ members = [
# `x86_64-unknown-none` y `panic = "abort"`, incompatibles con los
# perfiles globales. Los crates compartidos se referencian por `path`
# cruzando la frontera.
# `llimphi-wawa` está EXCLUIDA, no ausente por olvido. Enciende
# `llimphi-text/cpu`, que es el backend EXCLUYENTE del `vello` que todo el resto
# del workspace usa por default — y la unificación de features de cargo los
# junta, disparando el `compile_error!` que llimphi-text tiene justo para
# delatarlo. Vive en el mismo mundo que el kernel: se compila aparte, con su
# propio lock. Sus tests se corren así:
# cargo test --manifest-path llimphi-wawa/Cargo.toml \
# --no-default-features --features std
#
# `llimphi-jaula` está excluida por lo MISMO, del otro lado: enciende
# `llimphi-text/wire`, el tercer backend excluyente (W4). Es la cáscara de una
# app dentro de la jaula WASM — el gemelo de `llimphi-wawa`—, y que las dos estén
# afuera del workspace no es una molestia sino el diseño: son dos procesos en dos
# lados de una frontera, y forzarlos a compartir un lock sería fingir lo
# contrario. Sus tests:
# cargo test --manifest-path llimphi-jaula/Cargo.toml \
# --no-default-features --features std
#
# `llimphi-hybrid-android` está excluida **por contagio**: consume `llimphi-wawa`
# y por lo tanto arrastra el mismo `llimphi-text/cpu`. Es la tercera cáscara de
# la familia —kernel, jaula, teléfono— y cae del mismo lado de la frontera por
# el mismo motivo. Se compila así:
# cargo check --manifest-path \
# android/llimphi-hybrid-android/Cargo.toml
# y se empaca con `android/scripts/build-android.sh`.
exclude = [
"android",
"demo",
"llimphi-jaula",
"llimphi-video-plane",
"llimphi-voxel-app",
"llimphi-voxel-studio",
@@ -139,6 +173,7 @@ exclude = [
"llimphi-wasm-open",
"llimphi-wasm-registry",
"llimphi-wasm-runner",
"llimphi-wawa",
"modules/allichay",
"modules/plugin-host",
"modules/shuma-term",
@@ -155,6 +190,8 @@ repository = "https://git.tawasuyu.net/tawasuyu/llimphi"
[workspace.dependencies]
# === Configuración declarativa (vocabulario de esquemas) ===
# Escala única de `nice` de la sesión (compositor · marco · app · segundo plano
# · lote) + las tres formas de aplicarla (propia / pid vivo / pre_exec).
# Telemetría local de desarrollo (stderr+panics+tracing → $XDG_STATE_HOME/tawasuyu/<dominio>/<bin>.log)
# default = [] (núcleo liviano sin vello); `bake` es opt-in. Declaramos
# default-features = false aquí para que el `default-features = false` de los
@@ -206,6 +243,12 @@ aes-gcm = "0.10"
chacha20poly1305 = "0.10"
argon2 = "0.5"
rand = "0.8"
# TOTP (RFC 6238) del segundo factor de nakui: HMAC-SHA1 es lo que hablan
# Google Authenticator, Aegis y los tokens de hardware — no es elección
# nuestra, es el formato del ecosistema. base32 para el secreto del QR.
hmac = "0.12"
sha1 = "0.10"
data-encoding = "2"
# === WASM (arje) ===
# wasmi 1.0: unifica la versión con wawa (su kernel ya corre 1.0), para
@@ -316,15 +359,34 @@ tempfile = "3"
# === Llimphi (motor gráfico soberano) ===
# wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux.
# raw-window-handle 0.6 alinea winit 0.30 con wgpu 27.
# vello 0.7 = rasterizador vectorial sobre wgpu 27 (renderer GPU "wgpu" + opt-in "hybrid" CPU+GPU).
# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos).
# raw-window-handle 0.6 alinea winit 0.30 con wgpu 29.
# vello 0.9 = rasterizador vectorial sobre wgpu 29 (renderer GPU "wgpu" + opt-in "hybrid" CPU+GPU).
#
# **El 27→29 no fue por gusto** (2026-07-28): `vello_hybrid 0.0.9` —el ÚNICO
# rasterizador que el driver Mali del teléfono digiere, ver
# `02_ruway/llimphi/android/DIAGNOSTICO.md`— vive en wgpu 29, y dos versiones de
# wgpu no comparten un `Device`. La migración salió barata porque `vello 0.9`
# usa el mismo peniko 0.6.1 y no movió su API de `Scene`: el bump de vello dio
# CERO errores; todos los que hubo fueron deriva de wgpu, y concentrada en los
# cuatro crates que tienen código wgpu propio.
# taffy 0.12 = motor Flexbox/Grid puro Rust (0.9→0.12 en 2026-07-27: arregla el percent de ítem de grid en track sizing).
# parley 0.6 = shaping/layout de texto compatible con peniko 0.6 (que vello 0.7 expone).
wgpu = "27"
wgpu = "29"
# El compilador de shaders que wgpu lleva adentro. Se declara acá para que los
# tests que validan WGSL headless (llimphi-3d, supay-render-llimphi) usen la
# MISMA versión que después compila esos shaders en la GPU. Con dos versiones,
# un test puede dar verde sobre un shader que el runtime rechaza — o al revés.
naga = "29"
winit = "0.30"
raw-window-handle = "0.6"
pollster = "0.4"
vello = "0.7"
# `default-features = false` acá arriba y no en cada consumidor: el default de
# vello es ["wgpu","wgpu_default"], y `llimphi-text`/`llimphi-compositor` sólo
# quieren sus TIPOS (Scene, peniko), no el motor de GPU. Declararlo abajo no
# alcanza — cargo IGNORA `default-features` en un dep heredado si la raíz no lo
# fijó, y lo avisa con un warning fácil de pasar por alto. Quien necesita el
# motor lo pide por feature (ver llimphi-raster).
vello = { version = "0.9", default-features = false }
# Renderer hybrid CPU+GPU sin compute shaders (mejor compat WebGL2/Adreno viejas).
# Opt-in; el renderer "wgpu" sigue siendo el default.
vello_hybrid = "0.0.9"
@@ -337,15 +399,29 @@ foreign-lottie = { path = "shared/foreign-lottie", version = "0.1.0" }
# `SemanticsSpec` del compositor es independiente de estas crates.
accesskit = "0.24"
accesskit_winit = "0.33"
taffy = "0.9"
taffy = "0.12"
# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
parley = "0.6"
# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps.
llimphi-ui = { path = "llimphi-ui", version = "0.1.0" }
# El compositor DIRECTO, para que el catálogo de widgets cuelgue de él y no del
# shell de escritorio (W4.6). Un widget que dependa de `llimphi-ui` arrastra
# winit + accesskit + arboard + vello, y con eso queda fuera del alcance de una
# app enjaulada; colgando de acá sirve igual en Linux, en el kernel y en la jaula.
#
# `default-features = false` VA ACÁ Y NO EN CADA WIDGET. Es la trampa recurrente
# de cargo: apagar los defaults desde un miembro NO HACE NADA si el workspace
# declaró la dep sin ellos (cargo lo avisa y lo ignora). Ya pasó con taffy, vello
# y peniko. Sin esto, cada widget encendería el backend `vello` del compositor y
# volvería a quedar fuera del alcance de una app enjaulada — que es justamente lo
# que W4.6 vino a arreglar. Quien ensambla la app elige el backend: `llimphi-ui`
# en Linux, `llimphi-jaula` en la jaula, `llimphi-wawa` en el kernel.
llimphi-compositor = { path = "llimphi-compositor", version = "0.1.0" }
# Tier 3 — apps WASM con UI Llimphi real. El guest pinta un WireNode (IR
# serializable), el host (runner) lo materializa en View<Msg> y rebota los
# eventos al update del guest. SDK = lado guest, runner = lado host.
llimphi-wire-view = { path = "llimphi-wire-view", version = "0.1.0" }
llimphi-wire-escena = { path = "llimphi-wire-escena", version = "0.1.0" }
llimphi-wasm-app-sdk = { path = "llimphi-wasm-app-sdk", version = "0.1.0" }
# Núcleo puro de distribución (CAS + verificación + resolve), SIN runner/GPU.
# Cara con baterías: re-exporta core + puente al runner para correr la app.
@@ -446,6 +522,10 @@ llimphi-widget-avatar = { path = "widgets/avatar", version = "0.1.0" }
llimphi-widget-skeleton = { path = "widgets/skeleton", version = "0.1.0" }
llimphi-widget-field = { path = "widgets/field", version = "0.1.0" }
# Firma visual transversal (gradient sutil + hairline accent).
# `default-features = false` acá y no en `card`: los defaults de un widget sólo
# se pueden apagar desde la declaración del WORKSPACE (la trampa recurrente de
# cargo). Sin esto, `card` arrastraría a `panel` con backend `vello` y volvería a
# quedar fuera de una jaula, aunque `card` pidiera `wire`.
llimphi-widget-panel = { path = "widgets/panel", version = "0.1.0" }
llimphi-widget-panes = { path = "widgets/panes", version = "0.1.0" }
# Widgets Flutter-like — composición sobre primitivas Tier 1/3 ya
@@ -455,6 +535,10 @@ llimphi-widget-fab = { path = "widgets/fab", version = "0.1.0" }
llimphi-widget-wrap = { path = "widgets/wrap", version = "0.1.0" }
llimphi-widget-range-slider = { path = "widgets/range-slider", version = "0.1.0" }
llimphi-widget-calendar = { path = "widgets/calendar", version = "0.1.0" }
llimphi-widget-datetime-picker = { path = "widgets/datetime-picker", version = "0.1.0" }
llimphi-widget-form = { path = "widgets/form", version = "0.1.0" }
llimphi-widget-accordion = { path = "widgets/accordion", version = "0.1.0" }
llimphi-widget-stepper = { path = "widgets/stepper", version = "0.1.0" }
llimphi-widget-fitted-box = { path = "widgets/fitted-box", version = "0.1.0" }
llimphi-widget-carousel = { path = "widgets/carousel", version = "0.1.0" }
llimphi-widget-rating = { path = "widgets/rating", version = "0.1.0" }
@@ -587,7 +671,12 @@ markup5ever = "0.39"
markup5ever_rcdom = "0.39"
cssparser = "0.35"
url = "2"
ureq = { version = "2", default-features = false, features = ["tls"] }
# `gzip`: sin ella ureq ni anuncia `Accept-Encoding` ni decodifica la respuesta.
# python.org (detrás de Fastly) devuelve `content-encoding: gzip` AUNQUE no se lo
# pidan, y el cuerpo comprimido se decodificaba como UTF-8: 3.911 caracteres
# U+FFFD y una página de 16 cajas en vez de 1.489. Además baja lo que se
# descarga en todo el resto.
ureq = { version = "2", default-features = false, features = ["tls", "gzip"] }
# Charset de la web real: no todo es UTF-8 (google.com sirve ISO-8859-1).
# Ya venía transitivo por el stack de Servo — aquí se hace explícito.
encoding_rs = "0.8"
+41 -5
View File
@@ -2,7 +2,9 @@
> Framework de UI nativa: HAL · raster · layout · text · theme · ui — más widgets y módulos.
`llimphi` es el motor gráfico que comparten todas las apps del monorepo. Pipeline retained-mode declarativa sobre `vello` 0.7 + `wgpu` 27 + `taffy`, con shaping `parley` 0.6 (DejaVu Sans embebida como fallback de símbolos), theme `Dark/Light/Aurora/Sunset`, accesibilidad AccessKit, HAL multiplataforma (Wayland · X11 · Win32 · Android · Wawa).
![una UI densa compuesta sólo con primitivas del compositor: tema oscuro, top bar con tabs, sidebar, editor de código con resaltado sintáctico, texto rico, tarjetas de métricas con gradientes y sombras, un gráfico de barras de puros rects y un toast flotante](https://tawasuyu.net/02_ruway/llimphi/pantallazo.png)
`llimphi` es el motor gráfico que comparten todas las apps del monorepo. Pipeline retained-mode declarativa sobre `vello` 0.9 + `wgpu` 29 + `taffy` 0.12, con shaping `parley` 0.6 (DejaVu Sans embebida como fallback de símbolos), theme `Dark/Light/Aurora/Sunset`, accesibilidad AccessKit, HAL multiplataforma (Wayland · X11 · Win32 · Android · Wawa).
**Manual de uso:** [MANUAL.md](MANUAL.md) — referencia completa (bucle Elm, DSL `View<Msg>`, los 70 widgets y 11 módulos, GPU directo, gotchas) para humanos e IA. Diseño y roadmap: [SDD.md](SDD.md).
@@ -31,11 +33,35 @@ llimphi-widget-... = { workspace = true }
| Crate | Rol |
|---|---|
| [`llimphi-hal`](llimphi-hal/README.md) | Abstracción de superficie (winit / framebuffer / android). |
| [`llimphi-raster`](llimphi-raster/README.md) | Rasterizer vello + cache de scenes. |
| [`llimphi-raster`](llimphi-raster/README.md) | Rasterizer vello + cache de scenes (feature opt-in `hybrid`: renderer CPU+GPU sin compute shaders). |
| [`llimphi-layout`](llimphi-layout/README.md) | Layout taffy + extensiones. |
| [`llimphi-text`](llimphi-text/README.md) | Shaping + fonts (Fontdue/HarfBuzz). |
| [`llimphi-theme`](llimphi-theme/README.md) | Themes Dark/Light/Aurora/Sunset + paleta. |
| [`llimphi-ui`](llimphi-ui/README.md) | `View<Msg>` retained-mode + Elm-arch. |
| [`llimphi-text`](llimphi-text/README.md) | Shaping + fonts (parley; DejaVu Sans embebida como fallback). |
| [`llimphi-theme`](llimphi-theme/README.md) | Themes Dark/Light/Aurora/Sunset + paleta + tokens de motion/elevation. |
| [`llimphi-ui`](llimphi-ui/README.md) | `View<Msg>` retained-mode + Elm-arch + puente AccessKit. |
| `llimphi-compositor` | Núcleo declarativo sin winit: árbol `View<Msg>`, mount taffy, paint a scene, hit-test. |
| `llimphi-image` | Decode PNG/JPEG → `peniko::Image`. |
| `llimphi-svg` | Puente `vello_svg` → Llimphi (SVG real, íconos `.desktop`). |
| `llimphi-icons` | Íconos vectoriales propios (grid 24×24). |
| `llimphi-motion` | Tweens e interpolación (incl. transform afín). |
| `llimphi-surface` | Texturas externas. |
| `llimphi-workspace` | Chasis tipo tmux: splits resizables sobre `widget-panes`. |
| `llimphi-gallery` | Demo único del kit de elegancia (todos los widgets juntos). |
| `llimphi-gpu-bench` | Bench standalone del path GPU directo. |
| `llimphi-layer` | Corre un `App` como superficie **wlr-layer-shell** (barra anclada a un borde) en vez de ventana: así se pintan `pata` y el daemon de notificaciones. |
| `llimphi-test` | Arnés golden-image **headless y determinista** — la razón por la que un cambio de UI se puede certificar como texto en vez de mirando una captura. |
| `llimphi-3d` · `llimphi-voxel` · `llimphi-voxel-app` · `llimphi-voxel-studio` | El motor 3D (ray-march de voxels + mallas en una pasada de profundidad), la capa de contenido (worldgen procedural, personajes articulados, director de escenas) y el creador de mundos. |
| `llimphi-anim` | Máquina de estados de animación estilo Rive, **clip-agnóstica**. |
| `llimphi-anim-studio` | Los documentos serializables que autora el editor de animación (el grafo de estados). |
| `llimphi-lottie` | Puente fino a `velato` (reproductor Lottie de Linebender). |
| `llimphi-mesh` | Render de mallas deformables (huesos y skinning) a una `vello::Scene`. |
| `llimphi-video-plane` | Plano de video. |
| `llimphi-term-graphics` | Decodificador agnóstico de protocolos de gráficos de terminal. |
| `llimphi-wire-view` | IR serializable de un subset de `View<Msg>` — el cable entre guest y host. |
| `llimphi-wasm-app-sdk` | Lado **guest** de las apps WASM Tier 3: una app implementa `WasmApp` con su propio modelo y pinta un `View<Msg>` normal. |
| `llimphi-wasm-runner` | Lado **host** sobre `wasmi`: carga un `.wasm` que sigue ese ABI. |
| `llimphi-wasm-core` · `-dist` · `-net` · `-registry` · `-open` | Distribución **por hash** de apps WASM: el núcleo puro, la cara con baterías, el transporte P2P sobre Brahman, el puente data-driven a registros públicos y la apertura. |
| `llimphi-wasm-wasi` | Corre apps **WASI de consola** sobre wasmi — el catálogo no es trampa. |
| `llimphi-wasm-demo-{counter,form}` | Las apps guest de ejemplo. |
## Crates: widgets (visuales reactivos)
@@ -61,6 +87,8 @@ llimphi-widget-... = { workspace = true }
| [`theme-switcher`](widgets/theme-switcher/README.md) | Selector de tema. |
| [`nodegraph`](widgets/nodegraph/README.md) | Lienzo de nodos + cables Bezier. |
Y además (catálogo completo con firmas en [MANUAL.md](MANUAL.md) §13): avatar · badge · breadcrumb · calendar · carousel · chip · clipboard · color-picker · dock-rail · edit-menu · empty · fab · field · fitted-box · gauge · grid · hero · menubar · modal · navigator · panel · panes · progress · range-slider · rating · scaffold · scroll · segmented · select · shortcuts-help · skeleton · spinner · splash · status-bar · switch · table · terminal · text-editor-core · timeline · toast · tooltip · transport · waveform · wawa-mark · wrap.
## Crates: modules (feature funcional con estado)
| Module | Función |
@@ -74,6 +102,8 @@ llimphi-widget-... = { workspace = true }
| [`symbol-outline`](modules/symbol-outline/README.md) | Outline de símbolos LSP. |
| [`plugin-host`](modules/plugin-host/README.md) | Host para plugins WASM. |
| [`shuma-term`](modules/shuma-term/README.md) | Terminal embebida (shell shuma). |
| `allichay` | Renderizador único de configuración declarativa (`allichay::Schema` → rail + controles). |
| `selector` | Abstracción portátil abrir/guardar (`trait Selector`: host con PathBuf, Wawa content-addressed). |
## Crates: android
@@ -83,6 +113,12 @@ llimphi-widget-... = { workspace = true }
| [`vello-hello-android`](android/vello-hello-android/README.md) | Vello hello-world Android. |
| [`vello-text-android`](android/vello-text-android/README.md) | Text shaping Android. |
## Demo
En vivo: **<https://tawasuyu.net/02_ruway/llimphi/demo/>** — tour autocontenido tipo prezi: arquitectura, bucle Elm, kit de widgets y pantallazos headless de ~10 apps reales corriendo sobre llimphi (cosmos · pluma · nada · takiy · tullpu · supay · dominium · nahual · shuma…). Espacio / flechas / click para navegar; auto-advance cada 6 s — listo para grabar como video.
Fuente en [`demo/`](demo/index.html) (HTML+CSS+JS plano, sin build). Local: `python3 -m http.server -d demo`.
## Consideraciones
- **Una sola API: `View<Msg>` declarativa**. Sin imperativo, sin DOM virtual ajeno.
+702 -14
View File
@@ -31,6 +31,8 @@ real (firmas copiadas del código). Para el **porqué** arquitectónico ver
14. [Catálogo de módulos](#14-catálogo-de-módulos)
15. [`llimphi-workspace` — chasis tipo tmux](#15-llimphi-workspace--chasis-tipo-tmux)
16. [Reglas duras y gotchas](#16-reglas-duras-y-gotchas)
16.bis. [Tests de regresión visual (golden-image)](#16bis-tests-de-regresión-visual--llimphi-test-golden-image)
16.ter. [Semántica accesible y tests por consulta](#16ter-semántica-accesible--tests-por-consulta)
17. [Comandos y demos](#17-comandos-y-demos)
18. [Cheat-sheet](#18-cheat-sheet)
19. [Índice de crates](#19-índice-de-crates)
@@ -78,6 +80,7 @@ Tres reglas de oro:
3. llimphi-layout ....... motor de layout (taffy: flexbox + grid)
2. llimphi-raster ....... rasterizador vectorial (vello) + backend GPU directo
1. llimphi-text ......... shaping + fuentes (parley): bidi, ligaduras, CJK/emoji
└ llimphi-glifos ..... atlas de cobertura: el texto del camino GPU-directo
0. llimphi-hal .......... abstracción de superficie (wgpu + winit / framebuffer)
```
@@ -248,6 +251,13 @@ impl<Msg: Send + 'static> Handle<Msg> {
pub fn spawn_periodic<F: Fn() -> Msg + Send + 'static>(&self, period: Duration, f: F); // tick periódico
pub fn for_test() -> Self; // handle "muerto" para tests sin event loop
pub fn for_test_with<F: Fn(Msg) + Send + Sync + 'static>(sink: F) -> Self; // tests que SÍ quieren los Msg
// --- ventana (sólo la primaria; en un handle `lift`ado son no-op) ---
pub fn set_fullscreen(&self, on: bool); // pantalla completa sin bordes, en su monitor
pub fn set_minimized(&self, on: bool); // minimizar / restaurar
pub fn set_content_type(&self, k: ContentType); // qué mostrás: None/Photo/Video/Game
pub fn open_window(&self, key: u64, title: impl Into<String>, w: u32, h: u32); // ventana OS secundaria
pub fn close_window(&self, key: u64);
}
```
@@ -268,6 +278,27 @@ impl<Msg: Send + 'static> Handle<Msg> {
el runtime real. Con eso el trabajo corre **una sola vez** y el test es
determinista.
- **`set_fullscreen`** — `Fullscreen::Borderless` sobre el monitor donde ya
está la ventana (no cambia el modo de video). Es una **petición** al
compositor y el runtime **no lleva la cuenta**: el estado vive en el modelo
de la app, que decide cuándo entra y cuándo sale. Si el usuario sale por el
WM, tu `bool` queda desfasado — repetir el pedido es inocuo. El resize llega
por el camino normal (`Resized`), no hay que tocar la surface.
- **`set_content_type`** — declara **qué** muestra la ventana
(`wp_content_type_v1`). No cambia nada de cómo pintás: es una pista para que
el compositor ajuste su política. mirada le **cede la pantalla** al contenido
con ritmo propio —deja de animar su fondo de marca, que le cuesta una
recomposición completa por latido— y con lo mismo decide VRR y tearing. Un
reproductor declara `Video` al arrancar y vuelve a `Photo` al pausar (un video
en pausa **es** una imagen fija) o a `None` al cerrar el medio; repetir el
mismo valor es gratis. Fuera de Wayland, o con un compositor que no exponga el
protocolo, es un no-op silencioso (avisa una vez por stderr).
Winit no habla ese protocolo: por debajo se monta una segunda conexión
wayland-client sobre el **mismo** `wl_display` y se envuelve la `wl_surface`
de winit — el mismo cruce que validó el spike `llimphi-video-plane`. La pista
es estado doble-buffereado, así que viaja con el **próximo cuadro** que
pintes. Ejemplo vivo: `cargo run -p llimphi-ui --example content_type`.
- **`spawn`** — trabajo bloqueante (IO, PAM, parse, efemérides). El `Msg` que
devuelve la closure se entrega al `update` en el hilo de UI. **Este es el
patrón obligatorio para todo cómputo pesado** (§16).
@@ -522,9 +553,12 @@ Bueno hasta ~500 K primitivos por frame (rebuild) o ~2 M (Scene reusada).
})
```
Para volumen masivo: starfield Gaia de cosmos, particles de tinkuy, viewport de
nakui, pineal denso. Rango 100 K 10 M+ primitivos. **No** soporta texto ni AA
fino ni múltiples grosores de stroke por flush. Para texto encima de un render
GPU, usar `view_overlay` (segunda Scene vello).
nakui, pineal denso. Rango 100 K 10 M+ primitivos. **** soporta texto desde
2026-08-05 (§12, `llimphi-glifos` + `add_glyph`) y múltiples grosores de stroke
por flush desde el mismo día. Lo que sigue sin soportar es el AA analítico de
curvas: los bordes de rects/líneas/tris salen del MSAA 4×, no de cobertura
exacta. Para texto **con brush** (gradiente, imagen) o decoraciones sigue
haciendo falta la Scene vello de `view_overlay`.
### UI encima de contenido GPU — `View::over` y `paint_over`
@@ -555,8 +589,8 @@ marca (`src/fondo.rs::fondo_overlay`) flotando sobre el video.
| Primitivos/frame | < ~500 K rebuild / < ~2 M Scene reusada | 100 K 10 M+ |
| ¿Cambian cada frame? | sí, rebuild barato | mejor estático (buffer persistente) |
| Curvas Bezier | nativas | hay que teselar |
| Texto | sí | no |
| AA fino | sí (analítico) | no (sin MSAA) |
| Texto | sí, con brush | sí, color sólido (atlas de cobertura) |
| AA fino | sí (analítico) | bordes por MSAA 4×; el texto trae el suyo |
**Default: `paint_with`** salvo que ya midas que el volumen lo justifica
(factores ~11× a 1M en GPU mid sólo en el régimen persistente). El backend GPU
@@ -620,18 +654,160 @@ Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color
// GPU directo:
GpuPipelines::new(device, color_format) -> Self // campos: lines, tris, rects, bind_layout
GpuBatch::new(&pipelines)
.line_width(w) .add_line(p0,p1,color) .add_polyline(&pts,color)
.line_width(w) // pluma: vale para lo que se agregue DESPUÉS
.add_line(p0,p1,color) .add_polyline(&pts,color)
.add_tri(a,b,c, ca,cb,cc) .add_tri_list(&verts,color) .add_rect(x,y,w,h,color)
.add_disc(cx,cy,r,color) .add_ring(cx,cy,r,stroke,color)
.atlas(&AtlasTextura) // sin esto los quads NO se dibujan
.add_glyph(x,y,w,h,(u0,v0,u1,v1),color) // quad texturado: el TEXTO
.scissor(x,y,w,h) // sólo se toca ese rect (damage)
.max_buffer_bytes(bytes) // baja el techo por buffer; normalmente no se toca
.primitive_count() -> u32
.flush(device, queue, encoder, view, viewport, load_op)
units_per_chunk(stride, max_bytes, multiplo) -> u32
RECT_INSTANCE_STRIDE / LINE_INSTANCE_STRIDE / DISC_INSTANCE_STRIDE / TRI_VERTEX_STRIDE
QUAD_INSTANCE_STRIDE
// la textura de cobertura contra la que se muestrean los quads:
AtlasTextura::new(device, &pipelines, lado) -> Self
.subir(queue, pixeles, (x,y,w,h)) // sube SÓLO ese rect, no el atlas entero
```
Re-exporta `vello` y `peniko` (`Color`, `Image`, `Fill`, etc.).
Cuatro cosas del `flush` que conviene saber y no se ven en la firma:
- **`line_width` es una pluma, no un modo del lote (cambió 2026-08-05).** Cada
línea se queda con el grosor que estaba puesto cuando se la agregó, así que
una grilla fina y una curva gruesa entran en el mismo `flush`. Antes el grosor
era un uniforme del lote entero y dibujar dos grosores obligaba a cortar —
y cortar no cuesta una draw call más: **cuesta el ciclo entero otra vez**,
porque cada `flush` arma su propia textura MSAA 4×, la resuelve y la compone
(2 texturas + 2 render passes por `flush`). El precio es 4 B más por línea
(36 en vez de 32), que baja el techo de un solo buffer de 8,4 M de líneas a
7,4 M; el troceado ya lo maneja.
- **No hay techo de primitivas.** `max_buffer_size` del dispositivo (256 MB en
una Iris Xe) topa a ~8,4 M de instancias en **una** reserva, y pasado eso wgpu
rechaza: la app no se pone lenta, se cae. `flush` parte cada tipo en lotes y
dibuja uno por lote en orden, así que el alpha-over sale idéntico. En el caso
normal es un lote y las mismas cuatro draw calls. `max_buffer_bytes` existe
sobre todo para **testear** el troceado sin reservar cientos de MB.
- **`scissor` recorta el costo, no sólo el dibujo.** Las texturas intermedias
(MSAA 4× + resolve) se crean del tamaño del rect sucio, porque el
`LoadOp::Clear` del attachment y el resolve son de toda la superficie y el
scissor no los toca. Sin eso, pintar un cursor de 32 px costaba limpiar y
resolver la pantalla entera. El rect se pide siempre en **coordenadas de
viewport**; `flush` baja el origen a par (las derivadas de `fwidth`, de las
que sale el AA del disco, se calculan por bloques de 2×2 alineados al target:
un corrimiento impar cambiaría la orla), así que puede tocar una fila y una
columna de más.
- **Los quads sin atlas no se dibujan, y no revientan.** `add_glyph` acumula
igual, pero si nadie llamó a `atlas()` el `flush` los saltea en silencio. La
alternativa —un panic adentro del `flush`— sería reventar el frame de una app
porque alguien pidió texto antes de tener el atlas listo. Que falte el texto es
un síntoma leíble; que se caiga la ventana, no.
### `llimphi-glifos` — el texto del camino GPU-directo
```rust
AtlasGlifos::new(lado) -> Self // atlas cuadrado de cobertura, 1 B/px
.pixeles() -> &[u8] .lado() -> u32 .version() -> u64 .entradas() -> usize
.tomar_sucio() -> Option<Sucio> // qué cambió desde la última vez, y limpia
.limpiar() // única salida cuando `fallos` sube
.fallos / .aciertos / .rasterizados // contadores públicos
quads_de_layout(&mut atlas, &layout, (x,y), &mut Vec<QuadGlifo>) -> usize
quads_de_run(&mut atlas, &glyph_run, (x,y), &mut Vec<QuadGlifo>) // multicolor
```
Rasteriza cada glifo **una sola vez** a un mapa de 8 bits y lo empaqueta por
estantes; de ahí en más pintar texto es pintar quads. Un renglón de 80
caracteres pasa de 80 contornos rellenos a 80 instancias de 48 B.
Lo que hay que saber antes de usarlo:
- **No shapea.** Recibe un `parley::Layout` ya resuelto —el que arma
`llimphi_text::Typesetter`—, así que el texto por GPU-directo y el texto por
vello parten del **mismo** layout. Certificado:
`llimphi-test/tests/texto_gpu_directo.rs` pone los dos caminos sobre el mismo
layout y mide `Δ64:0` — ningún píxel difiere más de 64/255 — con la caja de lo
pintado coincidente y 0,31 % de tinta de diferencia.
- **Un color sólido por run.** El atlas guarda cobertura, no color, así que el
mismo glifo cacheado sirve para cualquier color sin re-rasterizar — pero un
brush (gradiente, imagen recortada al texto) no entra. Eso sigue siendo vello.
- **Ni emoji ni glifos de color**: el atlas es de un canal. Pedirían un atlas
RGBA y un pipeline propio.
- **No hintea**, a propósito: vello rasteriza el contorno crudo, y hintear haría
que el mismo texto saliera con métricas distintas por cada camino.
- **No crece ni desaloja.** Lleno, devuelve `None` y suma a `fallos`. La salida
es `limpiar()` y volver a llenar. Una política de desalojo sin una app que la
presione se diseña a ciegas.
- **Subí sólo el rect sucio.** `tomar_sucio()` + `AtlasTextura::subir` es el
camino esperado; subir el atlas entero por frame anula el punto de cachear (un
atlas de 1024² son 1 MiB por cuadro).
**Hay DOS atlas de glifos en llimphi, y hay que saber cuál usar.** El otro es
`llimphi_widget_terminal::GlyphAtlas`, anterior (Fase 4 del SDD-TERMINAL) y vivo:
lo consume el modo TUI de shuma. `llimphi-glifos` se escribió sin advertirlo.
Desde el 2026-08-05 **comparten rasterizador** (`llimphi_glifos::Rasterizador`,
swash/zeno); antes el terminal tenía `fontdue` propio. Lo que sigue habiendo dos
es el **empaquetado**, y eso es de diseño:
| | `GlyphAtlas` (widget terminal) | `AtlasGlifos` (`llimphi-glifos`) |
|---|---|---|
| clave | `char` (charmap, sin shaping) | `(fuente, id, tamaño, subpíxel, síntesis)` |
| empaquetado | grilla de celdas iguales | estantes de tamaño variable |
| subpíxel | no | 4×4 |
| fuentes | una, monoespaciada | cualquiera, varias por renglón |
**Cuál usar:** una grilla de terminal, `GlyphAtlas` — puede asumir monoespaciado,
celdas enteras y cero shaping, y esas suposiciones son parte de por qué es
rápido. Cualquier otro texto, `llimphi-glifos`.
**Si empaquetás a tu manera**, no escribas un tercer rasterizador:
`Rasterizador::glifo(datos, ClaveGlifo::de_glifo(id, tam))` devuelve el
`MapaGlifo` (cobertura + colocación) y vos lo ponés donde quieras. Y para lo que
no necesita shaping hay `metricas`, `id_de_caracter` y `avance`.
**La migración se hizo contra dos oráculos, no a ojo:**
- `tests/metricas_fontdue.rs` — `fontdue` sobrevive como **dev-dependency** para
esto: la celda sale **idéntica** en 11/12/13/14/16/18 px, y de 42 glifos
comparados sólo **1** difiere (1 px de caja, el `0` a 13 px).
- `tests/dos_atlas.rs` — los dos empaquetados entregan el mismo glifo.
Y un tropiezo que dejó regla: la primera corrida de `dos_atlas` daba **44 %** de
diferencia. No era el rasterizador — `TextBlock::simple` deja
`font_family: None` y parley cae a **sans-serif**, así que comparaba dos
tipografías. **Si comparás glifos, fijá la familia primero.**
**¿Cuándo conviene?** Medido en metal (Iris Xe/Vulkan, `llimphi-gpu-bench`
sección «Texto», 12.096 glifos): el total baja **3,6-5,0×** contra vello, y lo
que importa es la *forma* — el costo del atlas es casi plano con la cantidad de
texto (5,0 → 5,5 ms entre 7.200 y 12.096 glifos) mientras vello crece lineal
(13,6 → 26,4 ms). Pero la mitad de **CPU** del atlas es **peor** que la de vello
(~0,7×): vello apila un id y una posición por glifo, el atlas hace una búsqueda
en tabla y arma un quad. Toda la ganancia viene del lado GPU.
O sea: el atlas es para **mucho texto que se repinta seguido** — grilla de
terminal, editor, etiquetas sobre un canvas denso. Para un rótulo suelto no
compra nada y encima paga el ciclo fijo del `flush` (MSAA + resolve +
composite). **El default sigue siendo vello.**
### `llimphi-image` — decode pipeline
```rust
decode_bytes(&[u8]) -> Result<peniko::Image, DecodeError>
load_path(&Path, max_bytes: u64) -> Result<peniko::Image, DecodeError>
from_rgba8(rgba: Vec<u8>, w: u32, h: u32) -> peniko::Image
// mismo decode, pero además dice de qué espacio venía (para editores):
decode_bytes_con_origen(&[u8]) -> Result<Decodificada, DecodeError>
load_path_con_origen(&Path, max_bytes: u64) -> Result<Decodificada, DecodeError>
Decodificada { imagen: Image, origen: Option<Perfil>, convertida: bool }
Decodificada::devolver_al_origen(&self, rgba: &mut [u8]) -> bool
// perfiles de color (mod perfil):
perfil::leer_icc(&[u8]) -> Option<Perfil> // matrix-shaper; None si no lo es
perfil::convertir_a_srgb(&mut [u8], &Perfil) -> bool // entrada: del perfil a sRGB
perfil::convertir_desde_srgb(&mut [u8], &Perfil) -> bool // salida: de sRGB al perfil
perfil::matriz_a_srgb(&[f32;9]) -> [f32;9] / matriz_desde_srgb(&[f32;9]) -> Option<[f32;9]>
perfil::es_identidad(&[f32;9], tol) -> bool
Curva::{Lineal, Gamma(f32), Tabla(Vec<u16>), Parametrica{tipo, p}}
Curva::a_lineal(v) -> f32 / Curva::desde_lineal(lin) -> f32 // una es inversa de la otra
```
Encapsula el patrón `image::ImageReader + to_rgba8 + Blob + Image::new` que cada
app que decodifica imágenes tenía duplicado. `max_bytes` aplica al tamaño del
@@ -640,6 +816,45 @@ mayor — un PNG 4K ocupa ~64 MB descomprimido); `0` deshabilita el cap.
Re-exporta `peniko::{Blob, Image, ImageFormat}`. Formatos según las features
del crate `image` upstream del workspace (hoy: PNG, JPEG, WEBP).
**Gestión de color (2026-08-05).** `decode_bytes` / `load_path` leen el perfil
ICC embebido y llevan la imagen a **sRGB**. Antes se tiraba: una foto Display P3
—lo que saca cualquier teléfono desde 2016— se pintaba **sobresaturada**, porque
sus rojos, que son más rojos que los de sRGB, se mostraban como si fueran los de
sRGB. Nadie tiene que hacer nada: el caller sigue recibiendo RGBA8 sRGB.
- Cubre perfiles **matrix-shaper** (matriz 3×3 + curva), que es lo que son sRGB,
Display P3, Adobe RGB, Rec.2020 y casi todos los de pantalla y cámara.
- **No** cubre los basados en LUT (`A2B0`), como los CMYK de imprenta: ahí no
convierte y deja la imagen como estaba, que es mejor que inventar.
- Sin perfil se asume sRGB (la convención de la web). Con perfil sRGB, la matriz
da la identidad y **no se recorre un solo píxel**.
- El recorte a gamut es por saturación de canal: un rojo P3 puro no existe en
sRGB y termina en el rojo sRGB más cercano.
**Volver al espacio de origen (2026-08-05).** Un editor no puede conformarse con
recibir sRGB: si `tullpu` abre una foto Display P3 y la guarda, tiene que
guardarla **en P3**, no degradar el archivo del usuario por el solo hecho de
haberlo abierto. Para eso están `decode_bytes_con_origen` / `load_path_con_origen`,
que devuelven una `Decodificada` con el perfil del archivo al lado de los píxeles.
```rust
let d = llimphi_image::load_path_con_origen(ruta, 64 << 20)?;
let mut px: Vec<u8> = d.imagen.image.data.as_ref().to_vec(); // sRGB, para editar
// …edición…
d.devolver_al_origen(&mut px); // false si no hay a dónde volver: se guarda sRGB
```
- `origen: None` **no** significa «era sRGB»: significa que no había perfil o que
era de los que este crate no entiende (CMYK/LUT). En los dos casos los píxeles
quedaron como venían y no hay a dónde volver.
- `convertida: false` con `origen: Some(…)` es el caso corriente: el archivo ya
venía en sRGB y no se recorrió un píxel.
- **La ida y vuelta no es sin pérdida, por dos razones distintas.** La segunda
cuantización a 8 bits mete ±1. Y lo que se recortó al **entrar** (un rojo P3 que
en sRGB no existe) no vuelve: se perdió en el recorte, no al salir. Editar un P3
sin pérdida pide trabajar en P3; el `Perfil` expuesto es lo que lo habilita.
- Quien sólo va a pintar sigue usando `decode_bytes` / `load_path` y no se entera:
son las mismas funciones, que ahora delegan y tiran el perfil.
### `llimphi-text` — shaping
```rust
Typesetter::new() // una por proceso (FontContext es caro)
@@ -806,8 +1021,16 @@ con `on_pointer_enter/leave`.
**empty** — empty-state. `empty_view(Icon, title, description: Option<&str>, &palette)`.
**skeleton** — placeholder con shimmer. `skeleton_view`, `skeleton_box_view(w,h,..)`,
`skeleton_line_view(w,..)`. Requiere redraws periódicos.
**skeleton** — placeholder con shimmer: una banda de gradiente `[low, high,
low]` que **cruza** el rect. `skeleton_view`, `skeleton_box_view(w,h,..)`,
`skeleton_line_view(w,..)`. Requiere redraws periódicos (`spawn_periodic(50ms)`
mientras haya skeletons visibles). Esas tres arrancan reloj propio y están
gateadas a `std`; `skeleton_view_en(inicio, ahora, &pal)` recibe el instante por
argumento y es la que sirve **sin `std`** (jaula/kernel), misma doctrina que el
`today` del calendar. La aritmética es pura y testeable: `banda_shimmer(ancho,
progreso) -> (izq, der)` —la banda entra y sale entera del rect, con piso de
ancho para que en un avatar chico el destello no sea un píxel— y
`progreso_shimmer(segundos) -> [0,1)`.
**carousel** — pager paginado de N páginas. `carousel_view(CarouselSpec {
pages, current, wrap: CarouselWrap::{Wrap, Clamp}, show_arrows, palette,
@@ -831,6 +1054,85 @@ entre meses, fila de iniciales (`L M M J V S D` ó `D L M M J V S` según
`WeekStart`), grilla siempre de 6 filas (no reflowea al cambiar de mes). El
caller le inyecta `today` — el widget no toca el reloj. Base del date-picker:
combinar con `view_overlay` + un `field`/`button` disparador.
`calendar_view_ext(spec, CalendarOpciones { limites, rango, navegacion_anual })`
suma lo que un `<input type=date>` sí tiene, sin tocar a quien ya usa
`calendar_view` (el `Default` de las opciones **es** el calendario de siempre):
`Limites{min,max}` inclusive apaga los días fuera de rango (`aria_disabled`, sin
click) y las flechas cuyo mes destino esté entero afuera (`mes_habil`);
`RangoSeleccion` pinta la banda entre dos extremos y avanza sola con
`.click(fecha)` (1º fija inicio, 2º cierra **ordenando**, 3º empieza de nuevo;
dos clicks al mismo día = rango de un día), con `contiene`/`es_extremo`/`dias`/
`recortado(&limites)` —que **interseca**, no clampea cada punta— para el host;
`navegacion_anual` agrega una fila `« año »` arriba de la de mes (fila aparte y
no cuatro flechas en una: el ancho lo fija la grilla y «septiembre» no entraría).
Teclado: `cal_move_desde_tecla(&KeyEvent) -> Option<CalMove>` es el mapa del
picker nativo (flechas = día/semana, PageUp/Down = mes, **Ctrl**+PageUp/Down =
año, Home/End = bordes del mes) y `nav_date_limitada(fecha, mov, &limites)`
aterriza en el borde en vez de salirse.
**accordion** — secciones plegables (expansion panels).
`accordion_view(AccordionSpec{secciones, abiertas: &[usize], modo, on_toggle,
palette})` con `Modo::{Una, Varias}`; la máquina de qué queda abierto es
`alternar(&abiertas, i, modo) -> Vec<usize>` (pura) y `todas(n, modo)` da el
«expandir/plegar todo» — en `Modo::Una` devuelve **una**, porque devolver todas
dejaría el `Model` en un estado que el propio modo prohíbe. El cuerpo llega como
`Fn() -> View` y **no se construye si la sección está cerrada**: en un acordeón
de veinte secciones con formularios adentro, armar los diecinueve invisibles es
el costo real. `Seccion::con_resumen("Visa ••4242")` muestra el dato a la
derecha **sólo cuando está cerrada**.
**stepper** — asistente por pasos. `stepper_view(StepperSpec{pasos: &[Paso],
actual, cuerpo, on_ir, on_finalizar, palette})`; `Paso::{pendiente, actual,
hecho, con_error}`. La regla de navegación es pura: `puede_ir_a(pasos, actual,
destino)` —atrás siempre, adelante sólo si el actual está `Hecho`, de un salto
sólo si **todos** los intermedios lo están—, más `siguiente`/`anterior`/
`es_ultimo`/`hechos`. En el último paso el botón dice **Finalizar** y emite otro
`Msg`. Los botones del pie y los pasos inalcanzables se **apagan** con
`aria_disabled`, no desaparecen ni quedan mudos.
**form** — **validación** de formularios sobre el `field`. Tres piezas, y la
tercera es la que se hace mal en todos lados:
```rust
Formulario::new(vec![ // qué se pide
CampoDef::new(Id::Correo, "Correo").reglas(vec![Regla::Requerido, Regla::Correo]),
CampoDef::new(Id::Edad, "Edad").regla(Regla::Rango(18.0, 120.0)).ayuda("…"),
])
form.validar(&[(Id::Correo, &m.correo), …]) -> Errores<Id> // qué está mal: TODOS
estado.tocar(id) · estado.enviar() · estado.visibles(&errores) // cuándo se dice
campo_view(&form, id, control, &visibles, &pal) // etiqueta + asterisco + error
resumen_errores_view(&visibles, Msg::IrA, &pal) -> Option<View>
```
`Regla::{Requerido, LargoMin, LargoMax, Numero, Rango, Correo, Propia(fn)}`. Un
valor **vacío pasa todas menos `Requerido`** (si no, un campo opcional en blanco
se queja de formato). El error se muestra al **salir** del campo o al **intentar
enviar**, nunca mientras se tipea por primera vez; tras el submit se muestran
todos, porque esconder alguno sería mentir sobre por qué no se envió.
`campo_view` saca del formulario la etiqueta, el **asterisco de obligatorio**
(derivado de las reglas, no de un flag aparte) y el error: los tres dejan de
escribirse dos veces. `resumen_errores_view` devuelve `None` si no hay nada que
decir, y cada ítem es clickeable para ir al campo — en un formulario largo el
error puede quedar fuera de pantalla y sólo se ve que el botón no hizo nada.
**datetime-picker** — pickers de **fecha** y **hora**, los dos con la misma
forma: un `*_trigger` en el formulario (muestra el valor formateado o el
placeholder, emite `Msg` al click) y un `*_popup` que el host monta en
`App::view_overlay` con scrim que cierra al click-fuera. Sin estado propio: el
valor, si está abierto y el mes en foco viven en el `Model`.
```rust
date_picker_trigger(DateTrigger{valor, formato, placeholder, enabled, on_open, palette})
date_picker_popup(DatePopup{anchor, viewport, valor, view:(año,mes), today,
week_start, opciones: CalendarOpciones, on_pick, on_view_change, on_dismiss,
on_clear, acciones: Acciones::hoy_y_limpiar(), palette, theme})
time_picker_trigger(TimeTrigger{..}) · time_picker_popup(TimePopup{.., paso_minutos})
formatear_fecha(d, FormatoFecha::{Iso,DiaMesAnio,Largo}) · parsear_fecha(&str)
Hora{h,m}: formatear/parsear (FormatoHora::{H24,H12}) · mover(campo,±1,paso) · al_paso
```
El de fecha **compone el `calendar`**, así que hereda límites min/max, rango,
salto de año y mapa de teclas — no reimplementa la grilla. El de hora es grilla
de horas + minutos por paso: elegir la hora **conserva** el minuto y viceversa.
`parsear_fecha` acepta ISO y día-primero con `-`, `/` o `.`, y **rechaza** el año
de dos dígitos (adivinar en una fecha es peor que devolver `None`). `Hora`
resuelve el caso que todos yerran: medianoche es 12 AM y mediodía 12 PM, no 0.
**banner** — tira de status. `banner_view(BannerKind::{Info,Success,Warning,Error}, message)`.
@@ -847,6 +1149,15 @@ accent, &recent_items, &palette)`.
**tabs** — `tabs_view(TabsSpec { labels, active: usize, on_select: Fn(usize)->Msg,
content: View<Msg>, tab_height, palette, tab_width })`. Selección la maneja la app.
`tabs_view_sliding(spec, indicator_t)` cambia el acento por-tab (que se prende de
golpe) por un **realce único que se desliza** hasta `indicator_t` — el índice
animado que el host tween-ea con `llimphi-motion` hacia el activo. Como cada tab
mide lo que dice su label, interpola **posición y ancho** a la vez
(`indicator_bounds_px(&anchos, t)`; los anchos con `tab_widths(&labels,
tab_width, closable)`, la misma cuenta que el render y que `strip_width`), así se
estira al ir hacia un tab más largo. Con overflow viaja dentro de la tira, o sea
que el `strip_offset` lo arrastra solo. `indicator_t = active as f32` da la
posición estática, para un host que no quiera animar.
**router** — navegación por **pila de rutas** con transiciones de página
(el `Navigator` de Flutter, a la Elm). La pila vive en el Model; el widget
@@ -877,12 +1188,31 @@ de un mismo frame.
**splitter** — divisor draggable de 2 panes. `splitter_two(Direction::{Row,Column},
a, a_size, b, b_size, on_resize: Fn(DragPhase, delta)->Option<Msg>, &palette)`.
`PaneSize::{Fixed(px), Flex}`. La app acumula el delta en su Model.
`PaneSize::{Fixed(px), Flex}`. La app acumula el delta en su Model (con
`apply_resize(actual, delta, min, max)`, que clampea por paso).
`splitter_two_ext(…, SplitterOpciones { on_toggle, focus_id })` agrega el
**doble-click** sobre el divisor y lo hace **enfocable**, que es la puerta del
resize por teclado: con el foco puesto, el host traduce la tecla con
`ajuste_desde_tecla(&ev, direction) -> Option<AjusteSplitter>` (las flechas del
eje del divisor —←/→ en `Row`, ↑/↓ en `Column`—, `Shift` para el paso grande,
Home/End a los topes, Enter/Espacio para alternar) y la aplica con
`aplicar_ajuste`. El colapso lo resuelve `alternar_colapso(actual, recordado,
por_defecto) -> (nuevo, a_recordar)`: guarda **cuánto** medía, no que estaba
abierto, así restaurar devuelve el layout del usuario y no un ancho de fábrica.
**scroll** — área de scroll vertical con barra arrastrable. `scroll_y(offset,
content_len, viewport_len, content, on_scroll: Fn(delta_px)->Msg, &palette)`.
Stateless (offset en el Model); rueda autocontenida. Helpers: `clamp_offset`,
`ensure_visible` (selección a la vista), `approach` (scroll suave). Ver §8.
Para darle scroll al cuerpo de un widget de filas está la pieza **compartida**
—la usan `list`, `tree` y `detail-table`, y por eso vive acá y no copiada tres
veces—: `cuerpo_scrolleable(contenido, contenido_len, DesplazamientoV{offset,
viewport_len, on_scroll}, medidor: Option<AltoVisible>, &palette)`.
`AltoVisible` publica el alto **medido** desde el pintado (el alto disponible no
existe hasta que taffy repartió) y `offset_efectivo(offset, viewport_len)` pinta
desde arriba mientras no haya medida — `clamp_offset` con viewport `0` da por
bueno cualquier offset, así que sin esto una lista re-montada con offset
guardado arranca desplazada y salta al cuadro siguiente.
**lazy-list** — lista **perezosa** llave-en-mano (Bloque 22 de
`PLAN-NUCLEO-RETENIDO.md`). A diferencia de `list`/`grid` (el caller windowea a
@@ -896,8 +1226,15 @@ lazy_list_fixed(total, item_h, offset, viewport_h, overscan,
item: Fn(usize)->View, on_scroll: Fn(f32)->Msg, &ScrollPalette) // extent uniforme, ventana O(1)
lazy_list_varied(offsets: &[f32] /*len total+1, tops acumulados*/, offset, viewport_h,
overscan, item, on_scroll, &palette) // extent por fila, O(log n)
// extent CALCULABLE barato (el sabor a preferir para listas grandes de alto variado):
IndiceExtent::nuevo(total, extent: Fn(usize)->f32) // índice por bloques de BLOQUE_EXTENT (1024)
lazy_list_extent(&indice, extent, offset, viewport_h, overscan, item, on_scroll, &palette)
// guarda 1 float cada 1024 filas en vez de 1 por fila (1M filas: 978 floats ≈ 3,9 KB
// contra 4 MB) y NUNCA construye un View para medir. Consulta = binaria + ≤1 bloque.
// helpers puros: visible_window(total, item_h, offset, vp, overscan) -> Window{first,count,y0}
// visible_window_varied(offsets, offset, vp, overscan), content_height(total, item_h)
// visible_window_extent(&indice, &extent, offset, vp, overscan)
// IndiceExtent::{y, indice_en, alto_total, len_indice}
// altura MEDIDA (listas heterogéneas cuyo alto depende del contenido/reflow):
item_offsets(total, ancho, item: Fn(usize)->View) -> Vec<f32> // mide cada ítem → offsets acumulados
// el caller lo llama al cambiar contenido/ancho, cachea el Vec, y por frame usa
@@ -959,7 +1296,25 @@ ToolbarItem::new(icon: Fn(size_px, color) -> View<Msg>, on_click: Msg)
.with_label("subir").active(bool).enabled(bool)
ToolbarGroup::new(vec![items...])
toolbar_view(groups: Vec<ToolbarGroup<Msg>>, height: f32, &ToolbarPalette) -> View<Msg>
// Overflow + tooltips (aditivo; Default = la barra de siempre):
let medidor = AnchoBarra::new(); // se lee el cuadro siguiente
let anchos = item_widths(&groups); // misma cuenta que el render
let visibles = overflow_count(&anchos, medidor.px(), MORE_W, ITEM_GAP);
let ocultos = items_ocultos(&groups, visibles); // para armar el popup
toolbar_view_ext(groups, 36.0, &pal, ToolbarOpciones {
medidor: Some(medidor), visibles: Some(visibles),
on_overflow: Some(Msg::AbrirMas), // dibuja «⋯» sólo si sobra algo
on_hover: Some(Arc::new(|i| Msg::Tooltip(i))), // Some(i) al entrar, None al salir
})
```
El **ancho disponible** no se sabe hasta que taffy repartió, así que `AnchoBarra`
lo publica desde el pintado y el host lo usa en el cuadro siguiente (un frame de
latencia en el primero y en el resize — lo mismo que hace cualquier toolbar con
overflow). `item_offsets(&groups)` da `(left, width)` de cada ítem contando
padding, gaps **y separadores**: es el ancla del `tooltip` (que es render puro y
lo monta el host en su `view_overlay`). El menú de overflow también lo monta el
host, con `context_menu_view`; `items_ocultos` le da label/estado/`Msg` en orden
para que el índice del `on_pick` no se desincronice.
**grid** — grilla 2D virtualizada. `ventana_visible(total, vp_w, vp_h, scroll_fila,
&metrics) -> VisibleWindow` para virtualizar, luego `grid_view(GridSpec { cells:
@@ -967,11 +1322,16 @@ Vec<GridCell { content, label, selected, on_click }>, cols, metrics, caption, ..
**list** — lista vertical virtualizada. `list_view(ListSpec { rows: Vec<ListRow {
label, selected, on_click }>, total, caption, truncated_hint, row_height, palette })`.
La app prefiltra las filas visibles.
La app prefiltra las filas visibles. `list_view_ext(spec, ListOpciones{scroll,
medidor})` le da **scroll real** (no sólo `clip`): las filas se desplazan y
tienen barra arrastrable, mientras el `caption` y el aviso de truncado quedan
fijos — son rótulos de la lista, no contenido de ella.
**tree** — árbol expand/collapse. `tree_view(TreeSpec { rows: Vec<TreeRow { label,
depth, has_children, expanded, selected, on_toggle, on_select }>, row_height,
indent_px, palette })`. La app aplana el árbol según nodos expandidos.
`tree_view_ext(spec, TreeOpciones{scroll, medidor})` le da el mismo scroll real
que la `list`.
**navigator** — navegador data-agnóstico de nodos en dos modos conmutables
(**árbol** ↔ **grafo**, reusa tree + nodegraph). Render-only: la app guarda
@@ -1089,6 +1449,42 @@ table_height(n_rows, has_header) -> f32 // pre-calcular alto del
La variante `list_view` no se confunde con la `list` virtualizada de §13:
ésta es **una sola columna editable** que delega en `table_view([], ..)`.
**detail-table** — la vista *detalle* de un file manager: grilla read-only con
columnas `ColWidth::{Flex(peso), Fixed(px)}` y encabezados que ordenan (▲/▼).
`detail_table_view(DetailSpec{columns, rows, sort, row_height, caption,
palette}, on_sort)`; `detail_table_view_dnd(..)` agrega arrastre por fila.
`detail_table_view_ext(spec, on_sort, DetailOpciones{scroll, medidor,
on_resize})` suma **encabezado fijo con cuerpo scrolleable** y los **handles de
resize** entre columnas:
```rust
DetailScroll { offset, viewport_h, on_scroll: Fn(f32)->Msg } // compone scroll_y
AltoCuerpo::new() // publica el alto medido del cuerpo (post-layout)
offset_efectivo(offset, viewport_h) // sin medir todavía ⇒ se pinta desde arriba
resize_col(ancho, delta, min) // el host aplica el delta del handle
```
El «sticky» no es un truco de posición: con `scroll` el encabezado queda
**afuera** del cuerpo desplazado, así que no viaja. El cuerpo se compone del
`scroll` canónico (rueda, barra, clamp y fling ya viven ahí). El handle lleva
`draggable` y **no** `on_click`, así agarrarlo no dispara el orden de la columna
que tiene debajo — que es el bug clásico de esta UI.
**Es también la DataTable** read-only ordenable y paginable — no hay una tabla
aparte para eso. Lo que faltaba y ya está:
```rust
ciclar_orden(sort_actual, col) -> Option<(col, SortDir)> // asc → desc → SIN orden
orden_de_filas(&celdas, col, dir, &TipoCol) -> Vec<usize> // permutación estable
TipoCol::{Texto, Numero, Natural, Propio(fn)} // «archivo2» < «archivo10»
Pagina{indice, tamano}: rango(total) · total_paginas · acotada(total) · etiqueta
botones_pagina(actual, total, max) -> Vec<BotonPagina> // 1 … 4 5 6 … 20
pager_view(pagina, total_filas, Fn(usize)->Msg, &palette)
```
`orden_de_filas` devuelve **índices**, no filas movidas: así ordena sin conocer
el tipo del caller (una `DetailRow` lleva `Msg` y `View`) y sin perder el orden
original, que es al que vuelve el tercer click. `Numero` manda al **final** lo
que no parsea (un «—» encabezando no es información) y no hay variante `Fecha`:
en ISO ordena bien como `Texto`, y para otros formatos está `Propio` — un parser
de fechas acá sería un segundo parser distinto del del `datetime-picker`.
**text-editor** — editor IDE (capa visual sobre el core agnóstico). La app guarda
`EditorState`:
```rust
@@ -1405,6 +1801,239 @@ fn mi_card() {
misma firma que `View::gpu_paint_with`; limpia a `bg` y te deja pintar directo,
después lee de vuelta para el golden (ver `llimphi-test/tests/gpu_golden.rs`).
### El golden es el ÚLTIMO recurso, no el primero
Un golden es una imagen bendecida: pasa a ser correcta porque alguien dijo que
lo era. Eso funciona para «esto no cambió», pero no contesta «¿esto está bien?»,
y para bendecirla hay que mirarla — justo lo que §8 pide evitar.
Casi siempre hay algo mejor: **el oráculo es la otra manera de calcular lo
mismo**. En vez de comparar contra una imagen, se pinta el mismo árbol por dos
caminos que tienen que coincidir, y la afirmación pasa a ser objetiva. Los que
ya existen en el repo, todos escritos así:
| test | los dos caminos | resultado |
|---|---|---|
| `boundary_cache.rs` | `paint` contra `paint_cached` | 0 px |
| `gpu_lotes.rs` | un buffer entero contra troceado | idénticos |
| `gpu_grosores.rs` | un lote con dos plumas contra dos lotes de una | idénticos |
| `gpu_scissor.rs` | pasada completa contra recorte | mismo píxel exacto |
| `cpu_vs_gpu.rs` | `vello_cpu` (wawa) contra `vello` (Linux) | tabla por patrón |
| `wire_vs_directo.rs` | por el cable contra en proceso | bit a bit |
| `hybrid_vs_cpu.rs` | `vello_hybrid` (Mali) contra CPU | Δ1:0 |
| `texto_gpu_directo.rs` | atlas de glifos contra vello | Δ64:0 |
### Cuatro reglas que salieron de escribirlos
1. **Poné el umbral DESPUÉS de medir, no antes.** Los umbrales «prudentes» que
se eligen a ojo casi nunca se rozan, y un umbral que nada roza no es un gate.
En `hybrid_vs_cpu` el 5 % sobre Δ16 que parecía razonable no habría agarrado
el sabotaje; lo medido (0 px sobre Δ1) sí. Dejá el número medido escrito al
lado del `assert`, con fecha: es lo que permite decidir el umbral siguiente.
2. **Saboteá el test antes de creerle.** Rompé a mano lo que dice cubrir y mirá
que falle *ese* test y no otro. Dos veces en esta tanda el test pasaba con la
pieza rota: `boundary_gradiente_imagen` reconstruía el objeto con `default()`
en vez de clonarlo, y cada `default()` hacía un `Blob` nuevo — o sea que
invalidaba por identidad y nunca llegaba a probar el hash. Cuatro de cinco
sabotajes pasaban.
3. **Todo test de igualdad necesita una guarda de arnés.** Dos lienzos vacíos
coinciden perfecto. Afirmá además que se pintó algo (`pintados > total/8`),
que el caché acertó (`hits > 0`), que se ejecutaron las órdenes
(`ejecutadas > 0, salteadas == 0`), que se compararon todos los casos
(`probados == patrones().len()`). Sin eso, el test firma cualquier cosa.
4. **Recorré el camino difícil, no el fácil.** `texto_gpu_directo` sube sólo el
rect sucio y no el atlas entero, porque es lo que hace una app; y tiene un
caso incremental aparte justamente porque en un atlas recién hecho lo sucio
arranca en (0,0), donde un error de coordenada pasa desapercibido.
Cuándo sí un golden: cuando **no hay** segundo camino. `gpu_golden.rs` (starfield
por GPU directo) es el caso — no existe otra forma de producir esa imagen, así
que la referencia tiene que ser ella misma.
### Correr la suite contra la GPU de verdad
```bash
LLIMPHI_TEST_GPU=1 cargo test --no-fail-fast -p llimphi-test -p llimphi-raster -p llimphi-hybrid
```
El arnés fuerza el adapter de **software** para que un golden valga lo mismo en
cualquier máquina, y eso está bien — pero deja una pregunta sin contestar: *¿el
hardware hace lo mismo?* Con esa env se ignora la preferencia y se usa la GPU
real. Es para preguntar a mano, no para cambiar la referencia.
**Medido el 2026-08-05 (Iris Xe/Vulkan contra llvmpipe), y el resultado dice más
que cualquier argumento sobre goldens:**
| | software | GPU real |
|---|---:|---:|
| tests en verde | 113 | **112** |
El único que se cae es `gpu_golden` — el único que compara contra una imagen
bendecida (2,74 % de píxeles, Δ máx 44). **Los ocho oráculos pasan en las dos**,
y con casi el mismo número:
| test | software | GPU real |
|---|---|---|
| `texto_gpu_directo` | Δ16:417 Δ64:0 · tinta +0,31 % | Δ16:423 Δ64:0 · tinta +0,29 % |
| `hybrid_vs_cpu` | Δ0:236 Δ1:0 | Δ0:232 Δ1:0 |
| `boundary_cache` / `gpu_lotes` / `gpu_scissor` | 0 px | 0 px |
Un oráculo compara dos cosas que el mismo rasterizador produce, así que la
diferencia de rasterizador **se cancela**. Una foto no puede cancelar nada. Por
eso `gpu_golden` se saltea solo cuando se pide GPU: así el comando de arriba es
una pregunta limpia y no una corrida con un fallo conocido de ruido.
---
## 16.ter Semántica accesible + tests por consulta
Un `View` describe **cajas**: posición, color, texto. No dice qué *significan*.
Esa segunda capa se declara con `View::role` + `View::aria_*`, y paga dos veces:
1. **Lectores de pantalla, gratis.** El runtime traduce el árbol montado a
AccessKit en cada frame (`llimphi-ui::a11y`), y el adapter de Linux habla
**AT-SPI2 por DBus** — el mismo bus que usa Orca. No hay que encender nada:
`accesskit_winit` ya está cableado en el event loop, sin feature gate.
2. **Tests que preguntan por significado**, no por píxeles (ver más abajo).
### Declarar
```rust
View::new(style)
.role(Role::Button) // qué ES el nodo
.aria_label("Guardar") // cómo se llama (si no hay texto visible)
.aria_value("70%") // dato dinámico: valor de un slider, texto de un input
.aria_description("…") // contexto extra; el lector lo dice aparte
.aria_pressed(activo) // …y los flags: checked/pressed/expanded/
.aria_disabled(!habilitado) // disabled/readonly/required
```
**Dónde va el rol: en el nodo que lleva el `on_click`**, no en el contenedor.
AccessKit sólo ofrece `Action::Click` donde hay handler; un rol `Button` sobre
una caja sin handler produce un botón que el lector no puede activar.
**El nombre accesible** es `aria_label` si está, si no el `text` visible. Por eso
un nodo que ya pinta su texto no necesita label — pero si el texto vive en un
**hijo** (ícono + etiqueta en una fila), el nodo con el rol queda sin nombre y hay
que dárselo explícito. Es el error más común.
Roles disponibles (`llimphi_ui::Role`): `Button` `TextInput` `MultilineTextInput`
`Heading` `Checkbox` `Switch` `Label` `Link` `Image` `Slider` `ProgressBar`
`MenuItem` `Menu` `MenuBar` `Tab` `TabList` `Toolbar` `List` `ListItem` `Tree`
`TreeItem` `Table` `Row` `Cell` `ColumnHeader` `Dialog` `Alert` `Status`
`Tooltip` `Terminal` `Splitter` `Group`. Si falta uno, agregalo en
`llimphi-compositor/src/semantics.rs` **y** mapealo en `llimphi-ui/src/a11y.rs` —
cuando aparezca un caller real, no antes.
**Cuándo NO declarar:** decorativo puro (divider, gradiente, sombra, `View`
envoltorio de layout). Un `Role::Group` en cada caja ensucia la navegación más de
lo que ayuda.
### Consultar — `llimphi_test::query`
La misma declaración habilita tests que **no miran píxeles**:
```rust
use llimphi_test::query::{Sel, Ui};
use llimphi_ui::Role;
let ui = Ui::new(app.view(), 800.0, 600.0);
assert_eq!(ui.find_all(Role::Tab).len(), 3);
let guardar = ui.get(Sel::new().role(Role::Button).label("Guardar"));
assert!(guardar.is_enabled());
// Clickear = obtener el Msg y metérselo al update.
app.update(guardar.click().unwrap());
```
- **No necesita GPU** — sólo `mount` + layout. Corre en cualquier CI, a
diferencia de `Harness` (que pide lavapipe).
- **`Ui::get`** paniquea con el árbol semántico entero en el mensaje si no
encuentra (o si hay más de uno). El fallo se lee sin abrir un PNG.
- **`Ui::dump()`** vuelca el árbol como texto indentado — la evidencia barata que
pide CLAUDE.md §8:
```
· @0,0 510×100
Heading txt:"Datos" @0,0 50×100
TextInput "Nombre" = "sergio" required=true @50,0 200×32
Button "Guardar" [click] @250,0 80×32
Button "Cancelar" disabled [click] @330,0 80×32
· [click] @410,0 100×100
```
El `·` es un nodo sin rol declarado. El último de ese volcado —`· [click]`— es
un canvas con handler y sin semántica: un lector de pantalla no puede
anunciarlo. Verlo en el dump **es** el hallazgo.
- **Selectores:** `Sel::new()` + `.role() .label() .label_contains() .value()
.checked() .enabled() .clickable() .focusable() .role_none()`. Atajos por
conversión: `ui.find(Role::Button)` y `ui.find("Guardar")` (por nombre).
- **Auditar lo mudo:** `ui.find_all(Sel::new().clickable(true).role_none())`
devuelve exactamente los nodos interactivos sin semántica — lo que un lector
de pantalla no sabe anunciar.
### Estado de la cobertura
**46 de 70 widgets anotados.** Los 24 restantes lo están **a propósito** — ver
abajo.
Anotados: `app-header` `avatar` `badge` `banner` `breadcrumb` `button`
`calendar` `chip` `color-picker` `context-menu` `detail-table` `dock-rail`
`empty` `fab` `field` `gauge` `list` `menubar` `modal` `nodegraph` `progress`
`range-slider` `rating` `rive-button` `segmented` `select` `shortcuts-help`
`slider` `spinner` `splitter` `stat-card` `status-bar` `switch` `table` `tabs`
`terminal` `text-editor` `text-input` `theme-switcher` `timeline` `toast`
`toolbar` `tooltip` `transport` `tree` `waveform`.
**Sin anotar por diseño** (el módulo `semantics` lo pide explícitamente: un
`Role::Group` en cada caja ensucia la navegación más de lo que ayuda):
- *Contenedores de layout puro* — `card` `panel` `grid` `wrap` `hero`
`fitted-box` `tiled` `panes` `scroll` `router` `scaffold` `lazy-list`.
La semántica la traen sus hijos.
- *Decorativos* — `skeleton` `splash` `wawa-mark`.
- *Sin `View` propio* — `clipboard` `text-area` `text-editor-core`
`text-editor-lsp` `carousel` (sólo helpers), `edit-menu` (produce
`ContextMenuItem`, que ya viene anotado), `gallery` (binario de ejemplo).
- *Componen widgets ya anotados* — `navigator` (sobre `tree` + `nodegraph`),
`rag-sidebar` (sobre `segmented` + `dock-rail`).
### Activación: el rol y el handler no tienen por qué coincidir
Los widgets ponen el rol en la **fila** (`TreeItem`, `Row`, `ListItem`) porque es
la unidad que el lector navega, pero el `on_click` suele vivir en un **hijo** (el
nodo del label). `a11y::click_target` resuelve ese desajuste:
1. Si el nodo mismo tiene handler, es él.
2. Si no, y **declara un rol**, se delega en su subárbol — siempre que haya
**exactamente un** descendiente clickeable.
3. Si hay varios, no se delega: la elección sería arbitraria, y esos hijos ya
están en el árbol como nodos propios para activarlos directo.
El requisito de rol en (2) es lo que evita que toda caja de layout se vuelva
activable. El runtime además sintetiza la posición para los handlers
`on_click_at` (centro del rect del nodo en el layout del frame) — misma
convención que `query::NodeRef::click`.
### Dientes y botones animados: el `label` es obligatorio
`DockRailItem` y `RiveButton` no tienen texto en el árbol (el ícono lo pinta un
closure opaco), así que el nombre accesible viaja explícito:
```rust
DockRailItem::nombrado(id, activo, "Configuración") // recomendado
DockRailItem::new(id, activo) // queda mudo
RiveButton::builtin().con_label("Ejecutar")
```
Y un patrón que apareció en casi todos: **el ítem sólo-ícono**. Un botón sin
texto visible (`toolbar` sin `with_label`, transport, chevrones de calendario,
el `×` de una pestaña) es mudo salvo que se le dé `aria_label` a mano. Los que
tienen un nombre derivable ya lo llevan; los que dependen del caller están
señalados en el código.
---
## 17. Comandos y demos
@@ -1419,27 +2048,86 @@ cargo run -p llimphi-ui --example counter --release # bucle Elm completo
cargo run -p llimphi-ui --example editor --release # text field + teclado
cargo run -p llimphi-ui --example gpu_paint_demo --release
cargo run -p llimphi-gallery --release # showcase de TODO el kit
cargo test -p llimphi-gallery # la vitrina como CALLER: los widgets responden
cargo run -p nada --release # editor real para ejercitar widgets
# benchmark GPU directo vs vello:
# benchmark GPU directo vs vello (primitivas Y texto — pedile GPU real, en
# llvmpipe la mitad de rasterizado no decide nada):
cargo run -p llimphi-gpu-bench --release
# perfilador por frame (a stderr cada 60 frames: media/p95 por etapa):
LLIMPHI_PERF=1 cargo run -p <crate> --release
# ...con conteo de asignaciones de heap por frame y su desglose por etapa:
LLIMPHI_PERF=1 cargo run -p <crate> --release --features llimphi-ui/perf-alloc
# dónde poner un .repaint_boundary() en TU app (ver más abajo):
LLIMPHI_BOUNDARY_HINT=1 cargo run -p <crate> --release
# un update que tarda de más y congela la UI:
LLIMPHI_UI_SLOW_MS=50 cargo run -p <crate> --release
```
`LLIMPHI_PERF=N` reporta cada N frames (default 60) el desglose
`view/mount/layout/paint/raster/gpu/present` en µs + `layout-reuso`, el caché de
`repaint_boundary` y —si está la feature `perf-alloc`— `alloc <media>/<p95>
(<KB>) [view mount layout paint …]`. Sin esa feature el tramo de allocs
dice `n/a` en vez de ceros falsos. El contador es
(<KB>) [view N·KB mount N·KB layout N·KB paint N·KB resto N·KB]`, donde `resto`
es el tramo posterior a paint (raster vello + GPU + present) y el desglose suma
el total. Sin esa feature el tramo de allocs dice `n/a` en vez de ceros falsos.
Ojo: los contadores son del proceso, no del hilo — lo que asigne otro hilo
durante una etapa cae en esa etapa. El contador es
`llimphi_ui::alloc::ContadorAlloc`: si tu app ya elige su allocator, instalalo a
mano (`#[global_allocator]`) en vez de usar la feature. El camino `cache_hit`
(frame retenido entero) **no** se muestrea a propósito.
`llimphi-gallery` (`src/main.rs`, ~967 líneas) es la **referencia viva** del
### `LLIMPHI_BOUNDARY_HINT` — dónde poner un `repaint_boundary`
`.repaint_boundary(clave)` cachea la rasterización de un subárbol quieto y la
reusa mientras su contenido no cambie (Bloque 23). El problema práctico nunca fue
la API sino **dónde**: marcar un subárbol que en realidad cambia todos los
cuadros no es neutro —se paga el hash entero cada frame y no se ahorra nada—, y
desde afuera un panel quieto y uno que se reconstruye idéntico a sí mismo se ven
igual. Por eso ninguna app lo usaba.
```bash
LLIMPHI_BOUNDARY_HINT=1 cargo run -p <tu-app> --release # reporta cada 120 cuadros
LLIMPHI_BOUNDARY_HINT=300 … # cada 300
```
Usá la app un rato con el puntero **fuera** de lo que sospechás. La salida:
```
[llimphi] 2 subárbol(es) quieto(s), 431 nodos que se repintan de gusto
— poneles .repaint_boundary(clave):
nodo 12 · 287 nodos · (0,48 260×672) · 118 cuadros quieto «Archivos»
nodo 640 · 144 nodos · (0,0 1920×48) · 118 cuadros quieto «Inicio»
```
Cada línea es el subárbol **máximo** que vino idéntico: si un panel entero está
quieto no se listan también sus hijos. El texto es el primero que aparece
adentro, para que lo reconozcas sin contar nodos.
Es una **predicción, no una opinión**: el detector consulta las mismas tres
funciones que el caché de verdad (posición limpia, paint-puro, y el mismo hash
que decide hit o miss), así que lo que señala el caché lo va a acertar. Dos
cosas que no dice:
- **Si conviene.** Sugiere «esto habría sido cache-hit»; el hash se paga siempre.
Por eso ignora subárboles de menos de 16 nodos.
- **Que esté quieto para siempre.** Un panel quieto 120 cuadros puede cambiar al
siguiente click, y ahí el boundary falla y repinta — correcto, pero sin ahorro.
Con el puntero **adentro** de un subárbol, ni el caché lo sirve ni el detector lo
sugiere: el hover cambia lo pintado. Y es diagnóstico, no producción — hashea
cada candidato por cuadro. Encendelo, anotá, apagalo.
**Qué descalifica a un subárbol** (el detector se calla y el boundary, si lo
ponés igual, se pinta fresco): cualquier `paint_with`/`gpu_paint_with`, una
animación viva, un ripple, un hero, una `mask_image`, o estar bajo un
clip/alpha/transform de un ancestro. **Degradé e imagen sí entran** desde
2026-08-05 — antes no, y como un sidebar real casi siempre tiene un ícono o un
fondo con degradé, un solo nodo así descalificaba el panel entero y el caché no
servía para lo que existía.
`llimphi-gallery` (`src/main.rs`) es la **referencia viva** del
patrón completo: `Model`/`Msg`/`init`/`update`/`view`/`view_overlay` con overlays
mutuamente excluyentes (modal > atajos > toasts > context-menu > dropdown).
Controles: click en switches/segments; "Mostrar toast"/"Abrir modal"; `?` abre
+80 -48
View File
@@ -1,88 +1,120 @@
app-bus
foreign-lottie
foreign-vox
grafo-nav
llimphi-3d
llimphi-anim
llimphi-cpu
llimphi-glifos
llimphi-hal
llimphi-raster
llimphi-theme
llimphi-layout
llimphi-text
llimphi-compositor
llimphi-ui
llimphi-widget-text-editor-core
llimphi-widget-text-editor
llimphi-clipboard
llimphi-icons
llimphi-widget-text-input
llimphi-module-bookmarks
llimphi-module-command-palette
llimphi-module-diff-viewer
llimphi-module-fif
llimphi-module-file-picker
llimphi-module-mini-map
llimphi-module-selector
llimphi-module-symbol-outline
llimphi-motion
llimphi-surface
llimphi-term-graphics
llimphi-theme
llimphi-wasm-wasi
llimphi-widget-text-editor-core
llimphi-wire-escena
llimphi-wire-view
llimphi-hybrid
llimphi-raster
llimphi-text
llimphi-voxel
llimphi-widget-panel
llimphi-widget-app-header
llimphi-wasm-app-sdk
llimphi-compositor
llimphi-gpu-bench
llimphi-image
llimphi-wasm-demo-counter
llimphi-wasm-demo-form
llimphi-icons
llimphi-test
llimphi-ui
llimphi-widget-accordion
llimphi-widget-avatar
llimphi-widget-badge
llimphi-widget-banner
llimphi-widget-breadcrumb
llimphi-widget-button
llimphi-widget-calendar
llimphi-widget-card
llimphi-widget-carousel
llimphi-widget-chip
llimphi-widget-slider
llimphi-widget-color-picker
llimphi-widget-context-menu
llimphi-widget-detail-table
llimphi-widget-dock-rail
llimphi-widget-edit-menu
llimphi-widget-empty
llimphi-widget-fab
llimphi-widget-field
llimphi-widget-fitted-box
llimphi-widget-gauge
llimphi-widget-grid
llimphi-widget-hero
llimphi-widget-list
llimphi-widget-menubar
llimphi-widget-modal
llimphi-widget-nodegraph
llimphi-widget-tree
llimphi-widget-navigator
llimphi-widget-panel
llimphi-widget-panes
llimphi-widget-progress
llimphi-widget-range-slider
llimphi-widget-rating
llimphi-widget-router
llimphi-widget-scaffold
llimphi-widget-scroll
llimphi-widget-segmented
llimphi-widget-select
llimphi-widget-shortcuts-help
llimphi-widget-skeleton
llimphi-widget-slider
llimphi-widget-spinner
llimphi-widget-splash
llimphi-widget-splitter
llimphi-widget-stat-card
llimphi-widget-status-bar
llimphi-widget-stepper
llimphi-widget-switch
llimphi-widget-table
llimphi-widget-tabs
llimphi-widget-terminal
llimphi-widget-text-area
llimphi-widget-text-editor-lsp
llimphi-widget-text-editor
llimphi-widget-theme-switcher
llimphi-widget-tiled
llimphi-widget-timeline
llimphi-widget-toast
llimphi-widget-toolbar
llimphi-widget-tooltip
llimphi-widget-transport
llimphi-widget-waveform
llimphi-widget-wawa-mark
llimphi-widget-wrap
llimphi
llimphi-clipboard
llimphi-layer
llimphi-lottie
llimphi-mesh
llimphi-module-diff-viewer
llimphi-module-mini-map
llimphi-motion
llimphi-surface
llimphi-svg
llimphi-widget-app-header
llimphi-widget-breadcrumb
llimphi-widget-card
llimphi-widget-context-menu
llimphi-widget-datetime-picker
llimphi-widget-dock-rail
llimphi-widget-empty
llimphi-widget-form
llimphi-widget-lazy-list
llimphi-widget-list
llimphi-widget-modal
llimphi-widget-select
llimphi-widget-shortcuts-help
llimphi-widget-status-bar
llimphi-widget-tabs
llimphi-widget-terminal
llimphi-widget-text-editor-lsp
llimphi-widget-text-input
llimphi-widget-toast
llimphi-widget-transport
llimphi-workspace
llimphi-anim-studio
llimphi-module-bookmarks
llimphi-module-command-palette
llimphi-module-fif
llimphi-module-file-picker
llimphi-module-symbol-outline
llimphi-widget-color-picker
llimphi-widget-detail-table
llimphi-widget-edit-menu
llimphi-widget-grid
llimphi-widget-menubar
llimphi-widget-rag-sidebar
llimphi-widget-rive-button
llimphi-widget-splash
llimphi-widget-stat-card
llimphi-widget-table
llimphi-widget-text-area
llimphi-widget-tree
llimphi-gallery
llimphi-widget-gallery
llimphi-widget-navigator
+40 -8
View File
@@ -2,7 +2,7 @@
> Native UI framework — 2D **and** 3D: HAL · raster · layout · text · theme · ui · 3D voxel engine — plus widgets and modules.
`llimphi` is a sovereign, retained-mode UI framework with an Elm-style loop (`input → update → view → layout → raster → present`). Declarative pipeline over `vello` 0.7 + `wgpu` 27 + `taffy` + `parley` 0.6, with `Dark/Light/Aurora/Sunset/Tawa` themes and a multi-platform HAL (Wayland · X11 · Win32 · Android · Wawa bare-metal). It powers a full Rust application suite; this repository is the framework extracted to stand on its own.
`llimphi` is a sovereign, retained-mode UI framework with an Elm-style loop (`input → update → view → layout → raster → present`). Declarative pipeline over `vello` 0.9 + `wgpu` 29 + `taffy` 0.12 + `parley` 0.6, with `Dark/Light/Aurora/Sunset/Tawa` themes, AccessKit accessibility and a multi-platform HAL (Wayland · X11 · Win32 · Android · Wawa bare-metal). It powers a full Rust application suite; this repository is the framework extracted to stand on its own.
<p align="center">
<img src="docs/showreel.gif" alt="Llimphi showreel — real widgets (switch, slider, progress, segmented control, buttons, radial) animating live, then reflowing across layouts" width="900">
@@ -42,23 +42,55 @@ cargo run -p llimphi-ui --example counter # ~124 LOC: the full Elm loop on scr
## Install
From crates.io — the `llimphi` facade re-exports `llimphi-ui`, `llimphi-theme` and `llimphi-3d`:
```sh
cargo add llimphi
```
Or crate by crate (widgets are one crate each — pull only what you use):
```toml
[dependencies]
llimphi-ui = { git = "https://git.tawasuyu.net/tawasuyu/llimphi.git" }
llimphi-theme = { git = "https://git.tawasuyu.net/tawasuyu/llimphi.git" }
# widgets are one crate each — pull only what you use:
llimphi-widget-button = { git = "https://git.tawasuyu.net/tawasuyu/llimphi.git" }
llimphi-ui = "0.1"
llimphi-theme = "0.1"
llimphi-widget-button = "0.1"
```
## Beyond the 2D widget kit
Pieces worth naming because they change what an app can be:
- **Four paths to pixels, one compositor.** `vello` on GPU compute is the main road;
`llimphi-cpu` (`vello_cpu`) paints the bare-metal framebuffer; `llimphi-hybrid`
(`vello_hybrid`) is CPU+GPU with no compute shaders, for hardware that lacks it
(this is the one that runs on a phone's Mali); and `llimphi-wire-escena` serializes
a scene so a sandboxed guest can compose it and a host replay it. The same scene
tree goes through all of them and the output is diffed **per pixel** — wire vs
direct is bit-identical, hybrid vs CPU never differs by more than 1/255.
- **Text.** `parley` shapes; `llimphi-glifos` rasterizes each glyph once into a
shelf-packed coverage atlas (4 subpixel positions per axis) so a line of text is
quads instead of outlines — 3.65× faster on the direct-GPU path.
- **Testing.** `llimphi-test` is the headless, deterministic golden-image harness —
the reason a UI change can be certified as *text* (pixel-diff counts per threshold)
instead of by looking at a screenshot. Where two paths should paint the same thing,
the oracle is the other path, not a blessed image.
- **Animation.** `llimphi-anim` is a Rive-style, clip-agnostic state machine;
`llimphi-lottie` and `llimphi-svg` are thin bridges to `velato` and `vello_svg`;
`llimphi-mesh` renders deformable meshes with bones and skinning.
- **Surfaces.** `llimphi-layer` runs an `App` as a `wlr-layer-shell` surface (a bar
anchored to a screen edge) rather than a window. `llimphi-term-graphics` covers the
terminal graphics protocols.
## Compatibility
- **Linux/Wayland** — primary backend.
- **Linux/X11** — via XWayland.
- **macOS / Windows** — `winit` + `wgpu`.
- **Android** — HAL via `android` crates.
- **Wawa bare-metal** — alternative framebuffer HAL.
- **Android** — HAL via `android` crates (`hybrid` is the renderer that works on Mali).
- **Wawa bare-metal** — alternative framebuffer HAL, over `llimphi-cpu`.
Crates listed in [README.md](README.md) (framework, widgets, modules, android).
Full crate index (framework · widgets · modules · android) in [MANUAL.md](MANUAL.md) §19.
## Considerations
+1 -1
View File
@@ -110,7 +110,7 @@ Apps actualmente en GPUI que deben portarse:
- `llimphi-workspace` (chasis tipo tmux) + `llimphi-gallery` (showcase) + `llimphi-motion`/`llimphi-icons`/`llimphi-surface` auxiliares.
### Pendiente
- Runtime sobre framebuffer de `wawa` (`WawaFramebufferSurface`) reusando el compositor winit-free — habilitado por el split pero aún no escrito.
- ~~Runtime sobre framebuffer de `wawa` (`WawaFramebufferSurface`)~~ **HECHO (verificado 2026-08-04), y por otro camino que el que este SDD imaginaba.** No hay un `WawaFramebufferSurface` que implemente el trait `Surface` de wgpu: hay `llimphi-wawa`, la **cáscara** que corre el ciclo entero `View<Msg> → mount → layout → paint → volcar` contra un `&mut [u8]` + stride, **sin winit, sin GPU y sin `std`**, rasterizando por CPU (`llimphi-cpu`). Es más honesto que la idea original — en el kernel no hay Vulkan que abstraer. No reimplementa nada del compositor: es el mismo `llimphi-compositor` con otras features. Sin bucle propio (manda el reactor del kernel: expone `fotograma()`/`click()`), sin reloj (el instante entra por argumento) y sin tocar hardware (quién mapeó esa memoria es asunto del kernel). 10/10 tests: `cargo test --manifest-path 02_ruway/llimphi/llimphi-wawa/Cargo.toml --no-default-features --features std`. Está EXCLUIDO del workspace madre porque enciende `llimphi-text/cpu`, backend excluyente del `vello` que usa el resto.
- Backend GPU directo: **MSAA 4× SÍ está cableado** (`gpu.rs:59` `MSAA_SAMPLES=4` en los 4 pipelines + AA por SDF/smoothstep en discos/anillos) — corregido 2026-07-13, el "sin MSAA/AA fino" previo era falso. **Color del vértice ensanchado a `Float32x4`** (2026-07-13): HDR (>1.0) y sub-8-bit sobreviven a un target `Rgba16Float` (certificado por texto, `tests/gpu_hdr_precision`). **`GpuBatch::scissor(x,y,w,h)`** agregado (certificado, `tests/gpu_scissor`) → habilita damage denso. Lo que **aún** falta: sin texto; una sola `line_width` por flush; el `damage_rect` a nivel runtime + present parcial de surface (`[METAL]`); y el primer caller real denso (cosmos starfield, `[METAL]` fps). Plan de maduración (llave que desbloquea HDR #3 y damage #1 densos): `PLAN-MOTOR-PROFUNDO.md`.
- Widgets `llimphi-widget-{transport, waveform}` ✅ ambos extraídos (2026-06-07). `waveform`: visor de envelope min/max stateless y agnóstico (consumidor: `media-app::waveform_panel`, ~150 → ~25 LOC). `transport`: 17 botones (play/pause/prev/next/seek/volume/mute/repeat/shuffle/speed/snapshot/record/eq) con enum `TransportAction` semántico y enum `TransportButton` con estado por variante; el caller traduce `TransportAction``MediaCommand` (consumidor: `media-app::bar_item_view`, 22 → 19 LOC con paridad pixel via `TransportPalette` custom). 11 tests entre ambos crates.
- Investigación abierta: cuelgue/deadlock de apps Llimphi tras click/scroll (hipótesis `get_current_texture` Wayland FIFO) — pendiente reproducir+backtrace.
+1 -1
View File
@@ -26,4 +26,4 @@ llimphi-ui = { path = "../llimphi-ui", version = "0.1.0" }
# Validación headless de los shaders WGSL de post-proceso (sin GPU): naga
# parsea + valida los módulos en un test, atajando errores que de otro modo
# sólo saldrían al crear el pipeline en runtime.
naga = { version = "27", features = ["wgsl-in"] }
naga = { workspace = true, features = ["wgsl-in"] }
+1
View File
@@ -181,6 +181,7 @@ fn render(hal: &Hal, renderer: &mut Renderer, refl: &mut PlanarReflection) -> Ve
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
refl.draw_surface(&mut pass);
}
@@ -186,6 +186,7 @@ fn render(
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
sky.draw(&mut pass); // fondo primero
bb.draw(&mut pass); // billboards encima
+4
View File
@@ -173,6 +173,10 @@ struct Model {
dist: f32,
active: usize,
names: Vec<String>,
/// Color de cielo por dimensión. Se arma al cargar el multiverso pero el
/// demo todavía no lo aplica al render — queda como dato listo para cuando
/// el cambio de dimensión tiña el fondo.
#[allow(dead_code)]
skies: Vec<[u8; 3]>,
mv: Arc<Mutex<Multiverse>>,
}
+6 -6
View File
@@ -126,8 +126,8 @@ impl Billboards {
});
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-bb-pl"),
bind_group_layouts: &[&uniform_layout, &tex_layout],
push_constant_ranges: &[],
bind_group_layouts: &[Some(&uniform_layout), Some(&tex_layout)],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-3d-bb-pipeline"),
@@ -174,8 +174,8 @@ impl Billboards {
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: Default::default(),
bias: Default::default(),
}),
@@ -190,7 +190,7 @@ impl Billboards {
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
multiview_mask: None,
cache: None,
});
@@ -215,7 +215,7 @@ impl Billboards {
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
});
+6 -6
View File
@@ -79,8 +79,8 @@ impl Glows {
});
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-glow-pl"),
bind_group_layouts: &[&uniform_layout, &tex_layout],
push_constant_ranges: &[],
bind_group_layouts: &[Some(&uniform_layout), Some(&tex_layout)],
immediate_size: 0,
});
// Blend aditivo: la luz se suma al fondo (src*1 + dst*1).
let additive = wgpu::BlendState {
@@ -140,8 +140,8 @@ impl Glows {
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::LessEqual,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: Default::default(),
}),
@@ -156,7 +156,7 @@ impl Glows {
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
multiview_mask: None,
cache: None,
});
let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
@@ -180,7 +180,7 @@ impl Glows {
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
});
Self {
+3 -2
View File
@@ -150,7 +150,7 @@ impl Hud {
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-hud-pl"),
bind_group_layouts: &[],
push_constant_ranges: &[],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
@@ -194,7 +194,7 @@ impl Hud {
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
multiview_mask: None,
cache: None,
});
@@ -274,6 +274,7 @@ impl Hud {
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_vertex_buffer(0, self.vbuf.slice(..bytes.len() as u64));
+2
View File
@@ -44,10 +44,12 @@ mod reflection;
mod renderer;
mod scene;
mod sky;
mod ser;
mod voxel;
mod voxel_renderer;
pub use billboard::{Billboard, Billboards};
pub use ser::{SdfKind, SdfPrim, SdfSer, VOXEL_MAX_SERS, VOXEL_MAX_SER_PRIMS};
pub use camera::Camera3d;
pub use cinema::{CamKey, CameraTrack};
pub use glow::Glows;
+5 -5
View File
@@ -69,8 +69,8 @@ impl Lines3d {
});
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-lines-pl"),
bind_group_layouts: &[&uniform_layout],
push_constant_ranges: &[],
bind_group_layouts: &[Some(&uniform_layout)],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-3d-lines-pipeline"),
@@ -103,8 +103,8 @@ impl Lines3d {
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::LessEqual,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: Default::default(),
}),
@@ -119,7 +119,7 @@ impl Lines3d {
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
multiview_mask: None,
cache: None,
});
let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
+9 -6
View File
@@ -158,12 +158,12 @@ impl PostFx {
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Linear,
..Default::default()
});
// Helper para pipelines fullscreen (triángulo cubre-pantalla, sin VBO).
let make_fullscreen = |layouts: &[&wgpu::BindGroupLayout],
let make_fullscreen = |layouts: &[Option<&wgpu::BindGroupLayout>],
wgsl: &str,
label: &str| {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -173,7 +173,7 @@ impl PostFx {
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label),
bind_group_layouts: layouts,
push_constant_ranges: &[],
immediate_size: 0,
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
@@ -200,18 +200,18 @@ impl PostFx {
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
multiview_mask: None,
cache: None,
})
};
let bright_pipeline = make_fullscreen(
&[&tex_layout, &uniform_layout],
&[Some(&tex_layout), Some(&uniform_layout)],
BRIGHT_WGSL,
"llimphi-3d-postfx-bright",
);
let blit_pipeline = make_fullscreen(
&[&tex_layout, &tex_layout, &uniform_layout],
&[Some(&tex_layout), Some(&tex_layout), Some(&uniform_layout)],
BLIT_WGSL,
"llimphi-3d-postfx-blit",
);
@@ -334,6 +334,7 @@ impl PostFx {
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
})
}
@@ -391,6 +392,7 @@ impl PostFx {
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
bp.set_pipeline(&self.bright_pipeline);
bp.set_bind_group(0, scene_bg, &[]);
@@ -414,6 +416,7 @@ impl PostFx {
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
// Confinar al rect: el viewport mapea NDC→rect (la escena se ajusta
// al panel) y el scissor recorta el triángulo fullscreen, que de
+7 -6
View File
@@ -164,7 +164,7 @@ impl PlanarReflection {
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Linear,
..Default::default()
});
@@ -174,8 +174,8 @@ impl PlanarReflection {
});
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-refl-surface-pl"),
bind_group_layouts: &[&uniform_layout, &tex_layout],
push_constant_ranges: &[],
bind_group_layouts: &[Some(&uniform_layout), Some(&tex_layout)],
immediate_size: 0,
});
let surface_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-3d-refl-surface-pipeline"),
@@ -200,8 +200,8 @@ impl PlanarReflection {
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: Default::default(),
bias: Default::default(),
}),
@@ -216,7 +216,7 @@ impl PlanarReflection {
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
multiview_mask: None,
cache: None,
});
@@ -327,6 +327,7 @@ impl PlanarReflection {
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
})
}
+6 -5
View File
@@ -92,8 +92,8 @@ impl Renderer3d {
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-pl"),
bind_group_layouts: &[&bind_layout],
push_constant_ranges: &[],
bind_group_layouts: &[Some(&bind_layout)],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
@@ -133,8 +133,8 @@ impl Renderer3d {
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
@@ -150,7 +150,7 @@ impl Renderer3d {
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
multiview_mask: None,
cache: None,
});
@@ -315,6 +315,7 @@ impl Renderer3d {
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
self.draw(&mut pass);
}
+1
View File
@@ -162,6 +162,7 @@ impl Scene3d {
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
// Viewport (mapeo NDC→rect) + scissor (recorte físico al rect, clampeado a
+514
View File
@@ -0,0 +1,514 @@
//! **Seres SDF** — la segunda técnica de graficación del motor: personajes orgánicos
//! definidos como un ensamble de **primitivas analíticas** (esferas / cápsulas /
//! elipsoides) fundidas por **unión suave** (`smin`), trazadas por *sphere tracing*
//! dentro del MISMO fragment shader del ray-march voxel (la receta de *Dreams* de
//! Media Molecule).
//!
//! ## Por qué existe (lección 2026-07-27)
//!
//! El camino `humanoide_voxel` + `trace_smooth` modela al ser con elipsoides suaves,
//! lo **voxeliza a una grilla binaria** y el shader intenta *reconstruir* la superficie
//! lisa desde ese campo 0/1 — la información de la forma se destruye en la voxelización
//! y la fidelidad queda en ~1 voxel (bultos, anillos). Acá el ensamble se evalúa
//! **analíticamente**: silueta matemáticamente lisa a cualquier distancia, normales
//! exactas (gradiente del campo), y el redondeo es parámetro de diseño (`blend`), no
//! reconstrucción.
//!
//! ## Composición con el mundo
//!
//! `trace_sers` compite por `t` contra el mundo voxel y las [`Entity3d`]
//! (cajas analíticas) en el fs; el hit escribe `frag_depth` → oclusión mutua con
//! voxels Y mallas (depth compartido de `Scene3d`). Los seres proyectan sombra
//! (el shadow ray del sol y de las puntuales también los marcha) y se auto-sombrean.
//! Sombreado propio: **rampa toon** (bandas cuantizadas con AA) + **rim light** —
//! el ser lleva su respuesta de luz "de personaje", el mundo conserva la suya.
//!
//! ## Cara = decals (kind `Decal*`)
//!
//! Las primitivas decal **no aportan geometría**: re-pintan el color de la superficie
//! donde su campo es negativo (ojos como discos 2D expresivos sobre la cabeza 3D,
//! sonrisa como arco fino, rubor) — el truco de *Animal Crossing*/*Wind Waker*. Son
//! datos por frame → parpadeo/expresión = mover/achicar decals desde CPU.
//!
//! Todo va en un uniform propio (binding 5), gateado por `count == 0` → costo ~cero
//! cuando no hay seres (retrocompat byte-idéntico).
/// Tope de seres SDF por frame.
pub const VOXEL_MAX_SERS: usize = 10;
/// Tope de primitivas SDF TOTALES por frame (repartidas entre los seres).
/// El uniform completo debe quedar bajo los 16 KiB del min-spec de WebGPU:
/// `16 + 10×48 + 230×64 = 15 216` bytes.
pub const VOXEL_MAX_SER_PRIMS: usize = 230;
/// Clase de primitiva SDF. Las `Decal*` no tienen volumen: sólo re-pintan color.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SdfKind {
/// Esfera: `a` = centro, `ra` = radio.
Esfera,
/// Cápsula/cono redondeado: segmento `a→b`, radio `ra` en `a` → `rb` en `b`.
Capsula,
/// Elipsoide: `a` = centro, `b` = semiejes (aprox. de iq, suficiente para trace).
Elipsoide,
/// Decal esférico: pinta `color` donde `|pa| < ra` sobre la superficie ya trazada.
DecalEsfera,
/// Decal capsular: pinta `color` a distancia `< ra` del segmento `a→b`.
DecalCapsula,
}
impl SdfKind {
fn code(self) -> f32 {
match self {
SdfKind::Esfera => 0.0,
SdfKind::Capsula => 1.0,
SdfKind::Elipsoide => 2.0,
SdfKind::DecalEsfera => 3.0,
SdfKind::DecalCapsula => 4.0,
}
}
}
/// Una primitiva del ensamble. Coordenadas en espacio de voxel `[0, dim]` (como
/// [`Entity3d`](crate::Entity3d)); color RGB lineal `[0,1]`; `blend` = radio de
/// unión suave (voxels) con lo acumulado ANTES en la lista (orden importa).
#[derive(Debug, Clone, Copy)]
pub struct SdfPrim {
pub kind: SdfKind,
pub a: [f32; 3],
pub ra: f32,
pub b: [f32; 3],
pub rb: f32,
pub color: [f32; 3],
pub blend: f32,
}
/// Un ser = lista ordenada de primitivas (geometría primero, decals después) en
/// espacio LOCAL (pies en el origen, mirando `+Z`), posado en el mundo por
/// `pos` + `yaw`. El shader lleva cada punto a espacio local antes de evaluar el
/// campo — así el elipsoide (semiejes alineados a ejes) rota sin deformarse.
#[derive(Debug, Clone, Default)]
pub struct SdfSer {
pub prims: Vec<SdfPrim>,
/// Posición del origen local en coords de voxel `[0, dim]`.
pub pos: [f32; 3],
/// Rumbo (radianes alrededor de +Y; `0` = mirando `+Z`).
pub yaw: f32,
}
impl SdfSer {
/// Posa el ser: `pos` en coords de voxel `[0, dim]`, `yaw` en radianes.
pub fn posado(mut self, pos: [f32; 3], yaw: f32) -> Self {
self.pos = pos;
self.yaw = yaw;
self
}
/// Esfera envolvente `(centro, radio)` del ensamble en espacio LOCAL (el
/// empaque la lleva a mundo con `pos`/`yaw`). Incluye el margen de blend.
pub fn bound(&self) -> ([f32; 3], f32) {
if self.prims.is_empty() {
return ([0.0; 3], 0.0);
}
let mut lo = [f32::INFINITY; 3];
let mut hi = [f32::NEG_INFINITY; 3];
let mut kmax = 0.0f32;
for p in &self.prims {
let r = p.ra.max(p.rb).max(match p.kind {
SdfKind::Elipsoide => p.b[0].max(p.b[1]).max(p.b[2]),
_ => 0.0,
});
for i in 0..3 {
let (amin, amax) = match p.kind {
// El elipsoide se extiende `b` (semiejes) desde `a`; su `b` no es punto.
SdfKind::Elipsoide => (p.a[i] - p.b[i], p.a[i] + p.b[i]),
_ => (p.a[i].min(p.b[i]) - r, p.a[i].max(p.b[i]) + r),
};
lo[i] = lo[i].min(amin);
hi[i] = hi[i].max(amax);
}
kmax = kmax.max(p.blend);
}
let c = [
(lo[0] + hi[0]) * 0.5,
(lo[1] + hi[1]) * 0.5,
(lo[2] + hi[2]) * 0.5,
];
let dx = (hi[0] - lo[0]) * 0.5;
let dy = (hi[1] - lo[1]) * 0.5;
let dz = (hi[2] - lo[2]) * 0.5;
let r = (dx * dx + dy * dy + dz * dz).sqrt() + kmax + 1.0;
(c, r)
}
}
/// Empaca los seres al layout del uniform `SerU` del shader (binding 5).
/// `count(16) + hdrs(10×48) + prims(230×64)` = 15 216 bytes exactos.
pub(crate) fn pack_sers(sers: &[SdfSer]) -> Vec<u8> {
let mut out = Vec::with_capacity(16 + VOXEL_MAX_SERS * 48 + VOXEL_MAX_SER_PRIMS * 64);
let push4 = |buf: &mut Vec<u8>, v: [f32; 4]| {
for x in v {
buf.extend_from_slice(&x.to_ne_bytes());
}
};
// Header + prims aplanadas, respetando ambos topes. La esfera envolvente se
// lleva a MUNDO acá (centro local rotado por yaw + trasladado por pos).
struct Hdr {
c: [f32; 3],
r: f32,
start: usize,
count: usize,
pos: [f32; 3],
yaw: f32,
}
let mut flat: Vec<&SdfPrim> = Vec::new();
let mut hdrs: Vec<Hdr> = Vec::new();
for s in sers.iter().take(VOXEL_MAX_SERS) {
let start = flat.len();
let room = VOXEL_MAX_SER_PRIMS.saturating_sub(start);
let take = s.prims.len().min(room);
flat.extend(s.prims.iter().take(take));
let (cl, r) = s.bound();
let (sy, cy) = s.yaw.sin_cos();
let c = [
s.pos[0] + cy * cl[0] + sy * cl[2],
s.pos[1] + cl[1],
s.pos[2] - sy * cl[0] + cy * cl[2],
];
hdrs.push(Hdr { c, r, start, count: take, pos: s.pos, yaw: s.yaw });
}
push4(&mut out, [hdrs.len() as f32, 0.0, 0.0, 0.0]);
for i in 0..VOXEL_MAX_SERS {
match hdrs.get(i) {
Some(h) => {
push4(&mut out, [h.c[0], h.c[1], h.c[2], h.r]);
push4(&mut out, [h.start as f32, h.count as f32, h.yaw, 0.0]);
push4(&mut out, [h.pos[0], h.pos[1], h.pos[2], 0.0]);
}
None => {
for _ in 0..3 {
push4(&mut out, [0.0; 4]);
}
}
}
}
for i in 0..VOXEL_MAX_SER_PRIMS {
if let Some(p) = flat.get(i) {
push4(&mut out, [p.a[0], p.a[1], p.a[2], p.ra]);
push4(&mut out, [p.b[0], p.b[1], p.b[2], p.rb]);
push4(&mut out, [p.color[0], p.color[1], p.color[2], p.kind.code()]);
push4(&mut out, [p.blend.max(1e-3), 0.0, 0.0, 0.0]);
} else {
for _ in 0..4 {
push4(&mut out, [0.0; 4]);
}
}
}
out
}
/// Fragmento WGSL de los seres SDF: uniform (binding 5) + campo `smin` + sphere
/// tracing + decals + sombra. Se antepone al WGSL principal del voxel renderer
/// (el orden de declaración no importa en WGSL; el fs lo invoca).
pub(crate) const WGSL_SER: &str = r#"
struct SerPrim {
pa: vec4<f32>, // a.xyz, ra
pb: vec4<f32>, // b.xyz, rb
col: vec4<f32>, // rgb, kind (0 esfera / 1 cápsula / 2 elipsoide / 3 decal-esfera / 4 decal-cápsula)
ext: vec4<f32>, // x = blend (radio smin), yzw libres
};
struct SerHdr {
c: vec4<f32>, // centro.xyz (MUNDO), radio de esfera envolvente
rango: vec4<f32>, // x = prim inicial, y = cantidad, z = yaw (rad)
pos: vec4<f32>, // xyz = origen local del ser en mundo
};
struct SerU {
count: vec4<f32>, // x = seres vivos
hdrs: array<SerHdr, 10>,
prims: array<SerPrim, 230>,
};
@group(0) @binding(5) var<uniform> seru: SerU;
// Lleva un punto de MUNDO al espacio LOCAL del ser `si` (des-traslada y des-rota
// el yaw). Las primitivas viven en local — así el elipsoide (semiejes alineados
// a ejes) rota sin deformarse. Rotación = isometría → las distancias del campo
// siguen válidas en mundo (el gradiente/normal sale bien sin des-rotar nada).
fn ser_local(si: i32, p: vec3<f32>) -> vec3<f32> {
let d = p - seru.hdrs[si].pos.xyz;
let yaw = seru.hdrs[si].rango.z;
let cy = cos(yaw);
let sy = sin(yaw);
return vec3<f32>(cy * d.x - sy * d.z, d.y, sy * d.x + cy * d.z);
}
// Unión suave polinómica (iq): junta orgánica entre primitivas.
fn ser_smin(d1: f32, d2: f32, k: f32) -> f32 {
let h = clamp(0.5 + 0.5 * (d2 - d1) / max(k, 1e-4), 0.0, 1.0);
return mix(d2, d1, h) - k * h * (1.0 - h);
}
// Distancia a UNA primitiva de geometría (decals → 1e9, no aportan volumen).
// La cápsula interpola el radio a lo largo del segmento (cono redondeado aprox;
// no es SDF exacta con radios distintos → el trace marcha con factor de seguridad).
// El elipsoide usa la cota de iq (k0*(k0-1)/k1), suficiente para sphere tracing.
fn ser_prim_d(pr: SerPrim, p: vec3<f32>) -> f32 {
let kind = i32(pr.col.w);
if (kind == 0) {
return length(p - pr.pa.xyz) - pr.pa.w;
}
if (kind == 1) {
let a = pr.pa.xyz;
let ab = pr.pb.xyz - a;
let t = clamp(dot(p - a, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);
return length(p - (a + ab * t)) - mix(pr.pa.w, pr.pb.w, t);
}
if (kind == 2) {
let q = p - pr.pa.xyz;
let r = max(pr.pb.xyz, vec3<f32>(1e-3));
let k0 = length(q / r);
let k1 = length(q / (r * r));
return k0 * (k0 - 1.0) / max(k1, 1e-6);
}
return 1e9;
}
// Campo del ser `si` en `p` (MUNDO) = smin acumulado de sus primitivas (locales).
fn ser_map(si: i32, p: vec3<f32>) -> f32 {
let pl = ser_local(si, p);
let start = i32(seru.hdrs[si].rango.x);
let n = i32(seru.hdrs[si].rango.y);
var d = 1e9;
for (var i = 0; i < n; i = i + 1) {
let pr = seru.prims[start + i];
if (i32(pr.col.w) >= 3) { continue; }
d = ser_smin(ser_prim_d(pr, pl), d, pr.ext.x);
}
return d;
}
// Color del ser en el punto de superficie `p`: mezcla suave de las primitivas de
// geometría (transición ENDURECIDA respecto de la geométrica, para que la ropa no
// sangre de más) y después los DECALS en orden (el último pinta encima), con borde AA.
fn ser_color(si: i32, p: vec3<f32>) -> vec3<f32> {
let pl = ser_local(si, p);
let start = i32(seru.hdrs[si].rango.x);
let n = i32(seru.hdrs[si].rango.y);
var d = 1e9;
var col = vec3<f32>(0.5);
for (var i = 0; i < n; i = i + 1) {
let pr = seru.prims[start + i];
if (i32(pr.col.w) >= 3) { continue; }
let di = ser_prim_d(pr, pl);
let k = pr.ext.x;
let h = clamp(0.5 + 0.5 * (d - di) / max(k, 1e-4), 0.0, 1.0);
// Endurecer FUERTE el borde de color (la geometría funde ancho, el color casi
// duro): con ×2.6 el pelo sangraba una franja gris-marrón por la mejilla en
// toda la juntura smin. ×6 = look vinyl-toy de piezas pintadas.
let hc = clamp((h - 0.5) * 6.0 + 0.5, 0.0, 1.0);
col = mix(col, pr.col.rgb, hc);
d = mix(d, di, h) - k * h * (1.0 - h);
}
for (var i = 0; i < n; i = i + 1) {
let pr = seru.prims[start + i];
let kind = i32(pr.col.w);
if (kind < 3) { continue; }
var dd = 1e9;
if (kind == 3) {
dd = length(pl - pr.pa.xyz) - pr.pa.w;
} else {
let a = pr.pa.xyz;
let ab = pr.pb.xyz - a;
let t = clamp(dot(pl - a, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);
dd = length(pl - (a + ab * t)) - pr.pa.w;
}
// Borde AA del decal, RELATIVO al tamano del ser (un ser de escena mide
// ~2 voxels; un borde fijo de 0.4 vox le comeria la cara entera).
let aa = max(seru.hdrs[si].c.w * 0.01, 1e-3);
let w = clamp(0.5 - dd / aa, 0.0, 1.0);
col = mix(col, pr.col.rgb, w);
}
return col;
}
// Normal del ser = gradiente del campo (técnica del tetraedro, 4 evaluaciones).
fn ser_normal(si: i32, p: vec3<f32>) -> vec3<f32> {
// Epsilon RELATIVO al radio envolvente: calibrado a 0.06 para un ser de 72
// voxels (radio ~40); a escala de escena (~2 voxels) uno fijo aplastaria las
// normales de rasgos mas chicos que el propio epsilon.
let e = max(seru.hdrs[si].c.w * 1.5e-3, 1e-3);
let k0 = vec3<f32>( 1.0, -1.0, -1.0);
let k1 = vec3<f32>(-1.0, -1.0, 1.0);
let k2 = vec3<f32>(-1.0, 1.0, -1.0);
let k3 = vec3<f32>( 1.0, 1.0, 1.0);
let g = k0 * ser_map(si, p + k0 * e)
+ k1 * ser_map(si, p + k1 * e)
+ k2 * ser_map(si, p + k2 * e)
+ k3 * ser_map(si, p + k3 * e);
let l = length(g);
if (l < 1e-6) { return vec3<f32>(0.0, 1.0, 0.0); }
return g / l;
}
struct SerHit {
hit: bool,
t: f32,
si: i32,
p: vec3<f32>,
};
// Sphere tracing de todos los seres, gateado por esfera envolvente. Devuelve el
// hit más cercano por debajo de `max_t` (así compite con voxels y entidades).
// Factor de marcha 0.8 = margen para las SDF aproximadas (cápsula cónica/elipsoide).
fn trace_sers(ro: vec3<f32>, rd: vec3<f32>, max_t: f32) -> SerHit {
var best: SerHit;
best.hit = false;
best.t = max_t;
let ns = i32(seru.count.x);
for (var si = 0; si < ns; si = si + 1) {
let hdr = seru.hdrs[si];
if (hdr.c.w <= 0.0) { continue; }
let oc = ro - hdr.c.xyz;
let bq = dot(oc, rd);
let cq = dot(oc, oc) - hdr.c.w * hdr.c.w;
let disc = bq * bq - cq;
if (disc < 0.0) { continue; }
let sq = sqrt(disc);
let t_in = max(-bq - sq, 1e-3);
let t_out = -bq + sq;
if (t_out <= 0.0 || t_in >= best.t) { continue; }
var t = t_in;
for (var i = 0; i < 80; i = i + 1) {
let p = ro + rd * t;
let d = ser_map(si, p);
// Hit-eps relativo (0.035 a radio 40): la precision escala con el ser.
if (d < max(hdr.c.w * 9e-4, 5e-4)) {
if (t < best.t) {
best.hit = true;
best.t = t;
best.si = si;
best.p = p;
}
break;
}
t = t + d * 0.8;
if (t > min(t_out, best.t)) { break; }
}
}
return best;
}
// Sombra binaria de los seres: ¿algún ser bloquea el rayo antes de `max_t`?
// Marcha más gruesa que la primaria (alcanza para sombra dura).
fn ser_shadow(ro: vec3<f32>, rd: vec3<f32>, max_t: f32) -> bool {
let ns = i32(seru.count.x);
for (var si = 0; si < ns; si = si + 1) {
let hdr = seru.hdrs[si];
if (hdr.c.w <= 0.0) { continue; }
let oc = ro - hdr.c.xyz;
let bq = dot(oc, rd);
let cq = dot(oc, oc) - hdr.c.w * hdr.c.w;
let disc = bq * bq - cq;
if (disc < 0.0) { continue; }
let sq = sqrt(disc);
let t_in = max(-bq - sq, 5e-2);
let t_out = min(-bq + sq, max_t);
if (t_out <= t_in) { continue; }
var t = t_in;
let eps = max(hdr.c.w * 1.25e-3, 7e-4);
for (var i = 0; i < 48; i = i + 1) {
let d = ser_map(si, ro + rd * t);
if (d < eps) { return true; }
t = t + max(d * 0.9, eps * 0.6);
if (t > t_out) { break; }
}
}
return false;
}
"#;
#[cfg(test)]
mod tests {
use super::*;
fn ser_de_prueba() -> SdfSer {
SdfSer {
prims: vec![
SdfPrim {
kind: SdfKind::Esfera,
a: [10.0, 20.0, 10.0],
ra: 5.0,
b: [0.0; 3],
rb: 0.0,
color: [1.0, 0.5, 0.2],
blend: 2.0,
},
SdfPrim {
kind: SdfKind::Capsula,
a: [10.0, 5.0, 10.0],
ra: 2.0,
b: [10.0, 15.0, 10.0],
rb: 1.5,
color: [0.2, 0.4, 0.8],
blend: 1.5,
},
],
pos: [0.0; 3],
yaw: 0.0,
}
}
#[test]
fn bound_envuelve_las_primitivas() {
let s = ser_de_prueba();
let (c, r) = s.bound();
// La esfera (centro y=20, radio 5) llega a y=25; la cápsula baja a y=3.
// El bound debe cubrir ambos extremos desde su centro.
assert!(c[1] > 3.0 && c[1] < 25.0, "centro dentro del cuerpo: {c:?}");
assert!(r > 10.0, "radio cubre el ensamble: {r}");
assert!(c[1] - r < 3.0 && c[1] + r > 25.0, "extremos cubiertos");
}
#[test]
fn pack_layout_exacto() {
let sers = [ser_de_prueba()];
let buf = pack_sers(&sers);
assert_eq!(buf.len(), 16 + VOXEL_MAX_SERS * 48 + VOXEL_MAX_SER_PRIMS * 64);
// count.x = 1
assert_eq!(f32::from_ne_bytes(buf[0..4].try_into().unwrap()), 1.0);
// hdr[0].meta = start 0, count 2
let meta_off = 16 + 16;
assert_eq!(f32::from_ne_bytes(buf[meta_off..meta_off + 4].try_into().unwrap()), 0.0);
assert_eq!(
f32::from_ne_bytes(buf[meta_off + 4..meta_off + 8].try_into().unwrap()),
2.0
);
// prim[1].col.w = kind cápsula = 1.0
let p1 = 16 + VOXEL_MAX_SERS * 48 + 64 + 32;
assert_eq!(f32::from_ne_bytes(buf[p1 + 12..p1 + 16].try_into().unwrap()), 1.0);
}
#[test]
fn pack_respeta_topes() {
let gordo = SdfSer {
prims: vec![
SdfPrim {
kind: SdfKind::Esfera,
a: [0.0; 3],
ra: 1.0,
b: [0.0; 3],
rb: 0.0,
color: [1.0; 3],
blend: 0.5,
};
VOXEL_MAX_SER_PRIMS + 40
],
pos: [0.0; 3],
yaw: 0.0,
};
let buf = pack_sers(&[gordo]);
// hdr[0].count clampeado al tope global.
let meta_off = 16 + 16;
let count = f32::from_ne_bytes(buf[meta_off + 4..meta_off + 8].try_into().unwrap());
assert_eq!(count as usize, VOXEL_MAX_SER_PRIMS);
}
}
+6 -6
View File
@@ -140,8 +140,8 @@ impl SkyBackdrop {
});
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-sky-pl"),
bind_group_layouts: &[&uniform_layout, &tex_layout],
push_constant_ranges: &[],
bind_group_layouts: &[Some(&uniform_layout), Some(&tex_layout)],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-3d-sky-pipeline"),
@@ -158,8 +158,8 @@ impl SkyBackdrop {
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::Always,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
@@ -174,7 +174,7 @@ impl SkyBackdrop {
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
multiview_mask: None,
cache: None,
});
@@ -201,7 +201,7 @@ impl SkyBackdrop {
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Linear,
..Default::default()
});
+90 -11
View File
@@ -127,6 +127,7 @@ pub struct VoxelRenderer {
pipeline: wgpu::RenderPipeline,
ubuf: wgpu::Buffer,
ubuf_ent: wgpu::Buffer,
ubuf_ser: wgpu::Buffer,
dim: [u32; 3],
cdim: [u32; 3],
/// Slots del atlas por eje (cuántos bricks entran en cada dimensión).
@@ -149,6 +150,11 @@ pub struct VoxelRenderer {
depth: Option<crate::scene::DepthBuffer>,
/// Entidades vivas — se empacan y suben en cada `render`.
pub entities: Vec<Entity3d>,
/// **Seres SDF** — personajes orgánicos de primitivas analíticas fundidas por
/// `smin`, sphere-traceados en el mismo pase (ver [`crate::ser`]). Coordenadas
/// de voxel `[0, dim]`. Se empacan y suben en cada `render`; lista vacía =
/// costo ~cero (retrocompat).
pub sers: Vec<crate::ser::SdfSer>,
/// Luces puntuales coloreadas (≤ [`MAX_LIGHTS`]) — antorchas/lámparas que
/// iluminan voxels y entidades cercanos. Se empacan y suben en cada `render`.
pub lights: Vec<PointLight>,
@@ -273,7 +279,7 @@ impl VoxelRenderer {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-3d-voxel-shader"),
source: wgpu::ShaderSource::Wgsl(WGSL.into()),
source: wgpu::ShaderSource::Wgsl(shader_source().into()),
});
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -301,6 +307,7 @@ impl VoxelRenderer {
},
uniform_entry(2),
uniform_entry(3),
uniform_entry(5),
// Atlas de detalle sub-voxel (Rg8Uint, direccionado como el pool).
wgpu::BindGroupLayoutEntry {
binding: 4,
@@ -330,6 +337,14 @@ impl VoxelRenderer {
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let ubuf_ser = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-voxel-ubuf-ser"),
// count(16) + hdrs(10×48) + prims(230×64) — layout de `ser::pack_sers`.
size: (16 + crate::ser::VOXEL_MAX_SERS * 48 + crate::ser::VOXEL_MAX_SER_PRIMS * 64)
as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-3d-voxel-bg"),
@@ -355,13 +370,17 @@ impl VoxelRenderer {
binding: 4,
resource: wgpu::BindingResource::TextureView(&dpool_view),
},
wgpu::BindGroupEntry {
binding: 5,
resource: ubuf_ser.as_entire_binding(),
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-voxel-pl"),
bind_group_layouts: &[&bgl],
push_constant_ranges: &[],
bind_group_layouts: &[Some(&bgl)],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
@@ -382,12 +401,12 @@ impl VoxelRenderer {
// de triángulos se ocluyen correctamente entre sí.
depth_stencil: Some(wgpu::DepthStencilState {
format: crate::scene::DEPTH_FORMAT,
depth_write_enabled: true,
depth_write_enabled: Some(true),
// LessEqual (no Less): el cielo en los misses escribe profundidad
// lejana (1.0) y debe pasar contra el clear 1.0; un `Less` lo
// rechazaría y dejaría ver el fondo negro. Sólo hay un fragmento
// de voxel por píxel (el del rayo), así que no hay z-fighting.
depth_compare: wgpu::CompareFunction::LessEqual,
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
@@ -402,7 +421,7 @@ impl VoxelRenderer {
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
multiview_mask: None,
cache: None,
});
@@ -415,6 +434,7 @@ impl VoxelRenderer {
pipeline,
ubuf,
ubuf_ent,
ubuf_ser,
dim,
cdim,
atlas,
@@ -425,6 +445,7 @@ impl VoxelRenderer {
atmosphere: Atmosphere::default(),
depth: None,
entities: Vec::new(),
sers: Vec::new(),
lights: Vec::new(),
point_shadows: true,
rugosidad_amp: 0.0,
@@ -788,6 +809,7 @@ impl VoxelRenderer {
wgpu::BindGroupEntry { binding: 2, resource: self.ubuf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 3, resource: self.ubuf_ent.as_entire_binding() },
wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&dpool_view) },
wgpu::BindGroupEntry { binding: 5, resource: self.ubuf_ser.as_entire_binding() },
],
});
@@ -928,6 +950,9 @@ impl VoxelRenderer {
}
}
queue.write_buffer(&self.ubuf_ent, 0, &e);
// Seres SDF: header + primitivas aplanadas (layout de `ser::pack_sers`).
queue.write_buffer(&self.ubuf_ser, 0, &crate::ser::pack_sers(&self.sers));
}
/// Dibuja el fullscreen-triangle del ray-march en un pase **ya abierto** (con
@@ -979,6 +1004,7 @@ impl VoxelRenderer {
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
self.draw(&mut pass);
}
@@ -1708,6 +1734,15 @@ fn fs(in: VOut) -> FOut {
let t_vox = select(1e30, h.t, h.hit);
let eh = trace_entities(ro, rd, t_vox);
// Seres SDF: compiten por t contra voxels Y entidades (max_t = lo más cercano
// hasta acá) → si hay hit de ser, ES el más cercano. Gateado por count.
let t_ent = select(t_vox, eh.t, eh.hit);
var shs: SerHit;
shs.hit = false;
if (seru.count.x > 0.5) {
shs = trace_sers(ro, rd, t_ent);
}
let fog_density = u.sky_zenith.w;
var albedo: vec3<f32>;
@@ -1718,7 +1753,16 @@ fn fs(in: VOut) -> FOut {
// Brillo/reflectividad por voxel: viaja en el alpha (matte = 1.0 → 0 brillo;
// valores más bajos = más lustre). Sólo lo llevan los minerales lustrosos.
var shine_a: f32 = 1.0;
if (eh.hit) {
// Sombreado "de personaje" (rampa toon + rim) sólo para los seres SDF.
var is_ser: f32 = 0.0;
if (shs.hit) {
albedo = ser_color(shs.si, shs.p);
normal = ser_normal(shs.si, shs.p);
p = shs.p;
ao = 1.0;
t_hit = shs.t;
is_ser = 1.0;
} else if (eh.hit) {
albedo = eh.color;
normal = eh.normal;
p = ro + rd * eh.t;
@@ -1768,11 +1812,19 @@ fn fs(in: VOut) -> FOut {
// Bias del origen de sombra: con bisel, la superficie suave está ~1 voxel DENTRO
// de la cáscara dura (que es la que traza la sombra) → hay que salir más para no
// nacer enterrado y auto-sombrearse (motas negras). Sin bisel, el 0.5 clásico.
let so_bias = select(0.5, 1.75, bisel > 0.0);
// La superficie SDF del ser es exacta → bias chico (no está enterrada en cáscara).
let so_bias = select(select(0.5, 1.75, bisel > 0.0), 0.25, is_ser > 0.5);
let so = p + normal * so_bias + ldir * 0.01;
let sh_v = trace(so, ldir, dim, B, true);
let sh_e = trace_entities(so, ldir, 1e30);
let shadow = select(1.0, 0.25, sh_v.hit || sh_e.hit);
var sun_blocked = sh_v.hit || sh_e.hit;
// Los seres proyectan sombra sobre el mundo/entidades, pero NO sobre sí mismos
// (regla cartoon: el domo del ojo sombreaba la mejilla = mancha en la cara; la
// cara toon se lee plana). El mundo voxel SÍ los sombrea (sh_v arriba).
if (!sun_blocked && seru.count.x > 0.5 && is_ser < 0.5) {
sun_blocked = ser_shadow(so, ldir, 1e30);
}
let shadow = select(1.0, 0.25, sun_blocked);
// Luz CON COLOR (look cinematográfico) sin uniforms nuevos: el color del sol
// sale de su elevación (cálido al ras del horizonte → blanco en lo alto) y el
@@ -1788,6 +1840,21 @@ fn fs(in: VOut) -> FOut {
// de antes (no oscurece las caras que no ven el sol).
let amb_col = mix(vec3<f32>(0.45), u.sky_zenith.xyz, 0.45) * 0.70;
var light = amb_col + sun_col * (0.78 * diff * shadow);
if (is_ser > 0.5) {
// Rampa TOON del ser: el diffuse (ya con sombra) se cuantiza en dos bandas
// con borde AA (smoothstep angosto) → luz "de personaje", no de material.
// + RIM light frío del cielo en el contorno (despega al ser del fondo).
// HALF-LAMBERT (wrap): el Lambert crudo cruzaba su terminador en diagonal por
// la CARA (leía como veta gris); envolver la luz (d·0.5+0.5)² corre el
// terminador al borde de la silueta → la cara queda iluminada pareja, el
// volumen lo dan las bandas suaves + el rim. La sombra del mundo sí baja la banda.
let wrap = diff * 0.5 + 0.5;
let dsh = clamp(wrap * wrap * shadow, 0.0, 1.0);
let band = 0.46 + 0.24 * smoothstep(0.30, 0.40, dsh) + 0.28 * smoothstep(0.62, 0.72, dsh);
light = amb_col * 0.85 + sun_col * (0.95 * band);
let rimv = pow(1.0 - max(dot(normal, -rd), 0.0), 3.5);
light = light + mix(u.sky_zenith.xyz, vec3<f32>(1.0), 0.35) * (rimv * 0.45);
}
// Luces puntuales coloreadas (antorchas/lámparas): caída suave por distancia
// + sombra dura opcional (un shadow ray hacia la luz, acotado a la distancia
@@ -1840,6 +1907,11 @@ fn fs(in: VOut) -> FOut {
}
vis = 1.0 - occ / f32(SOFT_TAPS);
}
// Oclusión por seres SDF: un rayo al centro de la luz (MVP: sombra dura
// del ser aun bajo luces de área — los 16 taps con sphere-trace serían caros).
if (vis > 0.0 && seru.count.x > 0.5) {
if (ser_shadow(lso, ldir2, d - bias)) { vis = 0.0; }
}
}
light = light + lc.rgb * (att * ndl * vis);
}
@@ -1891,16 +1963,23 @@ fn fs(in: VOut) -> FOut {
}
"#;
/// WGSL completo del pipeline: el fragmento de seres SDF (uniform + campo + trace)
/// antepuesto al ray-march voxel. El orden de declaración no importa en WGSL.
fn shader_source() -> String {
format!("{}\n{}", crate::ser::WGSL_SER, WGSL)
}
#[cfg(test)]
mod tests {
use super::*;
/// El WGSL del ray-march parsea y valida sin GPU (naga). Ataja errores que de
/// otro modo sólo saldrían al crear el pipeline — incluye las funciones de
/// rugosidad sub-voxel (`hash13`/`vnoise`/`perturb_normal`).
/// rugosidad sub-voxel (`hash13`/`vnoise`/`perturb_normal`) y el fragmento de
/// seres SDF (`ser_map`/`trace_sers`/`ser_shadow`).
#[test]
fn wgsl_valida() {
let module = naga::front::wgsl::parse_str(WGSL)
let module = naga::front::wgsl::parse_str(&shader_source())
.unwrap_or_else(|e| panic!("WGSL del voxel-renderer no parsea: {e:?}"));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
+69 -38
View File
@@ -259,6 +259,12 @@ enum Msg {
// --- persistencia ---
Save,
Load,
/// El worker terminó de serializar y escribir. Trae el texto de estado ya
/// armado, porque quién falló y por qué lo sabe el que lo intentó.
SaveDone(String),
/// El worker terminó de leer, parsear y —si el rig traía una— cargar la
/// textura. `Arc` porque `Msg: Clone` y esto no se quiere copiar.
LoadDone(std::sync::Arc<Result<(Project, Option<llimphi_image::Image>), String>>),
}
// =============================================================================
@@ -321,7 +327,7 @@ impl App for Studio {
None
}
fn update(mut model: Model, msg: Msg, _handle: &Handle<Msg>) -> Model {
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::Tick => {
if model.playing {
@@ -668,42 +674,67 @@ impl App for Studio {
}
// ---------------- persistencia ----------------
// Serializar y escribir van a un worker: un rig con muchos huesos y
// un doc con muchos estados se van a RON largo, y encima el disco
// puede tardar lo que quiera. Con esto en `update` la ventana se
// congelaba justo al guardar, que es cuando peor cae.
Msg::Save => {
let project = Project {
doc: model.doc.clone(),
rig: model.rig.clone(),
};
let ron = ron::ser::to_string_pretty(&project, ron::ser::PrettyConfig::default());
model.status = match ron {
Ok(s) => match std::fs::write(PROJECT_PATH, s) {
Ok(_) => format!("guardado en {PROJECT_PATH}"),
Err(e) => format!("error al escribir: {e}"),
},
Err(e) => format!("error al serializar: {e}"),
};
model.status = "guardando…".into();
handle.spawn(move || {
let r = ron::ser::to_string_pretty(&project, ron::ser::PrettyConfig::default())
.map_err(|e| format!("error al serializar: {e}"))
.and_then(|s| {
std::fs::write(PROJECT_PATH, s)
.map_err(|e| format!("error al escribir: {e}"))
});
Msg::SaveDone(match r {
Ok(()) => format!("guardado en {PROJECT_PATH}"),
Err(e) => e,
})
});
}
Msg::Load => match std::fs::read_to_string(PROJECT_PATH) {
Ok(s) => match ron::from_str::<Project>(&s) {
Ok(p) => {
model.doc = p.doc;
model.rig = p.rig;
model.sel = Sel::None;
model.rig_sel = model.rig.bones.len().checked_sub(1);
// Recargar la textura referenciada por path, si la hay.
model.texture = model
.rig
.texture_path
.as_ref()
.and_then(|p| load_texture(p).ok().map(|(img, _)| img));
if let Some(p) = &model.rig.texture_path {
model.tex_input.set_text(p.clone());
}
model.rebuild();
model.status = format!("cargado de {PROJECT_PATH}");
Msg::SaveDone(s) => model.status = s,
// Leer + parsear + **decodificar la textura** al worker. Lo último
// es lo más caro de los tres: un PNG 4K son ~64 MB en RGBA8, y
// ahora además lleva la conversión de perfil de color.
Msg::Load => {
model.status = "cargando…".into();
handle.spawn(|| {
let r = std::fs::read_to_string(PROJECT_PATH)
.map_err(|e| format!("no se pudo leer: {e}"))
.and_then(|s| {
ron::from_str::<Project>(&s).map_err(|e| format!("RON inválido: {e}"))
})
.map(|p| {
let tex = p
.rig
.texture_path
.as_ref()
.and_then(|q| load_texture(q).ok().map(|(img, _)| img));
(p, tex)
});
Msg::LoadDone(std::sync::Arc::new(r))
});
}
Msg::LoadDone(r) => match std::sync::Arc::try_unwrap(r).unwrap_or_else(|a| (*a).clone())
{
Ok((p, tex)) => {
model.doc = p.doc;
model.rig = p.rig;
model.sel = Sel::None;
model.rig_sel = model.rig.bones.len().checked_sub(1);
model.texture = tex;
if let Some(p) = &model.rig.texture_path {
model.tex_input.set_text(p.clone());
}
Err(e) => model.status = format!("RON inválido: {e}"),
},
Err(e) => model.status = format!("no se pudo leer: {e}"),
model.rebuild();
model.status = format!("cargado de {PROJECT_PATH}");
}
Err(e) => model.status = e,
},
}
model
@@ -759,7 +790,7 @@ fn top_bar(model: &Model) -> View<Msg> {
width: length(120.0_f32),
height: length(28.0_f32),
},
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
padding: pad(12.0, 0.0),
..Default::default()
})
@@ -775,7 +806,7 @@ fn top_bar(model: &Model) -> View<Msg> {
height: length(44.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
padding: pad(12.0, 0.0),
gap: gap(8.0),
..Default::default()
@@ -863,7 +894,7 @@ fn input_row(i: usize, inp: &InputDef, theme: &Theme) -> View<Msg> {
width: Dimension::auto(),
height: length(24.0_f32),
},
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
..Default::default()
})
.text(format!("{} · {}", inp.name, inp.kind.label()), 12.0, theme.fg_text),
@@ -1660,7 +1691,7 @@ fn auto_h(h: f32) -> Style {
width: percent(1.0_f32),
height: if h > 0.0 { length(h) } else { Dimension::auto() },
},
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
..Default::default()
}
}
@@ -1705,7 +1736,7 @@ fn grow_text(text: String, theme: &Theme) -> View<Msg> {
width: Dimension::auto(),
height: length(26.0_f32),
},
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
..Default::default()
})
.text(text, 12.0, theme.fg_text)
@@ -1724,7 +1755,7 @@ fn toggle_btn(label: &str, active: bool, msg: Msg, theme: &Theme) -> View<Msg> {
width: Dimension::auto(),
height: length(28.0_f32),
},
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
padding: pad(10.0, 0.0),
..Default::default()
})
@@ -1758,7 +1789,7 @@ fn selectable_row(label: &str, selected: bool, msg: Msg, theme: &Theme) -> View<
width: percent(1.0_f32),
height: length(26.0_f32),
},
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
padding: pad(8.0, 0.0),
..Default::default()
})
@@ -1776,7 +1807,7 @@ fn row(children: Vec<View<Msg>>) -> View<Msg> {
height: Dimension::auto(),
},
gap: gap(6.0),
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
..Default::default()
})
.children(children)
+75 -3
View File
@@ -10,11 +10,83 @@ description = "llimphi-compositor — el núcleo declarativo de Llimphi sin wini
repository.workspace = true
[dependencies]
llimphi-layout = { path = "../llimphi-layout", version = "0.1.0" }
llimphi-text = { path = "../llimphi-text", version = "0.1.0" }
vello = { workspace = true }
# El mapa sin sistema operativo — el mismo que `std::collections::HashMap` usa
# por dentro, así que en Linux no cambia nada.
hashbrown = { version = "0.17", default-features = false, features = ["default-hasher", "inline-more"] }
# El hasher de las memoizaciones. `std::collections::hash_map::DefaultHasher`
# no existe sin std, y el default de hashbrown va sembrado al azar por proceso.
# `FixedState` es DETERMINISTA, que es lo que estos hashes necesitan: comparan
# el mismo subárbol entre fotogramas.
foldhash = { version = "0.2", default-features = false }
# `ceil`/`sqrt` sobre f32/f64 no existen sin `std`: son métodos que aporta la
# stdlib, no el lenguaje. Se piden a libm en el perfil bare-metal.
libm = { version = "0.2", optional = true }
# Sin `default-features`: su default enciende el backend vello, y acá el backend
# lo elige la feature de ESTE crate. Se reenvía abajo.
llimphi-text = { path = "../llimphi-text", version = "0.1.0", default-features = false }
# Reloj monotónico portable de las animaciones (ver `crate::Instant`). En
# targets nativos es un re-export literal de `std::time::Instant`: mismo tipo,
# coste cero. En wasm32 es `performance.now()`, donde el de std panica.
web-time = { version = "1", optional = true }
# vello declarado DIRECTO (no `{ workspace = true }`) y sin `default-features`.
#
# Es la mitad que faltaba para sacar wgpu de verdad: hacer opcional la dep
# DIRECTA no alcanzaba, porque wgpu volvía a entrar por la puerta de atrás —
# `vello` lo trae en su `default` (features `wgpu` + `wgpu_default`). Y como
# apagar `default-features` desde un miembro no hace nada si el workspace no lo
# declaró, había que declararlo acá.
#
# Lo que el compositor usa de vello es `Scene`, `kurbo` y `peniko`, y NINGUNO
# está detrás de la feature `wgpu` (`vello/src/lib.rs:146`). O sea: el árbol de
# escena de vello nunca necesitó una GPU, sólo venía en el mismo paquete.
#
# OPCIONAL desde W3.b: el compositor ya no nombra a `vello::Scene` sino al
# alias `Lienzo`, que en wawa es `llimphi_cpu::Escena`. vello entra sólo con la
# feature del backend homónimo.
# Versión desde el workspace, NO pinneada acá. Tenerla suelta fue lo que
# metió DOS vello en el grafo al migrar a 0.9 (2026-07-28) y produjo el
# error más desorientador de la tanda: «expected Scene, found
# llimphi_raster::vello::Scene» — el mismo tipo, de dos crates distintos.
vello = { workspace = true, default-features = false, optional = true }
# Sólo para los tipos de la firma de GpuPaintFn (Device/Queue/Encoder/View).
# wgpu NO depende de winit — el compositor sigue libre de windowing.
wgpu = { workspace = true }
#
# OPCIONAL desde W1 (PLAN-LLIMPHI.md): en wawa no hay wgpu que valga, y era la
# única dependencia del compositor atada a una GPU concreta. Con `default` la
# feature está encendida, así que para todo consumidor de hoy no cambia nada.
wgpu = { workspace = true, optional = true }
[features]
# El default reproduce lo de siempre: backend vello, fuentes del sistema y el
# hook de GPU directa. Ningún consumidor de hoy nota W3.b.
default = ["std", "vello", "system", "gpu"]
# --- El backend de pintura. Excluyentes; `llimphi-text` lo vigila con un
# `compile_error!` si se encienden los dos. ---
# Linux: se pinta en una `vello::Scene`.
# `std` da el reloj (`web-time`) y la stdlib. Sin él, el compositor usa su
# propio `Instant` de microsegundos (ver `crate::reloj`).
std = ["dep:web-time", "llimphi-text/std"]
vello = ["dep:vello", "llimphi-text/vello", "std"]
# wawa: se pinta en una `llimphi_cpu::Escena`, sin GPU y sin std.
cpu = ["llimphi-text/cpu"]
# App enjaulada (W4): no se pinta — se GRABA en una `EscenaWire` que cruza la
# frontera WASM, y el texto viaja sin shapear. `render.rs` no distingue: la
# costura está en `llimphi-text::wire`, que le da otro `Typesetter` con las
# mismas firmas.
# cargo check -p llimphi-compositor --no-default-features --features wire,std
wire = ["llimphi-text/wire"]
# Fuentes del sistema (fontique). Sólo tiene sentido con std.
system = ["llimphi-text/system"]
# Matemática por libm — el perfil bare-metal.
libm = ["llimphi-text/libm", "dep:libm"]
# El hook de pintura GPU directa (`View::gpu_paint_with`). Encendido por
# defecto: apagarlo es la excepción (el camino CPU de wawa), no la norma.
# Implica el backend vello: sin `vello::Scene` no hay pasada sobre la que
# encimar el render de wgpu.
gpu = ["vello", "dep:wgpu", "vello/wgpu_default"]
[dev-dependencies]
# Volcado headless del paint a PNG (llvmpipe) para VER sombra/gradiente/borde
+12 -4
View File
@@ -45,8 +45,8 @@ fn rgb(r: u8, g: u8, b: u8) -> Color {
fn card_shell(col: usize, row_y: f64, label: &str, fg: Color) -> View<()> {
View::<()>::new(Style {
size: Size { width: length(170.0), height: length(140.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
flex_direction: FlexDirection::Column,
..Default::default()
})
@@ -152,7 +152,15 @@ fn main() {
let n = mv.nodes.len();
let mut sub = vello::Scene::new();
paint_range(&mut sub, &mv, &computed, &mut ts_exit, None, None, 0, n, Affine::IDENTITY);
reg.store_live_exit(20 + i as u64, sub, DUR, llimphi_compositor::ease_out_cubic);
// El snapshot vivo se guarda con el `Anim` del propio nodo (el que
// trae `.animated_exit`) y el centro de su rect — es lo mismo que
// hace el runtime en `eventloop::redraw`.
let anim = mv.nodes[0].anim.expect("el root lleva animated_exit");
let centro = computed
.get(mv.nodes[0].id)
.map(|r| (r.x + r.w * 0.5, r.y + r.h * 0.5))
.unwrap_or((0.0, 0.0));
reg.store_live_exit(20 + i as u64, sub, centro, &anim);
}
// (2) Ausente: la key se va → fantasma con start=t0.
{
@@ -244,7 +252,7 @@ fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
@@ -52,8 +52,8 @@ fn build_view(target_size: (f32, f32), accent: Color, fg: Color, panel: Color) -
width: length(target_size.0),
height: length(target_size.1),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.fill(accent)
@@ -66,8 +66,8 @@ fn build_view(target_size: (f32, f32), accent: Color, fg: Color, panel: Color) -
width: length(60.0_f32),
height: length(40.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.fill(panel)
@@ -81,8 +81,8 @@ fn build_view(target_size: (f32, f32), accent: Color, fg: Color, panel: Color) -
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::FlexStart),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::FLEX_START),
gap: Size {
width: length(12.0_f32),
height: length(0.0_f32),
@@ -260,7 +260,7 @@ fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
@@ -229,7 +229,7 @@ fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
@@ -90,8 +90,8 @@ fn ficha(img: &Image, fit: ImageFit, label: &str, panel: Color, fg: Color) -> Vi
View::<()>::new(Style {
size: Size { width: length(220.0_f32), height: length(260.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::FlexStart),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::FLEX_START),
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
padding: Rect {
left: length(10.0_f32),
@@ -131,8 +131,8 @@ fn avatar(img: &Image, panel: Color, fg: Color) -> View<()> {
View::<()>::new(Style {
size: Size { width: length(220.0_f32), height: length(260.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::FlexStart),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::FLEX_START),
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
padding: Rect {
left: length(10.0_f32),
@@ -166,8 +166,8 @@ fn main() {
let root = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
gap: Size { width: length(20.0_f32), height: length(0.0_f32) },
padding: Rect {
left: length(24.0_f32),
@@ -269,7 +269,7 @@ fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
@@ -40,8 +40,8 @@ fn rgb(r: u8, g: u8, b: u8) -> Color {
fn card(label: &str) -> View<()> {
View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(64.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.fill(rgb(60, 72, 100))
@@ -224,7 +224,7 @@ fn write_png(hal: &Hal, target: &wgpu::Texture, w: u32, path: &str) {
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((w * H * 4) as usize);
@@ -42,8 +42,8 @@ fn chip(label: &str, pal: &ButtonPalette) -> View<()> {
Style {
size: Size { width: percent(1.0_f32), height: length(34.0_f32) },
padding: Rect { left: length(14.0_f32), right: length(14.0_f32), top: length(0.0_f32), bottom: length(0.0_f32) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
},
Alignment::Center,
@@ -81,7 +81,7 @@ fn fila_theme(nombre: &str) -> View<()> {
let etiqueta = View::<()>::new(Style {
size: Size { width: length(120.0_f32), height: length(34.0_f32) },
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
..Default::default()
})
.text_aligned(nombre.to_string(), 13.0, theme.fg_muted, Alignment::Start);
@@ -89,7 +89,7 @@ fn fila_theme(nombre: &str) -> View<()> {
View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: length(72.0_f32) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
gap: Size { width: length(16.0_f32), height: length(0.0_f32) },
padding: Rect { left: length(24.0_f32), right: length(24.0_f32), top: length(0.0_f32), bottom: length(0.0_f32) },
..Default::default()
+20 -20
View File
@@ -77,7 +77,7 @@ fn main() {
let kw = rgb(198, 120, 221); // keywords — violeta
let ty = rgb(229, 192, 123); // tipos — ámbar
let fnc = rgb(97, 175, 239); // funciones — azul
let strv = rgb(152, 195, 121); // strings — verde
let _strv = rgb(152, 195, 121); // strings — verde
let cmt = rgb(92, 104, 124); // comentarios — gris azulado
let lit = rgb(209, 154, 102); // literales numéricos — naranja
let code_fg = rgb(171, 178, 191);
@@ -86,7 +86,7 @@ fn main() {
let tab = |name: &str, activo: bool| {
let base = View::<()>::new(Style {
size: Size { width: auto(), height: length(30.0) },
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
@@ -103,7 +103,7 @@ fn main() {
);
let buscador = View::<()>::new(Style {
size: Size { width: length(230.0), height: length(28.0) },
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
padding: Rect { left: length(12.0), right: length(12.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
@@ -114,7 +114,7 @@ fn main() {
let topbar = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(46.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
gap: Size { width: length(10.0), height: length(0.0) },
padding: Rect { left: length(16.0), right: length(16.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
@@ -139,8 +139,8 @@ fn main() {
let base = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(30.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::SPACE_BETWEEN),
padding: Rect { left: length(10.0), right: length(10.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
@@ -148,7 +148,7 @@ fn main() {
let v = if sel { base.fill(theme.bg_selected) } else { base };
let izq = View::<()>::new(Style {
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
gap: Size { width: length(8.0), height: length(0.0) },
..Default::default()
})
@@ -161,7 +161,7 @@ fn main() {
hijos.push(
View::<()>::new(Style {
size: Size { width: auto(), height: length(17.0) },
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
padding: Rect { left: length(7.0), right: length(7.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
@@ -264,7 +264,7 @@ fn main() {
let editor_header = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(36.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
gap: Size { width: length(8.0), height: length(0.0) },
padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
@@ -373,7 +373,7 @@ medido y pintado por el mismo layout_spans, sin HTML ni DOM.";
let hero = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(120.0) },
flex_direction: FlexDirection::Column,
justify_content: Some(JustifyContent::Center),
justify_content: Some(JustifyContent::CENTER),
gap: Size { width: length(0.0), height: length(10.0) },
padding: Rect { left: length(18.0), right: length(18.0), top: length(12.0), bottom: length(12.0) },
..Default::default()
@@ -422,7 +422,7 @@ medido y pintado por el mismo layout_spans, sin HTML ni DOM.";
View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(18.0) },
flex_direction: FlexDirection::Row,
justify_content: Some(JustifyContent::SpaceBetween),
justify_content: Some(JustifyContent::SPACE_BETWEEN),
..Default::default()
})
.children(vec![
@@ -436,8 +436,8 @@ medido y pintado por el mismo layout_spans, sin HTML ni DOM.";
View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(94.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::FlexEnd),
justify_content: Some(JustifyContent::SpaceBetween),
align_items: Some(AlignItems::FLEX_END),
justify_content: Some(JustifyContent::SPACE_BETWEEN),
..Default::default()
})
.children(barras),
@@ -447,8 +447,8 @@ medido y pintado por el mismo layout_spans, sin HTML ni DOM.";
let boton = |label: &str, primario: bool| {
let base = View::<()>::new(Style {
size: Size { width: auto(), height: length(34.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
padding: Rect { left: length(18.0), right: length(18.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
@@ -479,14 +479,14 @@ medido y pintado por el mismo layout_spans, sin HTML ni DOM.";
View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(26.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::SPACE_BETWEEN),
..Default::default()
})
.children(vec![
View::<()>::new(Style {
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
gap: Size { width: length(8.0), height: length(0.0) },
..Default::default()
})
@@ -534,7 +534,7 @@ medido y pintado por el mismo layout_spans, sin HTML ni DOM.";
let statusbar = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(30.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
gap: Size { width: length(18.0), height: length(0.0) },
padding: Rect { left: length(16.0), right: length(16.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
@@ -557,7 +557,7 @@ medido y pintado por el mismo layout_spans, sin HTML ni DOM.";
inset: Rect { left: auto(), top: auto(), right: length(360.0), bottom: length(48.0) },
size: Size { width: length(290.0), height: length(64.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
gap: Size { width: length(12.0), height: length(0.0) },
padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
@@ -29,8 +29,8 @@ fn card(build: impl FnOnce(View<()>) -> View<()>, title: &str, fg: Color) -> Vie
let base = View::<()>::new(Style {
size: Size { width: length(180.0_f32), height: length(150.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
gap: Size { width: length(0.0_f32), height: length(8.0_f32) },
..Default::default()
})
@@ -97,8 +97,8 @@ fn main() {
let peso = View::<()>::new(Style {
size: Size { width: length(180.0_f32), height: length(150.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
gap: Size { width: length(0.0_f32), height: length(4.0_f32) },
..Default::default()
})
@@ -142,8 +142,8 @@ fn main() {
let elipsis = View::<()>::new(Style {
size: Size { width: length(180.0_f32), height: length(150.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
@@ -163,8 +163,8 @@ fn main() {
let root = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
gap: Size { width: length(28.0_f32), height: length(0.0_f32) },
padding: Rect {
left: length(24.0_f32),
@@ -254,7 +254,7 @@ fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
@@ -135,8 +135,8 @@ fn main() {
let card = View::<()>::new(Style {
size: Size { width: length(W as f32 - 80.0), height: length(H as f32 - 60.0) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::FlexStart),
justify_content: Some(JustifyContent::FlexStart),
align_items: Some(AlignItems::FLEX_START),
justify_content: Some(JustifyContent::FLEX_START),
gap: Size { width: length(0.0_f32), height: length(12.0_f32) },
padding: Rect {
left: length(24.0_f32),
@@ -153,8 +153,8 @@ fn main() {
let root = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.fill(theme.bg_app)
@@ -243,7 +243,7 @@ fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
+5 -5
View File
@@ -51,8 +51,8 @@ fn main() {
let pct = (i as f32 / (FRAMES as f32 - 1.0) * 100.0).round() as i32;
View::<()>::new(Style {
size: Size { width: length(150.0), height: length(140.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.fill(surface)
@@ -68,8 +68,8 @@ fn main() {
let root = View::<()>::new(Style {
size: Size { width: length(W as f32), height: length(H as f32) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
gap: Size { width: length(20.0), height: length(0.0) },
padding: Rect {
left: LengthPercentage::length(20.0),
@@ -163,7 +163,7 @@ fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
+4 -4
View File
@@ -156,7 +156,7 @@ fn card_header(title: &str, s: &Skin, accented: bool) -> View<()> {
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
gap: Size { width: length(10.0), height: length(0.0) },
flex_shrink: 0.0,
..Default::default()
@@ -193,7 +193,7 @@ fn card_body(i: usize, p: f32, s: &Skin) -> Vec<View<()>> {
let sw_row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(26.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
gap: Size { width: length(14.0), height: length(0.0) },
..Default::default()
})
@@ -337,8 +337,8 @@ fn card_body(i: usize, p: f32, s: &Skin) -> Vec<View<()>> {
let ring_row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(96.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.children(vec![ring]);
+13 -8
View File
@@ -44,12 +44,17 @@
//! rect — una transición entre dos identidades distintas reusando la misma
//! infra de ghosts del `exit`, sin tener que combinar enter+exit de dos keys.
use std::collections::HashMap;
use std::time::{Duration, Instant};
// Preludio de `alloc`: bajo `no_std` estos tipos ya no vienen solos.
#[allow(unused_imports)]
use alloc::{boxed::Box, string::{String, ToString}, vec::Vec};
use vello::kurbo::{Affine, Rect};
use vello::peniko::{Color, Fill, Mix};
use vello::Scene;
use hashbrown::HashMap;
use core::time::Duration;
use crate::Instant;
use llimphi_text::peniko::kurbo::{Affine, Rect};
use llimphi_text::peniko::{Color, Fill, Mix};
use crate::Lienzo as Scene;
use crate::Mounted;
@@ -303,7 +308,7 @@ fn reconcile_size_anim_inner<Msg>(
seen: &mut Vec<u64>,
) -> bool {
let mut animating = false;
if let Some(sa) = view.animated_size {
if let Some(sa) = view.rare.as_ref().and_then(|r| r.animated_size) {
if let Some(target) = try_extract_length_size(&view.style) {
seen.push(sa.key);
let entry = reg
@@ -1296,11 +1301,11 @@ mod tests {
// Al final del tween (t=1) la afín es scale(2) conjugada por el
// centro: el centro queda FIJO (100,100 → 100,100).
let xf = g.xf(t0 + Duration::from_millis(100)).expect("afín");
let p = xf * vello::kurbo::Point::new(100.0, 100.0);
let p = xf * llimphi_text::peniko::kurbo::Point::new(100.0, 100.0);
assert!((p.x - 100.0).abs() < 1e-6 && (p.y - 100.0).abs() < 1e-6,
"el centro no se mueve: {p:?}");
// Un punto a 50px del centro termina a 100px (escala 2).
let q = xf * vello::kurbo::Point::new(150.0, 100.0);
let q = xf * llimphi_text::peniko::kurbo::Point::new(150.0, 100.0);
assert!((q.x - 200.0).abs() < 1e-6, "escala desde el centro: {q:?}");
}
+8 -3
View File
@@ -33,11 +33,16 @@
//! 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};
// Preludio de `alloc`: bajo `no_std` estos tipos ya no vienen solos.
#[allow(unused_imports)]
use alloc::{boxed::Box, string::{String, ToString}, vec::Vec};
use hashbrown::HashMap;
use core::time::Duration;
use crate::Instant;
use llimphi_layout::{ComputedLayout, Rect};
use vello::kurbo::Affine;
use llimphi_text::peniko::kurbo::Affine;
/// Declara un nodo como **hero**: la `key` enlaza la identidad entre frames; si
/// el rect cambia, el runtime anima la transición.
+148
View File
@@ -0,0 +1,148 @@
// =============================================================================
// input :: el modelo de entrada del compositor (W4.7 de PLAN-LLIMPHI)
// -----------------------------------------------------------------------------
// Hasta acá los tipos de entrada —`KeyEvent`, `ImeEvent`, `Modifiers`— vivían
// en `llimphi-ui`, y `Key`/`NamedKey` eran directamente los de winit. La
// consecuencia medida: los cinco widgets del editor (`text-editor`,
// `text-input`, `text-area`, `edit-menu`, `gallery`) no podían cruzar a una
// jaula WASM ni al kernel, porque procesar UNA TECLA arrastraba el windowing
// de escritorio entero.
//
// El error de diseño era de dirección: el input no es del shell, es del
// MODELO. Un `KeyEvent` es un dato — quién lo produjo (winit, el canal de
// scancodes del kernel, un test) es asunto del sustrato, igual que quién
// presenta el framebuffer. Este módulo lo pone donde el resto del modelo ya
// vive, y cada shell CONVIERTE en su frontera:
//
// winit ──convierte──► input::KeyEvent ◄──convierte── sys_get_scancode
// (llimphi-ui) (la jaula, W7)
//
// ## `NamedKey`: el subconjunto REAL, no el catálogo de winit
//
// winit modela cientos de teclas (media keys, teclado japonés, botones de
// navegador). Se contó qué usa el repo entero: **31 variantes** — navegación,
// edición, F1-F12 y modificadores — y puriy, que traduce teclas a nombres DOM
// para el navegador, usa exactamente las mismas. Eso es la interfaz real, y es
// lo que este enum nombra, con los MISMOS nombres que winit para que ni un
// widget ni una app cambien un `match`. Lo no mapeado cae a `Unidentified`,
// que todo `match` existente ya absorbe en su brazo `_`.
// =============================================================================
use alloc::string::String;
/// Una tecla con nombre. Espeja el subconjunto de `winit::keyboard::NamedKey`
/// que el repo usa — mismos nombres, así el código existente no cambia.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NamedKey {
Enter,
Escape,
Backspace,
Tab,
Space,
Delete,
Insert,
Home,
End,
PageUp,
PageDown,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
Shift,
Control,
Alt,
Meta,
CapsLock,
F1,
F2,
F3,
F4,
F5,
F6,
F7,
F8,
F9,
F10,
F11,
F12,
/// Cualquier tecla con nombre que el modelo no distingue. Existe para que
/// la conversión desde un sustrato rico (winit) sea total sin arrastrar su
/// catálogo: una media key llega como esto y el brazo `_` de todo `match`
/// existente la absorbe, que es lo que ya pasaba.
Unidentified,
}
/// Una tecla: con nombre, o el carácter que produce.
///
/// Espeja la forma de `winit::keyboard::Key` en sus dos variantes reales — el
/// repo no usa `Dead` ni `Unidentified(NativeKey)` (verificado), así que no
/// existen acá. `Character` lleva `String` y no el `SmolStr` de winit: mismo
/// `.as_str()`, mismas comparaciones con `==`, sin la dependencia.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Key {
/// Tecla con nombre (Enter, flechas, F5…).
Named(NamedKey),
/// Tecla que produce texto: el carácter ya con layout aplicado.
Character(String),
}
/// Estado de una tecla en un evento.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyState {
Pressed,
Released,
}
/// Modificadores activos en el momento del evento.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Modifiers {
pub shift: bool,
pub ctrl: bool,
pub alt: bool,
pub meta: bool,
}
/// Evento de teclado normalizado.
///
/// La MISMA forma que tenía en `llimphi-ui`, movida acá sin un campo de
/// diferencia: era un dato puro desde el principio — sólo estaba mal vecindado.
#[derive(Debug, Clone)]
pub struct KeyEvent {
pub key: Key,
pub state: KeyState,
/// Texto resultante (con modifiers e IME aplicados). Útil para inserción
/// directa; `None` para teclas que no producen texto (flechas, etc.).
pub text: Option<String>,
pub modifiers: Modifiers,
pub repeat: bool,
}
/// Evento de IME normalizado. Ver `App::on_ime` en el shell para el flujo
/// Enabled → Preedit* → Commit/Disabled.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImeEvent {
/// El IME se activó para esta ventana.
Enabled,
/// Texto en composición (aún no confirmado). `cursor` es el rango
/// `(inicio, fin)` en bytes a resaltar dentro de `text`, si el IME lo
/// reporta. Cada `Preedit` reemplaza al anterior; uno con `text`
/// vacío cierra la preedición sin confirmar.
Preedit {
text: String,
cursor: Option<(usize, usize)>,
},
/// Texto confirmado: insertarlo como si se hubiera tecleado.
Commit(String),
/// El IME se desactivó (perder foco, cambiar de método).
Disabled,
}
/// Delta de rueda en "líneas" lógicas (normalizado a través de backends).
/// Convención CSS: positivo = scroll **hacia abajo** (contenido sube).
/// `x` similar para scroll horizontal (touchpads, ratones de 2 ejes).
#[derive(Debug, Clone, Copy, Default)]
pub struct WheelDelta {
pub x: f32,
pub y: f32,
}
+11 -6
View File
@@ -31,13 +31,18 @@
//! otro `layout_builder` no resuelve el interno (no existía en la pasada 1):
//! queda como hoja. El anidamiento requeriría iterar la resolución; se difiere.
// Preludio de `alloc`: bajo `no_std` estos tipos ya no vienen solos.
#[allow(unused_imports)]
use alloc::{boxed::Box, string::{String, ToString}, vec::Vec};
use crate::{Constraints, ComputedLayout, Mounted, View};
/// `true` si `view` o algún descendiente declara un [`crate::View::layout_builder`].
/// El runtime lo usa para decidir si vale la pena la resolución en dos pasadas;
/// cuando es `false` (lo normal) el camino diferido se evita por completo.
pub fn has_layout_builder<Msg>(view: &View<Msg>) -> bool {
view.layout_builder.is_some() || view.children.iter().any(has_layout_builder)
view.rare.as_ref().is_some_and(|r| r.layout_builder.is_some())
|| view.children.iter().any(has_layout_builder)
}
/// Lee las [`Constraints`] (tamaño del slot) de cada nodo `is_layout_builder`
@@ -75,7 +80,7 @@ pub fn expand_layout_builders<Msg>(view: View<Msg>, cons: &[Constraints]) -> Vie
}
fn expand_rec<Msg>(mut view: View<Msg>, cons: &[Constraints], idx: &mut usize) -> View<Msg> {
if let Some(builder) = view.layout_builder.take() {
if let Some(builder) = view.rare.as_mut().and_then(|r| r.layout_builder.take()) {
let c = cons
.get(*idx)
.copied()
@@ -88,7 +93,7 @@ fn expand_rec<Msg>(mut view: View<Msg>, cons: &[Constraints], idx: &mut usize) -
view.children = vec![child];
view
} else {
let children = std::mem::take(&mut view.children);
let children = core::mem::take(&mut view.children);
view.children = children
.into_iter()
.map(|c| expand_rec(c, cons, idx))
@@ -181,7 +186,7 @@ mod tests {
// El nodo builder (hijo 0 del root) ya no es builder y tiene 1 hijo
// producido (slot 200 < 300 → angosto → 1 columna).
let col_izq = &expanded.children[0];
assert!(col_izq.layout_builder.is_none(), "ya expandido");
assert!(raro!(col_izq, layout_builder).is_none(), "ya expandido");
assert_eq!(col_izq.children.len(), 1, "200px angosto → 1 hijo");
}
@@ -225,8 +230,8 @@ mod tests {
// Ambos expandidos, en orden (verificamos vía el ancho del hijo producido
// que NO depende de la constraint aquí — sólo confirmamos que se invocaron
// los dos y que ninguno quedó como builder).
assert!(expanded.children[0].layout_builder.is_none());
assert!(expanded.children[1].layout_builder.is_none());
assert!(raro!(expanded.children[0], layout_builder).is_none());
assert!(raro!(expanded.children[1], layout_builder).is_none());
assert_eq!(expanded.children[0].children.len(), 1);
assert_eq!(expanded.children[1].children.len(), 1);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -5
View File
@@ -26,11 +26,16 @@
//! en `duration`); no hay "mantener mientras se sostiene el press" (Material
//! `hold`), que requeriría rastrear el release por key.
use std::time::{Duration, Instant};
// Preludio de `alloc`: bajo `no_std` estos tipos ya no vienen solos.
#[allow(unused_imports)]
use alloc::{boxed::Box, string::{String, ToString}, vec::Vec};
use vello::kurbo::{Affine, Circle};
use vello::peniko::{BlendMode, Color, Fill};
use vello::Scene;
use core::time::Duration;
use crate::Instant;
use llimphi_text::peniko::kurbo::{Affine, Circle};
use llimphi_text::peniko::{BlendMode, Color, Fill};
use crate::Lienzo as Scene;
use crate::{ComputedLayout, Mounted};
@@ -163,7 +168,10 @@ impl RippleRegistry {
];
let max_radius = corners
.iter()
.map(|(px, py)| ((px - cx).powi(2) + (py - cy).powi(2)).sqrt())
.map(|(px, py)| {
let (dx, dy) = (px - cx, py - cy);
crate::sqrt_f64(dx * dx + dy * dy)
})
.fold(0.0_f64, f64::max);
let t = s.raw(now);
let radius = (s.easing)(t) as f64 * max_radius;
+65 -1
View File
@@ -31,7 +31,11 @@
//! declarar semántica — los lectores ya filtran texto vacío, pero un rol
//! superfluo (`Role::Group` en cada `View` envoltorio) ensucia la navegación.
use std::sync::Arc;
// Preludio de `alloc`: bajo `no_std` estos tipos ya no vienen solos.
#[allow(unused_imports)]
use alloc::{boxed::Box, string::{String, ToString}, vec::Vec};
use alloc::sync::Arc;
/// Rol semántico del nodo. Los nombres y la granularidad siguen los roles de
/// AccessKit / ARIA. Subset acotado: agregamos lo que falte cuando aparezca un
@@ -67,6 +71,66 @@ pub enum Role {
/// Agrupador genérico (toolbar, panel, sección). Sirve para que los
/// lectores ofrezcan "saltar al siguiente grupo".
Group,
// --- Contenedores de navegación (iter 3) ---
/// Barra de pestañas: el contenedor de los [`Role::Tab`].
TabList,
/// Barra de herramientas. Agrupa botones de acción.
Toolbar,
/// Barra de menú (contenedor de [`Role::Menu`] / [`Role::MenuItem`]).
MenuBar,
/// Menú desplegable o contextual (contenedor de [`Role::MenuItem`]).
Menu,
// --- Colecciones ---
/// Lista de ítems. Los hijos deberían ser [`Role::ListItem`].
List,
/// Ítem de una lista.
ListItem,
/// Árbol jerárquico. Los hijos deberían ser [`Role::TreeItem`].
Tree,
/// Fila de un árbol. Combina con el flag `expanded` cuando tiene hijos.
TreeItem,
/// Tabla de datos. Los hijos son [`Role::Row`], y los de éstos
/// [`Role::Cell`] / [`Role::ColumnHeader`].
Table,
/// Fila de una tabla.
Row,
/// Celda de una tabla.
Cell,
/// Celda de encabezado de columna.
ColumnHeader,
// --- Estado y notificación ---
/// Barra de progreso / medidor. El `value` lleva el texto que el lector
/// enuncia ("70%", "3 de 10").
ProgressBar,
/// Mensaje que aparece y reclama atención (toast, banner de error). Los
/// lectores lo anuncian sin que el usuario navegue hasta él.
Alert,
/// Región de estado no urgente (status bar). A diferencia de
/// [`Role::Alert`], se anuncia sin interrumpir.
Status,
/// Rótulo flotante de ayuda asociado a otro control.
Tooltip,
// --- Controles adicionales ---
/// Interruptor on/off. Distinto de [`Role::Checkbox`]: los lectores lo
/// enuncian "activado/desactivado" en vez de "marcado". Combina con
/// `checked`.
Switch,
/// Diálogo modal. Combina con `label` (el título) — el lector lo enuncia
/// al abrirse y confina la navegación adentro.
Dialog,
/// Campo de texto **multilínea** (text-area, editor). Los lectores cambian
/// el modo de navegación respecto de [`Role::TextInput`] de una línea.
MultilineTextInput,
/// Emulador de terminal. AccessKit tiene un rol propio: los lectores le
/// aplican el modo "salida en vivo" en vez de leer el buffer entero.
Terminal,
/// Divisor arrastrable entre dos paneles. Los lectores lo exponen como
/// control redimensionable en vez de como una línea decorativa.
Splitter,
}
/// Banderas booleanas del nodo accesible. Todas opcionales (`None` = no aplica,
+410
View File
@@ -0,0 +1,410 @@
//! Dónde poner un `repaint_boundary`, medido en vez de adivinado.
//!
//! ## El agujero que esto tapa
//!
//! El Bloque 23 del `PLAN-NUCLEO-RETENIDO` construyó el caché de rasterización
//! por subárbol, lo certificó píxel a píxel, y quedó anotado un follow-up:
//! «falta cablear `repaint_boundary` en chrome estático real». Seis meses
//! después no lo usa **ninguna app** — sólo los `examples/stress_*` del propio
//! motor, que se lo ponen a un grid que ellos mismos fabricaron quieto.
//!
//! La razón no es desidia: **nadie sabe dónde**. Marcar un subárbol que en
//! realidad cambia todos los frames no es neutro —se paga el hash entero cada
//! cuadro y no se ahorra nada—, y desde afuera no hay forma de distinguir un
//! panel quieto de uno que se reconstruye igual a sí mismo. Adivinar en la ruta
//! caliente es exactamente lo que el Bloque 21 prohíbe: medir antes de tocar.
//!
//! Esto es el instrumento que faltaba. Observa el árbol cuadro a cuadro y dice
//! qué subárboles **no cambiaron**, cuántos nodos tienen y cómo reconocerlos.
//!
//! ## Por qué una sugerencia de acá es una predicción, no una opinión
//!
//! El detector reusa **las mismas tres funciones** que usa el caché de verdad:
//! `opens_layer` (posición limpia), `boundary_cacheable` (paint-puro y sin el
//! puntero adentro) y `boundary_hash` (el hash con rects relativos al origen,
//! que es el que decide hit o miss). No hay una segunda implementación que
//! pueda divergir de la primera — que es como el bench del GPU-directo terminó
//! publicando 23.000 fps sobre un pase que no dibujaba.
//!
//! Lo que **no** dice: si conviene. Una sugerencia es «esto habría sido un
//! cache-hit», y el hash se paga todos los cuadros. Por eso hay un mínimo de
//! nodos: bajo cierto tamaño, hashear cuesta más que volver a pintar.
use alloc::string::String;
use alloc::vec::Vec;
use hashbrown::HashMap;
use crate::llimphi_layout::ComputedLayout;
use crate::render::{boundary_cacheable, boundary_hash, opens_layer};
use crate::Mounted;
/// Un subárbol que viene quieto y sería cache-hit si llevara
/// [`crate::View::repaint_boundary`].
#[derive(Debug, Clone)]
pub struct SugerenciaBoundary {
/// Índice del nodo raíz del subárbol en el `Mounted` de este cuadro.
pub idx: usize,
/// Cuántos nodos cuelgan de él (contándolo). Es la magnitud del ahorro: es
/// lo que `paint` recorre y codifica cada cuadro sin necesidad.
pub nodos: usize,
/// Su rect en pantalla, para ubicarlo.
pub rect: (f32, f32, f32, f32),
/// El primer texto que aparece adentro. Un índice de nodo no le dice nada a
/// nadie; «Archivos» sí.
pub texto: Option<String>,
/// Cuántos cuadros seguidos viene idéntico.
pub frames_quieto: u32,
}
/// Detector de subárboles quietos. Vive entre cuadros.
///
/// **Es diagnóstico, no producción**: hashea cada candidato por cuadro, que es
/// O(nodos × profundidad). Se enciende a mano y se apaga.
pub struct DetectorBoundaries {
previo: HashMap<(usize, usize), (u64, u32)>,
actual: HashMap<(usize, usize), (u64, u32)>,
/// Tamaño mínimo del subárbol para considerarlo. Bajo esto, el hash que se
/// pagaría cada cuadro no lo compensa el pintado que se ahorra.
pub min_nodos: usize,
/// Cuántos cuadros seguidos tiene que venir idéntico antes de sugerirlo.
/// Un solo cuadro quieto no dice nada: un botón entre dos parpadeos del
/// cursor también lo está.
pub min_frames: u32,
}
impl Default for DetectorBoundaries {
fn default() -> Self {
Self {
previo: HashMap::new(),
actual: HashMap::new(),
min_nodos: 16,
min_frames: 30,
}
}
}
impl DetectorBoundaries {
pub fn new() -> Self {
Self::default()
}
/// Con umbrales propios. `min_nodos` = tamaño mínimo del subárbol;
/// `min_frames` = cuántos cuadros seguidos tiene que venir idéntico.
pub fn con(min_nodos: usize, min_frames: u32) -> Self {
Self { min_nodos, min_frames, ..Self::default() }
}
/// Mira un cuadro y devuelve los subárboles que ya llevan `min_frames`
/// quietos.
///
/// Devuelve sólo los **máximos**: si un panel entero viene quieto, no se
/// listan también sus doce hijos. Y no sugiere nada dentro de un
/// `repaint_boundary` ya declarado — ahí el trabajo está hecho.
pub fn observar<Msg>(
&mut self,
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
hover_idx: Option<usize>,
drop_hover_idx: Option<usize>,
) -> Vec<SugerenciaBoundary> {
self.actual.clear();
let mut salida = Vec::new();
// Capas abiertas por un ancestro (clip/alpha/transform): adentro de una,
// un `scene.append` trasladado no reproduce lo que hay, así que el caché
// ni lo intenta y sugerirlo sería mentir.
let mut capas: Vec<usize> = Vec::new();
// Subárboles ya cubiertos: o son un boundary declarado, o ya se
// sugirieron y sus hijos no agregan nada.
let mut cubierto: Vec<usize> = Vec::new();
for idx in 0..mounted.nodes.len() {
while capas.last().is_some_and(|&e| idx >= e) {
capas.pop();
}
while cubierto.last().is_some_and(|&e| idx >= e) {
cubierto.pop();
}
let nodo = &mounted.nodes[idx];
let end = nodo.subtree_end;
if nodo.repaint_boundary.is_some() {
cubierto.push(end);
} else if cubierto.is_empty()
&& capas.is_empty()
&& end - idx >= self.min_nodos
&& boundary_cacheable(mounted, idx, end, hover_idx, drop_hover_idx)
{
let rect = computed
.get(nodo.id)
.map(|r| (r.x, r.y, r.w, r.h))
.unwrap_or((0.0, 0.0, 0.0, 0.0));
let hash = boundary_hash(mounted, computed, idx, end, (rect.0, rect.1));
// La identidad entre cuadros es `(idx, end)`. Si el árbol cambia
// de forma, el par deja de casar y el contador arranca de cero:
// se pierden sugerencias, no se inventan. Para un diagnóstico
// ése es el lado correcto del error.
let quieto = match self.previo.get(&(idx, end)) {
Some((h, q)) if *h == hash => q + 1,
_ => 1,
};
self.actual.insert((idx, end), (hash, quieto));
if quieto >= self.min_frames {
salida.push(SugerenciaBoundary {
idx,
nodos: end - idx,
rect,
texto: mounted.nodes[idx..end]
.iter()
.find_map(|n| n.text.as_ref().map(|t| t.content.clone())),
frames_quieto: quieto,
});
cubierto.push(end);
}
}
// El propio nodo puede abrir su capa: se registra DESPUÉS de
// evaluarlo, porque esa capa es interna a su subárbol.
if opens_layer(nodo) {
capas.push(end);
}
}
core::mem::swap(&mut self.previo, &mut self.actual);
salida
}
/// Olvida lo observado (cambió el viewport, el tema, la pantalla…).
pub fn clear(&mut self) {
self.previo.clear();
self.actual.clear();
}
}
/// Una línea por sugerencia, lista para imprimir.
pub fn describir(s: &SugerenciaBoundary) -> String {
let etiqueta = match &s.texto {
Some(t) => {
let t: String = t.chars().take(28).collect();
alloc::format!(" «{t}»")
}
None => String::new(),
};
alloc::format!(
" nodo {} · {} nodos · ({:.0},{:.0} {:.0}×{:.0}) · {} cuadros quieto{}",
s.idx,
s.nodos,
s.rect.0,
s.rect.1,
s.rect.2,
s.rect.3,
s.frames_quieto,
etiqueta
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llimphi_layout::taffy::prelude::*;
use crate::llimphi_layout::LayoutTree;
use crate::peniko::Color;
use crate::{mount, paint_cached, RepaintCache, View};
const VP: (f32, f32) = (400.0, 300.0);
const CLAVE_PANEL: u64 = 0xC0FFEE;
fn montar(v: View<()>) -> (crate::Mounted<()>, ComputedLayout) {
let mut lt = LayoutTree::new();
let m = mount(&mut lt, v);
let c = lt.compute(m.root, VP).expect("layout");
(m, c)
}
/// Un panel de `n` filas de texto. Es la forma de un sidebar real: muchos
/// nodos, todos paint-puros.
fn panel(titulo: &str, n: usize) -> View<()> {
let mut hijos = alloc::vec![View::<()>::new(Style::default()).text(
titulo,
14.0,
Color::WHITE
)];
for i in 0..n {
hijos.push(
View::<()>::new(Style {
size: Size { width: length(180.0), height: length(20.0) },
..Default::default()
})
.fill(Color::from_rgba8(40, 40, 50, 255))
.children(alloc::vec![View::<()>::new(Style::default()).text(
alloc::format!("fila {i}"),
12.0,
Color::WHITE
)]),
);
}
View::<()>::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: length(200.0), height: percent(1.0_f32) },
..Default::default()
})
.fill(Color::from_rgba8(24, 24, 32, 255))
.children(hijos)
}
/// Chrome quieto al costado + un contador que cambia todos los cuadros. Es
/// exactamente el caso que el Bloque 23 quiere y que ninguna app cableó.
fn pantalla(marca: u32) -> View<()> {
View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.children(alloc::vec![
panel("Archivos", 12),
View::<()>::new(Style::default()).text(
alloc::format!("contador: {marca}"),
13.0,
Color::WHITE
),
])
}
fn correr(det: &mut DetectorBoundaries, cuadros: u32) -> Vec<SugerenciaBoundary> {
let mut ultima = Vec::new();
for f in 0..cuadros {
let (m, c) = montar(pantalla(f));
ultima = det.observar(&m, &c, None, None);
}
ultima
}
#[test]
fn señala_el_panel_quieto_y_no_lo_que_cambia() {
let mut det = DetectorBoundaries::con(16, 5);
let sug = correr(&mut det, 6);
assert_eq!(sug.len(), 1, "tendría que salir un solo subárbol: {sug:?}");
let s = &sug[0];
assert!(s.nodos >= 25, "el panel entero, no una fila: {} nodos", s.nodos);
assert_eq!(s.texto.as_deref(), Some("Archivos"), "y reconocible por su texto");
assert!(s.frames_quieto >= 5);
// El rect es el del panel, no el de la pantalla.
assert!((s.rect.2 - 200.0).abs() < 1.0, "rect {:?}", s.rect);
}
#[test]
fn un_cuadro_quieto_no_alcanza() {
// Cualquier cosa está quieta entre dos cuadros. Sugerir con esa
// evidencia sería mandar a cablear un boundary a un panel que cambia
// cada medio segundo, y el hash se paga en TODOS los cuadros.
let mut det = DetectorBoundaries::con(16, 30);
assert!(correr(&mut det, 29).is_empty(), "29 cuadros no llegan a 30");
assert!(!correr(&mut det, 1).is_empty(), "el 30 sí");
}
#[test]
fn lo_que_cambia_no_se_sugiere_nunca() {
// El control: si el panel también cambia, no hay nada que cachear y el
// detector tiene que callarse por muchos cuadros que pasen.
let mut det = DetectorBoundaries::con(16, 3);
let mut sug = Vec::new();
for f in 0..20 {
let vivo = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.children(alloc::vec![panel(&alloc::format!("Archivos {f}"), 12)]);
let (m, c) = montar(vivo);
sug = det.observar(&m, &c, None, None);
}
assert!(sug.is_empty(), "sugirió cachear algo que cambia: {sug:?}");
}
#[test]
fn no_sugiere_lo_que_ya_tiene_boundary() {
// Ni el nodo marcado ni sus hijos: ahí el trabajo está hecho y repetirlo
// en la salida sería ruido que hace ignorar el instrumento.
let mut det = DetectorBoundaries::con(16, 3);
let mut sug = Vec::new();
for f in 0..10 {
let v = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.children(alloc::vec![
panel("Archivos", 12).repaint_boundary(1),
View::<()>::new(Style::default()).text(
alloc::format!("{f}"),
13.0,
Color::WHITE
),
]);
let (m, c) = montar(v);
sug = det.observar(&m, &c, None, None);
}
assert!(sug.is_empty(), "sugirió un boundary donde ya hay uno: {sug:?}");
}
#[test]
fn con_el_puntero_adentro_se_calla() {
// Misma regla que el caché: un subárbol con el hover adentro no se
// sirve cacheado, así que sugerirlo sería prometer un ahorro que no va
// a pasar mientras el usuario tenga el puntero ahí.
let mut det = DetectorBoundaries::con(16, 3);
let mut sug = Vec::new();
for f in 0..10 {
let (m, c) = montar(pantalla(f));
// Un nodo bien adentro del panel.
sug = det.observar(&m, &c, Some(6), None);
}
assert!(sug.is_empty(), "sugirió con el puntero adentro: {sug:?}");
}
/// **El test que le da sentido a todos los demás.** Una sugerencia no es una
/// opinión sobre el árbol: es la predicción de que, puesto ahí el boundary,
/// el caché va a **acertar**. Acá se toma la sugerencia, se cablea, y se
/// cuentan los hits. Que además lo pintado no cambie lo verifica por
/// píxeles `llimphi-test/tests/boundary_cache.rs`, que es donde vive el
/// rasterizador.
#[test]
fn cablear_la_sugerencia_da_los_cache_hits_que_prometio() {
let mut det = DetectorBoundaries::con(16, 3);
let sug = correr(&mut det, 4);
let s = sug.first().expect("tenía que sugerir el panel");
// Se cablea donde dijo: el panel es el hijo 0 de la pantalla.
let con_boundary = |marca: u32| {
View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.children(alloc::vec![
panel("Archivos", 12).repaint_boundary(CLAVE_PANEL),
View::<()>::new(Style::default()).text(
alloc::format!("contador: {marca}"),
13.0,
Color::WHITE
),
])
};
let mut ts = crate::llimphi_text::Typesetter::new();
let mut cache = RepaintCache::new();
for marca in 0..4u32 {
let (m, c) = montar(con_boundary(marca));
let mut lienzo = crate::Lienzo::new();
paint_cached(&mut lienzo, &m, &c, &mut ts, None, None, &mut cache);
}
assert_eq!(
(cache.hits, cache.misses),
(3, 1),
"la sugerencia prometía cache-hits y el caché no acertó"
);
// Y que el nodo sugerido sea efectivamente el que quedó marcado: si el
// índice apuntara a otro lado, los hits de arriba serían casualidad.
let (m, _) = montar(con_boundary(0));
assert_eq!(
m.nodes[s.idx].repaint_boundary,
Some(CLAVE_PANEL),
"el idx de la sugerencia no es el nodo que lleva el boundary"
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
//! Verifica el contrato de [`measure_intrinsic_node`]: una hoja de contenido
//! REEMPLAZADO (una imagen) aporta **0 a min-content y su tamaño natural a
//! max-content**.
//!
//! No es una sutileza: es lo único que un `Style` no puede expresar, y cada
//! forma de escribirlo con `Style` solo rompe una punta distinta —medidas las
//! dos sobre el corpus de puriy antes de llegar acá—:
//! - tamaño DEFINIDO → aporta el natural a las dos, y un contenedor con
//! `max-width` no puede encoger por debajo del ancho de su imagen.
//! - tamaño en PORCENTAJE → aporta 0 a las dos, y adentro de un padre
//! shrink-to-fit (un `float`) la imagen DESAPARECE.
use llimphi_compositor::{measure_intrinsic_node, mount, IntrinsicSize, View};
use llimphi_layout::taffy::prelude::*;
use llimphi_layout::taffy::{AvailableSpace, Size as TSize};
use llimphi_layout::LayoutTree;
const NAT: IntrinsicSize = IntrinsicSize { width: 600.0, height: 300.0 };
fn medir(w: AvailableSpace) -> TSize<f32> {
measure_intrinsic_node(
&NAT,
TSize { width: None, height: None },
TSize { width: w, height: AvailableSpace::MaxContent },
)
}
#[test]
fn min_content_es_cero_y_max_content_es_el_natural() {
assert_eq!(medir(AvailableSpace::MaxContent).width, 600.0, "max-content = ancho natural");
assert_eq!(medir(AvailableSpace::MinContent).width, 0.0, "min-content = 0: puede encoger");
}
#[test]
fn con_ancho_definido_nunca_pasa_del_natural_ni_del_disponible() {
assert_eq!(medir(AvailableSpace::Definite(200.0)).width, 200.0, "más chico: se adapta");
assert_eq!(medir(AvailableSpace::Definite(900.0)).width, 600.0, "más grande: no se estira");
}
#[test]
fn el_alto_sigue_la_proporcion() {
let m = medir(AvailableSpace::Definite(300.0));
assert_eq!((m.width, m.height), (300.0, 150.0), "600x300 a la mitad = 300x150");
// Con el alto ya decidido por el layout, se respeta.
let fijo = measure_intrinsic_node(
&NAT,
TSize { width: None, height: Some(90.0) },
TSize { width: AvailableSpace::Definite(300.0), height: AvailableSpace::MaxContent },
);
assert_eq!(fijo.height, 90.0, "un alto conocido manda sobre la proporción");
}
/// Un natural degenerado (0 en un eje) no debe dividir por cero.
#[test]
fn un_natural_degenerado_no_rompe() {
let cero = IntrinsicSize { width: 0.0, height: 0.0 };
let m = measure_intrinsic_node(
&cero,
TSize { width: None, height: None },
TSize { width: AvailableSpace::MaxContent, height: AvailableSpace::MaxContent },
);
assert!(m.width.is_finite() && m.height.is_finite(), "sin NaN ni infinito: {m:?}");
}
/// Extremo a extremo: la medida se registra en el `mount` y el layout la usa.
/// Una imagen de 600px dentro de un contenedor de 200 se adapta a 200 (no
/// desborda) y conserva la proporción.
#[test]
fn el_mount_registra_la_medida_y_el_layout_la_usa() {
let mut lt = LayoutTree::new();
let vista: View<()> = View::new(Style {
size: TSize { width: length(200.0), height: auto() },
..Default::default()
})
.children(vec![View::new(Style::default()).intrinsic(600.0, 300.0)]);
let m = mount(&mut lt, vista);
assert_eq!(m.intrinsic_measures.len(), 1, "la hoja quedó registrada para medir");
lt.compute_with_measure(m.root, (1000.0, 1000.0), |nid, known, avail| {
match m.intrinsic_measures.get(&nid) {
Some(nat) => measure_intrinsic_node(nat, known, avail),
None => TSize::ZERO,
}
})
.expect("layout");
let hoja = lt.inner().children(m.root).unwrap()[0];
let l = lt.inner().layout(hoja).unwrap();
assert_eq!(l.size.width, 200.0, "se adapta al contenedor en vez de desbordarlo");
assert_eq!(l.size.height, 100.0, "y conserva la proporción 2:1");
}
+58
View File
@@ -0,0 +1,58 @@
[package]
name = "llimphi-cpu"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-cpu — el rasterizador de Llimphi sin GPU. Graba las órdenes de dibujo en una `Escena` (misma forma que `vello::Scene`) y las rinde con `vello_cpu` a un `Pixmap`, que se vuelca a cualquier framebuffer. `no_std + alloc`: es la pieza que deja correr Llimphi sobre el kernel bare-metal de wawa, donde no hay wgpu ni winit. W1 de 03_ukupacha/wawa/PLAN-LLIMPHI.md."
[dependencies]
# El rasterizador de sparse strips de Linebender, en modo bare-metal:
#
# default-features = false — su `default` trae `std` + `png`, y `png` FUERZA
# `std` a través de vello_common.
# libm — la matemática de coma flotante sin sistema.
# u8_pipeline — hace falta AL MENOS un pipeline o el crate emite
# `compile_error!`. Con sólo u8, el render debe
# pedir `RenderMode::OptimizeSpeed` (OptimizeQuality
# exige el pipeline f32).
vello_cpu = { version = "0.0.9", default-features = false, features = ["u8_pipeline", "text"] }
# `force_support_fallback` es lo que habilita `Level::fallback()`, que es
# `const fn`. La alternativa, `Level::new()`, usa `is_x86_feature_detected!` —
# que REQUIERE std y por lo tanto no existe en el kernel.
fearless_simd = { version = "0.4.1", default-features = false, features = ["force_support_fallback"] }
# -----------------------------------------------------------------------------
# `std` vs `libm`: hay que elegir UNA, y no es preferencia de estilo.
# -----------------------------------------------------------------------------
# `vello_common` importa `peniko::kurbo::common::FloatFuncs` — el trait que da
# `round`/`sqrt`/etc. sin sistema operativo. kurbo SÓLO define ese trait cuando
# su feature `std` está APAGADA; con `std` puesto, la stdlib ya provee esos
# métodos y kurbo configura el trait fuera del binario.
#
# De ahí sale una regla que no se puede desobedecer: **el perfil de este crate
# tiene que coincidir con el de kurbo en el mismo build**. Y en cuanto `vello`
# entra en la unificación de features, kurbo TIENE std —vello declara `peniko`
# con `default-features = true`—, así que pedir `libm` acá revienta con
# «no `FloatFuncs` in `common`» en cuatro archivos de vello_common.
#
# Por eso `std` es el default: en Linux, donde vello convive con vello_cpu (o
# sea, el workspace entero), es el único perfil coherente. wawa pide el otro
# explícitamente, y ahí no hay vello que unifique nada.
[features]
default = ["std"]
# Linux: además de resolver lo de kurbo, permite detectar el nivel SIMD en
# tiempo de ejecución en vez de conformarse con el escalar.
std = ["vello_cpu/std", "fearless_simd/std"]
# Bare-metal (wawa). Se pide así:
# cargo check -p llimphi-cpu --target x86_64-wawa \
# --no-default-features --features libm -Z build-std=core,alloc
libm = ["vello_cpu/libm", "fearless_simd/libm"]
# Sin dev-dependency sobre `llimphi-text`, a propósito: el puente que camina un
# `parley::Layout` y emite glifos vive del OTRO lado (feature `cpu` de
# llimphi-text), y el test de punta a punta con él. Así la flecha va en un solo
# sentido —llimphi-text → llimphi-cpu— y no hay ciclo que cargo tenga que
# tolerar. Este crate no sabe qué es parley, y está bien que no lo sepa.
+62
View File
@@ -0,0 +1,62 @@
# llimphi-cpu
> El rasterizador sin GPU de [llimphi](../README.md).
Graba las órdenes de dibujo en una `Escena` —los mismos ocho métodos de
`vello::Scene` que usa `llimphi-compositor`, con idéntica firma— y las rinde con
`vello_cpu` a un `Pixmap`, que se vuelca a cualquier framebuffer con su stride y
su orden de canales.
`no_std + alloc` de nacimiento: el consumidor final es el kernel bare-metal de
wawa, donde no hay wgpu, ni winit, ni `std`. Es W1 de
[`PLAN-LLIMPHI.md`](../../../03_ukupacha/wawa/PLAN-LLIMPHI.md).
## Por qué existe
`vello::Scene` es un **buffer codificado** para que la GPU lo consuma de un
saque; `vello_cpu::RenderContext` es **imperativo y con estado**. No se
enchufan. `Escena` cierra la brecha grabando cada llamada como una `Orden`, y
`Pintor` las reproduce fijando el estado que cada una declara.
Grabar no es un rodeo: es lo que hace posible `append` —componer una escena hija
dentro de la madre— sin el cual el compositor no puede reciclar los subárboles
que cacheó.
## Piezas
- `escena` — la grabadora. `Escena`, `Orden`, `Forma`.
- `pintor` — reproduce la escena contra `vello_cpu` y deja un `Pixmap`.
- `lienzo` — vuelca el `Pixmap` al framebuffer (`stride`, BGRA/RGBA).
## Lo que NO hace
**Texto.** El shaping vive en `llimphi-text`, todavía atado a `std` y a las
fuentes del sistema — es W2 del plan, la fase más cara, y mezclarla acá
empantanaría las dos.
## Deps
- `vello_cpu` (`default-features = false`, `u8_pipeline`)
- `fearless_simd` (`force_support_fallback`)
## Los dos perfiles — y por qué no son intercambiables
```bash
cargo test -p llimphi-cpu # std (default), Linux
cargo check -p llimphi-cpu --target x86_64-wawa \
--no-default-features --features libm \
-Z build-std=core,alloc # bare-metal, wawa
```
`vello_common` importa `peniko::kurbo::common::FloatFuncs`, y kurbo **sólo
define ese trait cuando su feature `std` está apagada**. O sea: el perfil de
este crate tiene que coincidir con el de kurbo en el mismo build. Como `vello`
declara `peniko` con `default-features = true`, cualquier build donde vello y
vello_cpu convivan tiene kurbo **con** std — y ahí pedir `libm` falla con
«no `FloatFuncs` in `common`».
Por eso `std` es el default (es el único perfil coherente en el workspace) y
`libm` se pide explícitamente para wawa, donde no hay vello que unifique nada.
Con `libm` el nivel SIMD es el escalar: `Level::new()` usa
`is_x86_feature_detected!`, que no existe sin sistema operativo. Que alcance no
es consuelo — el spike de W0 lo midió en 909-5.000 fps a 480x400.
+62
View File
@@ -0,0 +1,62 @@
# llimphi-cpu
> The GPU-less rasterizer of [llimphi](../README.md).
Records draw calls into a `Escena` —the same eight `vello::Scene` methods that
`llimphi-compositor` uses, with identical signatures— and renders them with
`vello_cpu` into a `Pixmap`, which is then blitted to any framebuffer honoring
its stride and channel order.
`no_std + alloc` by birth: the end consumer is wawa's bare-metal kernel, where
there is no wgpu, no winit, and no `std`. This is W1 of
[`PLAN-LLIMPHI.md`](../../../03_ukupacha/wawa/PLAN-LLIMPHI.md).
## Why it exists
`vello::Scene` is an **encoded buffer** for the GPU to devour in one go;
`vello_cpu::RenderContext` is **imperative and stateful**. They don't plug into
each other. `Escena` bridges the gap by recording each call as an `Orden`, and
`Pintor` replays them, setting the state each one declares.
Recording is not a detour: it is what makes `append` possible —composing a child
scene inside the parent— without which the compositor cannot reuse the subtrees
it cached.
## Parts
- `escena` — the recorder. `Escena`, `Orden`, `Forma`.
- `pintor` — replays the scene against `vello_cpu`, yielding a `Pixmap`.
- `lienzo` — blits the `Pixmap` to the framebuffer (`stride`, BGRA/RGBA).
## What it does NOT do
**Text.** Shaping lives in `llimphi-text`, still tied to `std` and to system
fonts — that is W2 of the plan, the most expensive phase, and mixing it in here
would bog down both.
## Deps
- `vello_cpu` (`default-features = false`, `u8_pipeline`)
- `fearless_simd` (`force_support_fallback`)
## The two profiles — and why they are not interchangeable
```bash
cargo test -p llimphi-cpu # std (default), Linux
cargo check -p llimphi-cpu --target x86_64-wawa \
--no-default-features --features libm \
-Z build-std=core,alloc # bare-metal, wawa
```
`vello_common` imports `peniko::kurbo::common::FloatFuncs`, and kurbo **only
defines that trait when its `std` feature is off**. So this crate's profile must
match kurbo's in the same build. Since `vello` declares `peniko` with
`default-features = true`, any build where vello and vello_cpu coexist has kurbo
**with** std — and asking for `libm` there fails with "no `FloatFuncs` in
`common`".
Hence `std` is the default (the only coherent profile across the workspace), and
`libm` is requested explicitly for wawa, where no vello unifies anything. Under
`libm` the SIMD level is scalar: `Level::new()` uses `is_x86_feature_detected!`,
which does not exist without an OS. That it suffices is not a consolation — the
W0 spike measured it at 909-5,000 fps at 480x400.
+377
View File
@@ -0,0 +1,377 @@
// =============================================================================
// escena :: la grabadora de órdenes de dibujo
// -----------------------------------------------------------------------------
// `vello::Scene` es un BUFFER CODIFICADO: cada `fill`/`stroke` no dibuja nada,
// apila bytes que después la GPU consume de una. `vello_cpu::RenderContext`, en
// cambio, es IMPERATIVO y con estado: se le fija transform/paint/fill-rule y se
// le pide `fill_path` ya mismo. Esa es toda la diferencia entre los dos mundos,
// y es el trabajo que el plan (W1) señalaba: «vello_cpu cubre fill/stroke/clip/
// Pixmap casi 1:1; NO tiene `Scene` — ahí está el trabajo».
//
// `Escena` cierra esa brecha grabando. Expone los OCHO métodos que
// `llimphi-compositor` usa de `vello::Scene`, con la misma firma, y guarda cada
// llamada como una `Orden`. Después `crate::pintor` las reproduce contra un
// `RenderContext`. Grabar en vez de dibujar al vuelo no es un rodeo: es lo que
// hace que `append` —componer una escena hija dentro de la madre, que el
// compositor usa para reciclar subárboles cacheados— sea posible del todo.
//
// Las órdenes hablan en tipos de `peniko`/`kurbo`, que son LOS MISMOS que usa
// vello (ambos resuelven a peniko 0.6). No hay conversión ni copia semántica en
// el medio: `Color`, `Affine`, `BezPath` cruzan tal cual.
// =============================================================================
use alloc::vec::Vec;
use vello_cpu::kurbo::{Affine, BezPath, Rect, RoundedRect, Shape, Stroke};
use vello_cpu::peniko::{BlendMode, Brush, BrushRef, Color, Fill};
/// Tolerancia de aplanado al convertir una forma arbitraria en `BezPath`.
///
/// Es la misma que vello codifica de fábrica para sus formas (0,1 px). Más fino
/// no se nota en pantalla y cuesta segmentos; más grueso se ve en los radios.
const TOLERANCIA: f64 = 0.1;
/// Una forma grabada.
///
/// Se conservan los casos rápidos en su tipo original en vez de aplanar todo a
/// `BezPath`: `vello_cpu` tiene `fill_rect`/`stroke_rect` dedicados, y el
/// compositor de Llimphi dibuja rectángulos y rectángulos redondeados casi
/// siempre (cajas, bordes, recortes). Aplanar eso a curvas sería pagar
/// segmentos por nada en el 90% de los nodos.
#[derive(Clone, Debug)]
pub enum Forma {
/// Rectángulo recto — el camino barato.
Rect(Rect),
/// Rectángulo redondeado — se aplana al reproducir, pero se graba compacto.
Redondeado(RoundedRect),
/// Cualquier otra cosa, ya aplanada a curvas de Bézier.
Camino(BezPath),
}
impl Forma {
/// Graba una forma cualquiera, quedándose con la representación más barata.
///
/// `kurbo::Shape` ofrece `as_rect`/`as_rounded_rect` justamente para esto:
/// preguntar sin castear ni conocer el tipo concreto.
pub fn de(forma: &impl Shape) -> Self {
if let Some(r) = forma.as_rect() {
Self::Rect(r)
} else if let Some(rr) = forma.as_rounded_rect() {
Self::Redondeado(rr)
} else {
Self::Camino(forma.into_path(TOLERANCIA))
}
}
/// La forma como camino de Bézier. Para el recorte de capa, donde
/// `vello_cpu` sólo acepta `BezPath`.
pub fn a_camino(&self) -> BezPath {
match self {
Self::Rect(r) => r.into_path(TOLERANCIA),
Self::Redondeado(rr) => rr.into_path(TOLERANCIA),
Self::Camino(c) => c.clone(),
}
}
}
/// Una orden de dibujo grabada, con todo lo que hace falta para reproducirla.
///
/// El pincel viaja **en propiedad** (`Brush`, no `BrushRef`): la escena
/// sobrevive a quien la grabó —es justamente el punto de `append`— así que no
/// puede quedarse con préstamos.
#[derive(Clone, Debug)]
pub enum Orden {
/// `Scene::fill`.
Rellenar {
regla: Fill,
xf: Affine,
pincel: Brush,
xf_pincel: Option<Affine>,
forma: Forma,
},
/// `Scene::stroke`.
Trazar {
estilo: Stroke,
xf: Affine,
pincel: Brush,
xf_pincel: Option<Affine>,
forma: Forma,
},
/// `Scene::push_layer`.
AbrirCapa {
regla: Fill,
mezcla: BlendMode,
alfa: f32,
xf: Affine,
recorte: Forma,
},
/// `Scene::push_luminance_mask_layer`.
AbrirMascaraLuminancia {
regla: Fill,
alfa: f32,
xf: Affine,
recorte: Forma,
},
/// `Scene::pop_layer`.
CerrarCapa,
/// `Scene::draw_image`.
Imagen {
imagen: vello_cpu::peniko::ImageBrush,
xf: Affine,
},
/// `Scene::draw_blurred_rounded_rect`.
RectDifuso {
xf: Affine,
rect: Rect,
color: Color,
radio: f64,
desvio: f64,
},
/// Una corrida de glifos ya shapeados. El equivalente de `Scene::draw_glyphs`.
Glifos {
fuente: vello_cpu::peniko::FontData,
tamano: f32,
xf: Affine,
pincel: Brush,
/// Ajustar los contornos a la grilla de píxeles. En tamaños de UI
/// (12-14 px) es la diferencia entre texto nítido y texto borroso.
hint: bool,
glifos: Vec<Glifo>,
},
}
/// Un glifo posicionado: qué dibujar y dónde, en coordenadas de la corrida.
///
/// Es un tipo propio y no el de vello ni el de `glifo` a propósito: la escena es
/// el punto donde los dos mundos se tocan, y hacerla hablar el vocabulario de
/// uno de los dos la ataría a él. Los tres campos son los mismos en ambos, así
/// que la traducción es un `map` en el borde.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Glifo {
/// Índice del glifo **dentro de la fuente**. No es un code point Unicode:
/// el shaping ya resolvió ligaduras, bidi y selección de cara.
pub id: u32,
/// Desplazamiento horizontal dentro de la corrida.
pub x: f32,
/// Desplazamiento vertical dentro de la corrida (línea base).
pub y: f32,
}
/// La escena grabada: una lista plana de órdenes, en orden de emisión.
///
/// Deliberadamente plana y `Clone`: reproducir es recorrerla, y `append` es
/// concatenar. No hay árbol que recorrer ni punteros que arreglar.
#[derive(Clone, Debug, Default)]
pub struct Escena {
ordenes: Vec<Orden>,
}
impl Escena {
/// Una escena vacía.
pub fn new() -> Self {
Self::default()
}
/// Descarta todo lo grabado conservando la capacidad ya reservada.
///
/// Es lo que se llama entre fotogramas: el bucle de Llimphi vuelve a pintar
/// la vista entera cada vez, y realocar el vector en cada frame sería
/// regalar trabajo al asignador —que en wawa es un `linked_list_allocator`
/// de kernel, no un malloc con arenas—.
pub fn reset(&mut self) {
self.ordenes.clear();
}
/// Las órdenes grabadas, en orden. Lo que consume `crate::pintor`.
pub fn ordenes(&self) -> &[Orden] {
&self.ordenes
}
/// Cuántas órdenes tiene. Útil para tests y para medir escenas.
pub fn len(&self) -> usize {
self.ordenes.len()
}
/// ¿No se grabó nada?
pub fn is_empty(&self) -> bool {
self.ordenes.is_empty()
}
// -------------------------------------------------------------------------
// Los ocho métodos que `llimphi-compositor` usa de `vello::Scene`.
// Las firmas son las de vello 0.7, a propósito: el objetivo es que el
// compositor no note con cuál de los dos está hablando.
// -------------------------------------------------------------------------
/// Rellena una forma. Espeja `vello::Scene::fill`.
pub fn fill<'b>(
&mut self,
style: Fill,
transform: Affine,
brush: impl Into<BrushRef<'b>>,
brush_transform: Option<Affine>,
shape: &impl Shape,
) {
self.ordenes.push(Orden::Rellenar {
regla: style,
xf: transform,
pincel: brush.into().to_owned(),
xf_pincel: brush_transform,
forma: Forma::de(shape),
});
}
/// Traza el contorno de una forma. Espeja `vello::Scene::stroke`.
pub fn stroke<'b>(
&mut self,
style: &Stroke,
transform: Affine,
brush: impl Into<BrushRef<'b>>,
brush_transform: Option<Affine>,
shape: &impl Shape,
) {
self.ordenes.push(Orden::Trazar {
estilo: style.clone(),
xf: transform,
pincel: brush.into().to_owned(),
xf_pincel: brush_transform,
forma: Forma::de(shape),
});
}
/// Abre una capa recortada. Espeja `vello::Scene::push_layer`.
pub fn push_layer(
&mut self,
clip_style: Fill,
blend: impl Into<BlendMode>,
alpha: f32,
transform: Affine,
clip: &impl Shape,
) {
self.ordenes.push(Orden::AbrirCapa {
regla: clip_style,
mezcla: blend.into(),
alfa: alpha.clamp(0.0, 1.0),
xf: transform,
recorte: Forma::de(clip),
});
}
/// Abre una capa que enmascara por luminancia.
/// Espeja `vello::Scene::push_luminance_mask_layer`.
pub fn push_luminance_mask_layer(
&mut self,
clip_style: Fill,
alpha: f32,
transform: Affine,
clip: &impl Shape,
) {
self.ordenes.push(Orden::AbrirMascaraLuminancia {
regla: clip_style,
alfa: alpha.clamp(0.0, 1.0),
xf: transform,
recorte: Forma::de(clip),
});
}
/// Cierra la última capa abierta. Espeja `vello::Scene::pop_layer`.
pub fn pop_layer(&mut self) {
self.ordenes.push(Orden::CerrarCapa);
}
/// Dibuja una imagen. Espeja `vello::Scene::draw_image`.
pub fn draw_image(&mut self, image: &vello_cpu::peniko::ImageBrush, transform: Affine) {
self.ordenes.push(Orden::Imagen {
imagen: image.clone(),
xf: transform,
});
}
/// Dibuja un rectángulo redondeado con desenfoque gaussiano — la sombra.
/// Espeja `vello::Scene::draw_blurred_rounded_rect`.
pub fn draw_blurred_rounded_rect(
&mut self,
transform: Affine,
rect: Rect,
brush: Color,
radius: f64,
std_dev: f64,
) {
self.ordenes.push(Orden::RectDifuso {
xf: transform,
rect,
color: brush,
radio: radius,
desvio: std_dev,
});
}
/// Dibuja una corrida de glifos ya shapeados.
///
/// **No espeja la firma de `vello::Scene::draw_glyphs`, y es a propósito.**
/// La de vello devuelve un *builder* (`DrawGlyphs`) que se va configurando y
/// se cierra con `.draw(style, glyphs)`. Reproducir ese builder acá sería
/// maquinaria para nadie: el único que llama a este método es código propio
/// —el puente de `llimphi-text` hacia el backend CPU—, no el DSL de widgets.
/// Los ocho métodos que sí espejan firma lo hacen porque `llimphi-compositor`
/// los invoca tal cual; éste no está en ese conjunto.
pub fn draw_glyphs<'b>(
&mut self,
fuente: &vello_cpu::peniko::FontData,
tamano: f32,
brush: impl Into<BrushRef<'b>>,
transform: Affine,
hint: bool,
glifos: impl IntoIterator<Item = Glifo>,
) {
let glifos: Vec<Glifo> = glifos.into_iter().collect();
if glifos.is_empty() {
return;
}
self.ordenes.push(Orden::Glifos {
fuente: fuente.clone(),
tamano,
xf: transform,
pincel: brush.into().to_owned(),
hint,
glifos,
});
}
/// Incorpora una escena hija, opcionalmente transformada.
/// Espeja `vello::Scene::append`.
///
/// Es la razón de ser de la grabación. El compositor cachea el dibujo de un
/// subárbol que no cambió y lo vuelve a pegar trasladado al origen nuevo;
/// sin escena grabada eso exigiría re-ejecutar el subárbol entero.
pub fn append(&mut self, other: &Self, transform: Option<Affine>) {
match transform {
None => self.ordenes.extend_from_slice(&other.ordenes),
Some(t) => self
.ordenes
.extend(other.ordenes.iter().map(|o| trasladar(o, t))),
}
}
}
/// Aplica una transformación a una orden ya grabada.
///
/// La transformación se compone POR IZQUIERDA (`t * xf`), igual que hace vello
/// al concatenar encodings: la de la madre se aplica DESPUÉS de la de la hija,
/// que es lo que uno espera al meter un dibujo dentro de otro ya posicionado.
/// La transformación del pincel se deja como está: es relativa a la de la
/// forma, no al mundo, y componerla dos veces desplazaría los gradientes.
fn trasladar(orden: &Orden, t: Affine) -> Orden {
let mut o = orden.clone();
match &mut o {
Orden::Rellenar { xf, .. }
| Orden::Trazar { xf, .. }
| Orden::AbrirCapa { xf, .. }
| Orden::AbrirMascaraLuminancia { xf, .. }
| Orden::Imagen { xf, .. }
| Orden::RectDifuso { xf, .. }
| Orden::Glifos { xf, .. } => *xf = t * *xf,
Orden::CerrarCapa => {}
}
o
}
+60
View File
@@ -0,0 +1,60 @@
// =============================================================================
// llimphi-cpu :: Llimphi sin GPU (W1 de 03_ukupacha/wawa/PLAN-LLIMPHI.md)
// -----------------------------------------------------------------------------
// Durante dos años la doctrina fue que Llimphi no podía correr en wawa porque
// arrastra wgpu/winit/std. El spike de W0 la tumbó: TODO el stack de Linebender
// —vello_cpu, vello_common, peniko, kurbo— es `no_std`, y con SSE encendido
// cruza a `x86_64-wawa` sin tocarle una línea. Este crate es lo que faltaba en
// el medio.
//
// El problema concreto: `llimphi-compositor` pinta contra `vello::Scene`, que
// es un BUFFER CODIFICADO para que después la GPU lo devore de un saque.
// `vello_cpu` no tiene `Scene` — su API es imperativa y con estado. Las dos
// mitades no se enchufan.
//
// La pieza tiene tres partes, y ninguna es lista:
//
// escena — una `Escena` que expone los OCHO métodos de `vello::Scene` que el
// compositor usa, con la MISMA firma, y en vez de dibujar los
// graba. Grabar es lo que hace posible `append`, sin el cual el
// compositor no puede reciclar subárboles cacheados.
// pintor — reproduce esa lista contra un `vello_cpu::RenderContext` y deja
// un `Pixmap`.
// lienzo — vuelca el `Pixmap` al framebuffer real, con su stride y su orden
// de canales.
//
// `no_std + alloc` de nacimiento, no como concesión: el consumidor final es el
// kernel de wawa, donde no hay `std` que valga. En Linux compila igual —un
// crate `no_std` es perfectamente utilizable desde uno con `std`— y la feature
// `std` sólo agrega la detección de SIMD en tiempo de ejecución.
//
// Lo que este crate NO hace, a propósito: texto. El shaping vive en
// `llimphi-text`, que sigue atado a `std` y a las fuentes del sistema — es W2,
// la fase más cara del plan, y mezclarla acá sería empantanar las dos.
// =============================================================================
// `no_std` cuando se pide el perfil bare-metal (`--no-default-features
// --features libm`). Con `std` —el default, y el único perfil coherente cuando
// vello convive con vello_cpu; ver el porqué en Cargo.toml— y bajo `cargo test`,
// se usa la stdlib. Lo que se compila para wawa es el primer perfil, y ése es
// `no_std` puro.
#![cfg_attr(not(any(feature = "std", test)), no_std)]
extern crate alloc;
pub mod escena;
pub mod lienzo;
pub mod pintor;
pub use escena::{Escena, Forma, Glifo, Orden};
pub use lienzo::{volcar, FormatoPixel};
pub use pintor::Pintor;
// Se reexporta el sustrato para que quien use este crate no tenga que declarar
// `vello_cpu` por su cuenta ni —peor— arriesgarse a traer OTRA versión de peniko
// y terminar con dos tipos `Color` incompatibles que el compilador reporta como
// «expected Color, found Color».
pub use vello_cpu::{kurbo, peniko, Pixmap};
#[cfg(test)]
mod pruebas;
+87
View File
@@ -0,0 +1,87 @@
// =============================================================================
// lienzo :: del `Pixmap` al framebuffer de verdad
// -----------------------------------------------------------------------------
// El último tramo, y el más tonto de todos — pero el que decide si se ve algo.
// `vello_cpu` entrega un `Pixmap` de `PremulRgba8` COMPACTO (ancho*alto, sin
// relleno). Un framebuffer real casi nunca es así: tiene un `stride` mayor que
// el ancho visible (la GPU alinea cada fila) y el orden de canales lo dicta el
// firmware, no nosotros — el GOP de UEFI reporta `Bgr` tan seguido como `Rgb`.
//
// Por eso el volcado no es un `copy_from_slice`: es fila por fila, con
// conversión de canal. Se hace en el orden de memoria del destino para que el
// acceso sea secuencial; en un framebuffer sin caché de escritura combinada
// (el WC que el kernel programa en el PAT) saltar de acá para allá se paga muy
// caro.
// =============================================================================
use vello_cpu::Pixmap;
/// Cómo espera los canales el framebuffer de destino.
///
/// Sólo los dos ordenamientos de 32 bits que aparecen en la práctica: es lo que
/// reporta el GOP de UEFI (`PixelFormat::{Rgb, Bgr}`) y lo que ofrece virtio-gpu.
/// Los modos de 24 bits y los paletizados no se contemplan — ningún camino de
/// wawa los produce.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FormatoPixel {
/// Byte 0 = azul, 1 = verde, 2 = rojo, 3 = ignorado. Lo más común en x86.
Bgra8,
/// Byte 0 = rojo, 1 = verde, 2 = azul, 3 = ignorado.
Rgba8,
}
/// Vuelca un `Pixmap` sobre un framebuffer crudo.
///
/// - `destino`: los bytes del framebuffer, tal como los mapeó el kernel.
/// - `stride_bytes`: cuántos bytes hay de una fila a la siguiente. **No** es
/// `ancho * 4`: el firmware suele pedir más. Pasarlo mal produce la imagen
/// inclinada en diagonal, que es el síntoma clásico de confundir stride con
/// ancho.
/// - `formato`: el orden de canales del destino.
///
/// Recorta a lo que entre: si el pixmap es más grande que el framebuffer, se
/// copia la intersección en vez de entrar en pánico. Un kernel no debe morir
/// porque una ventana quedó más ancha que la pantalla.
///
/// Devuelve cuántas filas se copiaron de verdad.
pub fn volcar(
pixmap: &Pixmap,
destino: &mut [u8],
stride_bytes: usize,
formato: FormatoPixel,
) -> usize {
let ancho = pixmap.width() as usize;
let alto = pixmap.height() as usize;
if ancho == 0 || alto == 0 || stride_bytes < 4 {
return 0;
}
let origen = pixmap.data();
// Cuántos píxeles de la fila caben en el destino, y cuántas filas hay.
let px_por_fila = ancho.min(stride_bytes / 4);
let filas = alto.min(destino.len() / stride_bytes);
for y in 0..filas {
let fila_orig = &origen[y * ancho..y * ancho + px_por_fila];
let base = y * stride_bytes;
let fila_dest = &mut destino[base..base + px_por_fila * 4];
for (px, dest) in fila_orig.iter().zip(fila_dest.chunks_exact_mut(4)) {
// El pixmap ya viene premultiplicado y el framebuffer es opaco, así
// que el alfa se descarta: no hay nada debajo con qué componer.
match formato {
FormatoPixel::Bgra8 => {
dest[0] = px.b;
dest[1] = px.g;
dest[2] = px.r;
}
FormatoPixel::Rgba8 => {
dest[0] = px.r;
dest[1] = px.g;
dest[2] = px.b;
}
}
dest[3] = 255;
}
}
filas
}
+335
View File
@@ -0,0 +1,335 @@
// =============================================================================
// pintor :: reproducir una `Escena` grabada sobre `vello_cpu`
// -----------------------------------------------------------------------------
// Acá se cruza la frontera entre los dos modelos. La `Escena` guarda órdenes
// autocontenidas —cada una trae su transform, su pincel y su forma—; el
// `RenderContext` de vello_cpu tiene ESTADO, y espera que se lo fijes antes de
// pedirle el trazo. Reproducir es, entonces, para cada orden: fijar el estado
// que esa orden declara y disparar la primitiva.
//
// No se intenta minimizar los `set_*` comparando con el estado anterior. Sería
// la optimización obvia, y es prematura: `set_transform` es escribir un Affine
// en un struct, mientras que rasterizar el path que sigue es órdenes de
// magnitud más caro. Medirlo antes de complicarlo.
// =============================================================================
use alloc::sync::Arc;
use alloc::vec::Vec;
use vello_cpu::kurbo::{Affine, Rect};
use vello_cpu::peniko::color::{PremulRgba8, Srgb};
use vello_cpu::peniko::{Brush, ImageAlphaType, ImageFormat};
use vello_cpu::{Level, Pixmap, RenderContext, RenderMode, RenderSettings, Resources};
use crate::escena::{Escena, Forma, Orden};
/// El rasterizador: un `RenderContext` de vello_cpu con sus recursos, listo
/// para recibir escenas una y otra vez.
///
/// Se sostiene entre fotogramas a propósito. El `RenderContext` tiene búferes
/// internos (el de paths, sobre todo) que se reusan, y `Resources` guarda el
/// registro de imágenes: recrearlo por frame sería tirar ese trabajo.
pub struct Pintor {
ctx: RenderContext,
recursos: Resources,
ancho: u16,
alto: u16,
}
impl Pintor {
/// Un pintor para un lienzo de `ancho` x `alto` píxeles.
///
/// El nivel SIMD sale de [`nivel_simd`]: escalar en bare-metal, detectado en
/// Linux. `OptimizeSpeed` es obligatorio y no una preferencia: con sólo la
/// feature `u8_pipeline` compilada, pedir `OptimizeQuality` (que quiere el
/// pipeline f32) haría entrar a vello_cpu por un camino que no existe.
pub fn new(ancho: u16, alto: u16) -> Self {
let ajustes = RenderSettings {
level: nivel_simd(),
num_threads: 0,
render_mode: RenderMode::OptimizeSpeed,
};
Self {
ctx: RenderContext::new_with(ancho, alto, ajustes),
recursos: Resources::new(),
ancho,
alto,
}
}
/// Ancho del lienzo en píxeles.
pub fn ancho(&self) -> u16 {
self.ancho
}
/// Alto del lienzo en píxeles.
pub fn alto(&self) -> u16 {
self.alto
}
/// Reproduce la escena y deja el resultado en `destino`.
///
/// `destino` debe medir exactamente lo mismo que el pintor. El pixmap NO se
/// limpia acá: quien llama decide si el fotograma arranca de un fondo o
/// encima de lo anterior (Llimphi siempre pinta un fondo opaco de primera
/// orden, así que limpiar sería trabajo duplicado).
pub fn pintar(&mut self, escena: &Escena, destino: &mut Pixmap) {
self.ctx.reset();
for orden in escena.ordenes() {
self.ejecutar(orden);
}
self.ctx.flush();
self.ctx.render_to_pixmap(&mut self.recursos, destino);
}
/// Un pixmap del tamaño del pintor, listo para recibir un fotograma.
pub fn lienzo_nuevo(&self) -> Pixmap {
Pixmap::new(self.ancho, self.alto)
}
// -------------------------------------------------------------------------
fn ejecutar(&mut self, orden: &Orden) {
match orden {
Orden::Rellenar {
regla,
xf,
pincel,
xf_pincel,
forma,
} => {
self.ctx.set_fill_rule(*regla);
self.ctx.set_transform(*xf);
self.fijar_pincel(pincel, *xf_pincel);
match forma {
Forma::Rect(r) => self.ctx.fill_rect(r),
otra => self.ctx.fill_path(&otra.a_camino()),
}
}
Orden::Trazar {
estilo,
xf,
pincel,
xf_pincel,
forma,
} => {
self.ctx.set_stroke(estilo.clone());
self.ctx.set_transform(*xf);
self.fijar_pincel(pincel, *xf_pincel);
match forma {
Forma::Rect(r) => self.ctx.stroke_rect(r),
otra => self.ctx.stroke_path(&otra.a_camino()),
}
}
Orden::AbrirCapa {
regla,
mezcla,
alfa,
xf,
recorte,
} => {
self.ctx.set_fill_rule(*regla);
self.ctx.set_transform(*xf);
self.ctx.push_layer(
Some(&recorte.a_camino()),
Some(*mezcla),
Some(*alfa),
None,
None,
);
}
Orden::AbrirMascaraLuminancia {
regla,
alfa,
xf,
recorte,
} => self.abrir_mascara_luminancia(*regla, *alfa, *xf, recorte),
Orden::CerrarCapa => self.ctx.pop_layer(),
Orden::Imagen { imagen, xf } => {
let Some(pixmap) = pixmap_de_peniko(&imagen.image) else {
return;
};
let (w, h) = (pixmap.width() as f64, pixmap.height() as f64);
let fuente = vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(Arc::new(pixmap)),
sampler: imagen.sampler,
};
self.ctx.set_transform(*xf);
self.ctx.set_paint(fuente);
self.ctx.reset_paint_transform();
self.ctx.set_fill_rule(vello_cpu::peniko::Fill::NonZero);
self.ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
}
Orden::Glifos {
fuente,
tamano,
xf,
pincel,
hint,
glifos,
} => {
// El pincel y el transform se fijan ANTES de abrir el builder:
// `glyph_run` toma prestado el contexto hasta que se consume, y
// `fijar_pincel` necesita `&mut self`.
self.ctx.set_transform(*xf);
self.fijar_pincel(pincel, None);
self.ctx
.glyph_run(&mut self.recursos, fuente)
.font_size(*tamano)
.hint(*hint)
.fill_glyphs(glifos.iter().map(|g| vello_cpu::Glyph {
id: g.id,
x: g.x,
y: g.y,
}));
}
Orden::RectDifuso {
xf,
rect,
color,
radio,
desvio,
} => {
self.ctx.set_transform(*xf);
self.ctx.set_paint(*color);
self.ctx.reset_paint_transform();
self.ctx
.fill_blurred_rounded_rect(rect, *radio as f32, *desvio as f32);
}
}
}
/// Traduce un pincel de peniko al `PaintType` de vello_cpu.
///
/// Sólido y degradado son el mismo tipo de un lado y del otro. La variante
/// imagen no: peniko la lleva como bytes crudos con formato, vello_cpu como
/// un `Pixmap` premultiplicado. Un pincel-imagen dentro de un `fill` es raro
/// en Llimphi (las imágenes entran por `draw_image`), así que si la
/// conversión no cuadra se cae a transparente en vez de abortar el frame.
fn fijar_pincel(&mut self, pincel: &Brush, xf_pincel: Option<Affine>) {
// La transformación del pincel es la que posiciona un degradado dentro
// de la forma. vello la compone como `transform * brush_transform`, y
// vello_cpu hace EXACTAMENTE lo mismo con su `paint_transform`
// (`render.rs:336`), así que el mapeo es directo y no hay que
// pre-multiplicar nada a mano. Sin esto los degradados salen anclados al
// origen del mundo en vez de a la caja que los lleva.
match xf_pincel {
Some(t) => self.ctx.set_paint_transform(t),
None => self.ctx.reset_paint_transform(),
}
match pincel {
Brush::Solid(c) => self.ctx.set_paint(*c),
Brush::Gradient(g) => self.ctx.set_paint(g.clone()),
Brush::Image(img) => match pixmap_de_peniko(&img.image) {
Some(p) => self.ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(Arc::new(p)),
sampler: img.sampler,
}),
None => self
.ctx
.set_paint(vello_cpu::peniko::color::AlphaColor::<Srgb>::TRANSPARENT),
},
}
}
/// Abre una capa enmascarada por luminancia.
///
/// Es el único de los ocho métodos que vello_cpu no da hecho: su
/// `push_mask_layer` quiere una `Mask` construida sobre un pixmap del
/// TAMAÑO COMPLETO del lienzo, no una forma. Así que se rasteriza la forma
/// aparte —blanco sobre negro, en un contexto propio— y de ese pixmap sale
/// la máscara. Cuesta un lienzo extra; el compositor lo usa una sola vez
/// (`MaskMode::Luminance`), así que se paga cuando de verdad se pide.
fn abrir_mascara_luminancia(
&mut self,
regla: vello_cpu::peniko::Fill,
alfa: f32,
xf: Affine,
recorte: &Forma,
) {
let mut aparte = RenderContext::new_with(
self.ancho,
self.alto,
RenderSettings {
level: nivel_simd(),
num_threads: 0,
render_mode: RenderMode::OptimizeSpeed,
},
);
aparte.set_fill_rule(regla);
aparte.set_transform(xf);
aparte.set_paint(vello_cpu::peniko::color::AlphaColor::<Srgb>::WHITE);
match recorte {
Forma::Rect(r) => aparte.fill_rect(r),
otra => aparte.fill_path(&otra.a_camino()),
}
aparte.flush();
let mut lienzo = Pixmap::new(self.ancho, self.alto);
let mut recursos = Resources::new();
aparte.render_to_pixmap(&mut recursos, &mut lienzo);
let mascara = vello_cpu::Mask::new_luminance(&lienzo);
self.ctx
.push_layer(None, None, Some(alfa), Some(mascara), None);
}
}
/// El nivel SIMD con el que rasterizar.
///
/// Con `std` (Linux) se detecta en tiempo de ejecución y se aprovecha lo que la
/// máquina tenga. Sin `std` (wawa) se usa el camino escalar: `Level::new()` se
/// apoya en `is_x86_feature_detected!`, que no existe sin sistema operativo.
/// Que el escalar alcance no es un consuelo — el spike de W0 midió 909-5.000 fps
/// a 480x400 rasterizando así.
fn nivel_simd() -> Level {
#[cfg(feature = "std")]
{
Level::try_detect().unwrap_or_else(Level::fallback)
}
#[cfg(not(feature = "std"))]
{
Level::fallback()
}
}
/// Convierte los bytes crudos de una imagen de peniko en un `Pixmap`
/// premultiplicado, que es lo único que vello_cpu sabe muestrear.
///
/// Devuelve `None` si el blob no mide lo que el ancho/alto declaran — una
/// imagen mal formada no debe tumbar el fotograma entero.
fn pixmap_de_peniko(datos: &vello_cpu::peniko::ImageData) -> Option<Pixmap> {
let ancho = u16::try_from(datos.width).ok()?;
let alto = u16::try_from(datos.height).ok()?;
let pixeles = (datos.width as usize).checked_mul(datos.height as usize)?;
let crudo: &[u8] = datos.data.as_ref();
if crudo.len() < pixeles * 4 {
return None;
}
let mut salida: Vec<PremulRgba8> = Vec::with_capacity(pixeles);
let premultiplicada = datos.alpha_type == ImageAlphaType::AlphaPremultiplied;
for trozo in crudo.chunks_exact(4).take(pixeles) {
// peniko sólo define dos ordenamientos de canal, y ambos llegan acá.
let (r, g, b, a) = match datos.format {
ImageFormat::Rgba8 => (trozo[0], trozo[1], trozo[2], trozo[3]),
ImageFormat::Bgra8 => (trozo[2], trozo[1], trozo[0], trozo[3]),
_ => (trozo[0], trozo[1], trozo[2], trozo[3]),
};
salida.push(if premultiplicada {
PremulRgba8 { r, g, b, a }
} else {
// Premultiplicar con redondeo al más cercano: `+ 127` en vez de
// truncar. Truncar sesga TODO hacia oscuro y se nota como un halo
// sucio en los bordes suaves de los íconos.
let p = |c: u8| (((c as u32) * (a as u32) + 127) / 255) as u8;
PremulRgba8 {
r: p(r),
g: p(g),
b: p(b),
a,
}
});
}
Some(Pixmap::from_parts(salida, ancho, alto))
}
+345
View File
@@ -0,0 +1,345 @@
// =============================================================================
// pruebas :: certificación por PÍXELES, no por aserción
// -----------------------------------------------------------------------------
// Todo lo que se afirma acá se mide contando píxeles del pixmap resultante y
// comparando números — nada de mirar PNGs (regla 8 del repo: renderizar y
// mirar cuesta tokens; la evidencia puede ser texto). Cada test imprime lo que
// midió cuando falla, así que un rojo dice QUÉ salió y no sólo que salió mal.
// =============================================================================
use alloc::vec;
use vello_cpu::kurbo::{Affine, Circle, Rect, RoundedRect, Stroke};
use vello_cpu::peniko::color::{AlphaColor, PremulRgba8, Srgb};
use vello_cpu::peniko::{BlendMode, Fill};
use crate::escena::Escena;
use crate::lienzo::{volcar, FormatoPixel};
use crate::pintor::Pintor;
const ROJO: AlphaColor<Srgb> = AlphaColor::from_rgba8(255, 0, 0, 255);
const VERDE: AlphaColor<Srgb> = AlphaColor::from_rgba8(0, 255, 0, 255);
/// Pinta una escena sobre un lienzo de `w`x`h` y devuelve los píxeles.
fn pintar(w: u16, h: u16, escena: &Escena) -> vello_cpu::Pixmap {
let mut pintor = Pintor::new(w, h);
let mut lienzo = pintor.lienzo_nuevo();
pintor.pintar(escena, &mut lienzo);
lienzo
}
fn en(p: &vello_cpu::Pixmap, x: u16, y: u16) -> PremulRgba8 {
p.data()[y as usize * p.width() as usize + x as usize]
}
/// Cuántos píxeles no son totalmente transparentes.
fn cubiertos(p: &vello_cpu::Pixmap) -> usize {
p.data().iter().filter(|c| c.a > 0).count()
}
#[test]
fn rellenar_rectangulo_da_los_pixeles_exactos() {
let mut e = Escena::new();
e.fill(
Fill::NonZero,
Affine::IDENTITY,
ROJO,
None,
&Rect::new(10.0, 10.0, 30.0, 20.0),
);
let p = pintar(64, 64, &e);
// 20x10 = 200 píxeles exactos: bordes rectos y alineados a la grilla, así
// que no hay antialiasing que ensucie la cuenta.
assert_eq!(cubiertos(&p), 200, "área cubierta");
let dentro = en(&p, 15, 15);
assert_eq!(
(dentro.r, dentro.g, dentro.b, dentro.a),
(255, 0, 0, 255),
"el interior debe ser rojo opaco"
);
assert_eq!(en(&p, 5, 5).a, 0, "fuera del rect no se toca nada");
}
#[test]
fn la_esquina_redondeada_produce_antialiasing() {
let mut e = Escena::new();
e.fill(
Fill::NonZero,
Affine::IDENTITY,
ROJO,
None,
&RoundedRect::new(8.0, 8.0, 56.0, 56.0, 12.0),
);
let p = pintar(64, 64, &e);
// Un borde curvo rasterizado con AA deja píxeles a medio cubrir. Si el
// rasterizador cayera en un camino sin AA, este conteo sería CERO — que es
// justo la regresión que este test existe para atrapar.
let parciales = p.data().iter().filter(|c| c.a > 0 && c.a < 255).count();
assert!(
parciales > 20,
"esperaba antialiasing en las curvas, hubo {parciales} píxeles parciales"
);
// La esquina misma queda fuera del redondeo.
assert_eq!(en(&p, 8, 8).a, 0, "la esquina se recorta por el radio");
assert_eq!(en(&p, 32, 32).a, 255, "el centro está lleno");
}
#[test]
fn la_capa_de_recorte_corta_lo_que_sobra() {
let mut e = Escena::new();
// Capa recortada a la mitad izquierda...
e.push_layer(
Fill::NonZero,
BlendMode::default(),
1.0,
Affine::IDENTITY,
&Rect::new(0.0, 0.0, 32.0, 64.0),
);
// ...y adentro, un relleno que cubre TODO el lienzo.
e.fill(
Fill::NonZero,
Affine::IDENTITY,
ROJO,
None,
&Rect::new(0.0, 0.0, 64.0, 64.0),
);
e.pop_layer();
let p = pintar(64, 64, &e);
assert_eq!(cubiertos(&p), 32 * 64, "sólo debe sobrevivir la mitad izquierda");
assert_eq!(en(&p, 10, 32).a, 255, "izquierda pintada");
assert_eq!(en(&p, 50, 32).a, 0, "derecha recortada");
}
#[test]
fn la_capa_con_alfa_atenua_lo_de_adentro() {
let mut e = Escena::new();
e.push_layer(
Fill::NonZero,
BlendMode::default(),
0.5,
Affine::IDENTITY,
&Rect::new(0.0, 0.0, 64.0, 64.0),
);
e.fill(
Fill::NonZero,
Affine::IDENTITY,
ROJO,
None,
&Rect::new(0.0, 0.0, 64.0, 64.0),
);
e.pop_layer();
let p = pintar(64, 64, &e);
let c = en(&p, 32, 32);
assert!(
(120..=135).contains(&c.a),
"alfa 0,5 sobre opaco debería dar ~128, dio {}",
c.a
);
}
#[test]
fn append_traslada_la_escena_hija() {
// Hija: un cuadrado de 10x10 en el origen.
let mut hija = Escena::new();
hija.fill(
Fill::NonZero,
Affine::IDENTITY,
VERDE,
None,
&Rect::new(0.0, 0.0, 10.0, 10.0),
);
let mut madre = Escena::new();
madre.append(&hija, Some(Affine::translate((20.0, 30.0))));
let p = pintar(64, 64, &madre);
assert_eq!(cubiertos(&p), 100, "el área no cambia al trasladar");
assert_eq!(en(&p, 25, 35).a, 255, "el cuadrado viajó a (20,30)");
assert_eq!(en(&p, 5, 5).a, 0, "y ya no está en el origen");
}
#[test]
fn append_sin_transformacion_es_concatenar() {
let mut hija = Escena::new();
hija.fill(
Fill::NonZero,
Affine::IDENTITY,
VERDE,
None,
&Rect::new(0.0, 0.0, 10.0, 10.0),
);
let mut madre = Escena::new();
madre.fill(
Fill::NonZero,
Affine::IDENTITY,
ROJO,
None,
&Rect::new(20.0, 20.0, 30.0, 30.0),
);
madre.append(&hija, None);
assert_eq!(madre.len(), 2, "dos órdenes, sin envoltorios");
let p = pintar(64, 64, &madre);
assert_eq!(en(&p, 5, 5).g, 255, "la hija quedó donde estaba");
assert_eq!(en(&p, 25, 25).r, 255, "y la madre también");
}
#[test]
fn append_compone_la_transformacion_por_izquierda() {
// La hija ya viene escalada x2; la madre la traslada. El orden importa: si
// se compusiera al revés, la traslación saldría escalada al doble.
let mut hija = Escena::new();
hija.fill(
Fill::NonZero,
Affine::scale(2.0),
VERDE,
None,
&Rect::new(0.0, 0.0, 5.0, 5.0),
);
let mut madre = Escena::new();
madre.append(&hija, Some(Affine::translate((10.0, 10.0))));
let p = pintar(64, 64, &madre);
// 5x5 escalado x2 = 10x10, trasladado a (10,10) => ocupa [10,20).
assert_eq!(cubiertos(&p), 100, "10x10 tras el escalado");
assert_eq!(en(&p, 11, 11).a, 255, "arranca en (10,10)");
assert_eq!(en(&p, 21, 21).a, 0, "y termina en (20,20)");
}
#[test]
fn el_trazo_dibuja_contorno_y_no_relleno() {
let mut e = Escena::new();
e.stroke(
&Stroke::new(4.0),
Affine::IDENTITY,
ROJO,
None,
&Rect::new(16.0, 16.0, 48.0, 48.0),
);
let p = pintar(64, 64, &e);
assert_eq!(en(&p, 32, 32).a, 0, "el centro queda hueco");
assert_eq!(en(&p, 32, 16).a, 255, "el borde de arriba está trazado");
}
#[test]
fn el_rect_difuso_degrada_hacia_afuera() {
let mut e = Escena::new();
e.draw_blurred_rounded_rect(
Affine::IDENTITY,
Rect::new(20.0, 20.0, 44.0, 44.0),
ROJO,
6.0,
5.0,
);
let p = pintar(64, 64, &e);
let centro = en(&p, 32, 32).a;
let borde = en(&p, 32, 19).a;
let lejos = en(&p, 32, 4).a;
assert!(centro > 200, "el centro de la sombra es sólido ({centro})");
assert!(
borde > 0 && borde < centro,
"el borde difumina: centro={centro} borde={borde}"
);
assert!(lejos < borde, "y se apaga con la distancia ({lejos})");
}
#[test]
fn una_forma_arbitraria_se_aplana_a_camino() {
// Un círculo no es Rect ni RoundedRect: fuerza la rama `Forma::Camino`.
let mut e = Escena::new();
e.fill(
Fill::NonZero,
Affine::IDENTITY,
ROJO,
None,
&Circle::new((32.0, 32.0), 16.0),
);
let p = pintar(64, 64, &e);
// πr² = π*256 ≈ 804. Con AA el conteo de «algo cubierto» queda algo por
// encima; la banda es generosa a propósito, lo que se certifica es que
// rasterizó un disco y no un cuadrado (1024) ni nada (0).
let n = cubiertos(&p);
assert!(
(750..=950).contains(&n),
"un disco de r=16 debería cubrir ~804 px, cubrió {n}"
);
assert_eq!(en(&p, 32, 32).a, 255, "el centro del disco está lleno");
assert_eq!(en(&p, 2, 2).a, 0, "las esquinas no");
}
#[test]
fn reset_vacia_la_escena() {
let mut e = Escena::new();
e.fill(
Fill::NonZero,
Affine::IDENTITY,
ROJO,
None,
&Rect::new(0.0, 0.0, 10.0, 10.0),
);
assert_eq!(e.len(), 1);
e.reset();
assert!(e.is_empty(), "reset deja la escena vacía");
assert_eq!(cubiertos(&pintar(32, 32, &e)), 0, "y no pinta nada");
}
#[test]
fn el_volcado_respeta_stride_y_orden_de_canales() {
let mut e = Escena::new();
e.fill(
Fill::NonZero,
Affine::IDENTITY,
ROJO,
None,
&Rect::new(0.0, 0.0, 4.0, 4.0),
);
let p = pintar(4, 4, &e);
// Framebuffer más ancho que el pixmap: 8 px de stride para 4 de contenido.
// Es el caso real —el firmware alinea las filas— y el que produce la imagen
// en diagonal si uno confunde stride con ancho.
const STRIDE: usize = 8 * 4;
let mut fb = vec![0u8; STRIDE * 4];
let filas = volcar(&p, &mut fb, STRIDE, FormatoPixel::Bgra8);
assert_eq!(filas, 4, "cuatro filas volcadas");
// Primer píxel en BGRA: azul=0, verde=0, rojo=255.
assert_eq!(&fb[0..4], &[0, 0, 255, 255], "BGRA del rojo");
// La zona de relleno del stride (píxeles 4..8 de la fila 0) queda intacta.
assert_eq!(&fb[16..32], &[0u8; 16], "el relleno del stride no se pisa");
// Segunda fila arranca en STRIDE, no en 4*4.
assert_eq!(&fb[STRIDE..STRIDE + 4], &[0, 0, 255, 255], "fila 2 en su lugar");
// El mismo pixmap en RGBA invierte los dos extremos.
let mut fb2 = vec![0u8; STRIDE * 4];
volcar(&p, &mut fb2, STRIDE, FormatoPixel::Rgba8);
assert_eq!(&fb2[0..4], &[255, 0, 0, 255], "RGBA del rojo");
}
#[test]
fn el_volcado_recorta_en_vez_de_entrar_en_panico() {
let mut e = Escena::new();
e.fill(
Fill::NonZero,
Affine::IDENTITY,
ROJO,
None,
&Rect::new(0.0, 0.0, 16.0, 16.0),
);
let p = pintar(16, 16, &e);
// Framebuffer de 8x8 para un pixmap de 16x16: no entra ni a lo ancho ni a
// lo alto. Debe copiar la intersección y seguir viviendo.
const STRIDE: usize = 8 * 4;
let mut fb = vec![0u8; STRIDE * 8];
let filas = volcar(&p, &mut fb, STRIDE, FormatoPixel::Bgra8);
assert_eq!(filas, 8, "se copian las filas que entran");
assert_eq!(&fb[0..4], &[0, 0, 255, 255], "y lo copiado es correcto");
}
+17 -3
View File
@@ -13,14 +13,22 @@ name = "llimphi-gallery"
path = "src/main.rs"
[dependencies]
# La tanda nueva de widgets: la vitrina es su primer caller vivo.
llimphi-widget-accordion = { workspace = true }
llimphi-widget-stepper = { workspace = true }
llimphi-widget-form = { workspace = true }
llimphi-widget-datetime-picker = { workspace = true }
llimphi-widget-calendar = { workspace = true }
llimphi-widget-scroll = { workspace = true }
chrono = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-icons = { workspace = true }
llimphi-widget-wawa-mark = { workspace = true }
llimphi-widget-tooltip = { workspace = true }
llimphi-widget-spinner = { workspace = true }
llimphi-widget-spinner = { workspace = true, features = ["std"] }
llimphi-widget-progress = { workspace = true }
llimphi-widget-toast = { workspace = true }
llimphi-widget-toast = { workspace = true, features = ["std"] }
llimphi-widget-modal = { workspace = true }
llimphi-widget-empty = { workspace = true }
llimphi-widget-status-bar = { workspace = true }
@@ -31,10 +39,16 @@ llimphi-widget-segmented = { workspace = true }
llimphi-widget-breadcrumb = { workspace = true }
llimphi-widget-badge = { workspace = true }
llimphi-widget-avatar = { workspace = true }
llimphi-widget-skeleton = { workspace = true }
llimphi-widget-skeleton = { workspace = true, features = ["std"] }
llimphi-widget-field = { workspace = true }
llimphi-widget-panel = { workspace = true }
llimphi-widget-card = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-widget-menubar = { workspace = true }
app-bus = { workspace = true }
[dev-dependencies]
# El arnés headless: la vitrina es el primer caller vivo de la tanda nueva, así
# que acá se certifica que esos widgets están en el árbol Y responden — no
# alcanza con que compile.
llimphi-test = { path = "../llimphi-test", version = "0.1.0" }
+524 -18
View File
@@ -54,6 +54,20 @@ use llimphi_widget_switch::{switch_view, SwitchPalette};
use llimphi_widget_toast::{toast_stack_view, Toast};
use llimphi_widget_tooltip::{tooltip_view, Side, TooltipPalette, TooltipSpec};
use llimphi_widget_wawa_mark::{wawa_mark_view, WawaMarkPalette};
use chrono::NaiveDate;
use llimphi_widget_accordion::{accordion_view, alternar, AccordionPalette, AccordionSpec, Modo, Seccion};
use llimphi_widget_calendar::{CalendarOpciones, Limites, WeekStart};
use llimphi_widget_datetime_picker::{
date_picker_popup, date_picker_trigger, time_picker_popup, time_picker_trigger, Acciones,
DatePopup, DateTrigger, FormatoFecha, FormatoHora, Hora, PickerPalette, TimePopup, TimeTrigger,
};
use llimphi_widget_form::{
campo_view, resumen_errores_view, CampoDef, EstadoForm, FormPalette, Formulario, Regla,
};
use llimphi_widget_scroll::{clamp_offset, cuerpo_scrolleable, AltoVisible, DesplazamientoV, ScrollPalette};
use llimphi_widget_stepper::{
puede_ir_a, siguiente, stepper_view, EstadoPaso, Paso, StepperPalette, StepperSpec,
};
#[derive(Clone)]
enum Msg {
@@ -77,6 +91,38 @@ enum Msg {
MenuOpen(Option<usize>),
/// Comando elegido en la barra principal (id `menu.<verbo>`).
MenuCommand(String),
// --- Widgets de la tanda nueva ---
/// Rueda sobre una columna: `(cuál, delta px)`.
ColScroll(usize, f32),
/// Cabecera del acordeón.
Plegar(usize),
/// Ir a un paso del asistente.
IrAPaso(usize),
/// El campo del formulario perdió el foco (así se destapa su error).
CampoBlur(CampoForm),
/// Se intentó enviar el formulario: desde acá se muestran todos.
Enviar,
AbrirFecha,
AbrirHora,
ElegirFecha(NaiveDate),
LimpiarFecha,
ElegirHora(Hora),
VerMes(i32, u32),
CerrarPopup,
}
/// Los campos del formulario de muestra.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CampoForm {
Nombre,
Correo,
}
/// Qué popup de picker está abierto.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Popup {
Fecha,
Hora,
}
struct Model {
@@ -97,6 +143,21 @@ struct Model {
menu_last_pick: Option<String>,
/// Índice del menú raíz de la barra principal abierto. `None` = ninguno.
menubar_open: Option<usize>,
// --- Widgets de la tanda nueva ---
/// Desplazamiento y alto medido de cada columna (izq/centro/der).
col_offset: [f32; 3],
col_alto: [AltoVisible; 3],
/// Secciones abiertas del acordeón.
plegables: Vec<usize>,
/// Paso actual del asistente y cuáles quedaron resueltos.
paso: usize,
pasos_hechos: [bool; 3],
/// Estado de «cuándo mostrar» del formulario de muestra.
form_estado: EstadoForm<CampoForm>,
fecha: Option<NaiveDate>,
hora: Option<Hora>,
cal_view: (i32, u32),
popup: Option<Popup>,
}
struct Gallery;
@@ -131,6 +192,16 @@ impl App for Gallery {
menu_active: usize::MAX,
menu_last_pick: None,
menubar_open: None,
col_offset: [0.0; 3],
col_alto: Default::default(),
plegables: vec![0],
paso: 1,
pasos_hechos: [true, false, false],
form_estado: EstadoForm::nuevo(),
fecha: None,
hora: None,
cal_view: (2026, 8),
popup: None,
}
}
@@ -226,6 +297,44 @@ impl App for Gallery {
_ => {}
}
}
// --- Widgets de la tanda nueva ---
Msg::ColScroll(col, d) => {
let alto = [ALTO_COL_IZQ, ALTO_COL_CEN, ALTO_COL_DER][col.min(2)];
let i = col.min(2);
m.col_offset[i] = clamp_offset(m.col_offset[i] + d, alto, m.col_alto[i].px());
}
Msg::Plegar(i) => m.plegables = alternar(&m.plegables, i, Modo::Una),
Msg::IrAPaso(i) => {
if puede_ir_a(&pasos_de(&m), m.paso, i) {
m.paso = i;
}
}
Msg::CampoBlur(id) => m.form_estado.tocar(id),
Msg::Enviar => {
// El botón del pie del asistente: valida, marca el paso como
// resuelto y avanza. Es el mismo camino que en una app real.
m.form_estado.enviar();
if formulario().validar(&valores_form()).es_valido() {
m.pasos_hechos[m.paso.min(2)] = true;
let ps = pasos_de(&m);
if let Some(sig) = siguiente(&ps, m.paso) {
m.paso = sig;
}
}
}
Msg::AbrirFecha => m.popup = Some(Popup::Fecha),
Msg::AbrirHora => m.popup = Some(Popup::Hora),
Msg::CerrarPopup => m.popup = None,
Msg::ElegirFecha(d) => {
m.fecha = Some(d);
m.popup = None;
}
Msg::LimpiarFecha => {
m.fecha = None;
m.popup = None;
}
Msg::ElegirHora(h) => m.hora = Some(h),
Msg::VerMes(y, mo) => m.cal_view = (y, mo),
}
m
}
@@ -304,6 +413,11 @@ impl App for Gallery {
fn view_overlay(model: &Self::Model) -> Option<View<Self::Msg>> {
let theme = Theme::dark();
// Los popups de los pickers van primero: son los únicos que se abren
// desde un control de la vitrina y no desde el menú.
if let Some(p) = model.popup {
return Some(popup_pane(model, p, &theme));
}
// Prioridad: modal > shortcuts > toasts.
if model.modal_open {
return Some(modal_view(ModalSpec {
@@ -434,7 +548,7 @@ fn column_left(model: &Model, theme: &Theme) -> View<Msg> {
width: length(16.0_f32),
height: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
..Default::default()
})
.children(vec![
@@ -479,7 +593,7 @@ fn column_left(model: &Model, theme: &Theme) -> View<Msg> {
)]),
);
panel_view(children, theme)
panel_scrolleable(children, ALTO_COL_IZQ, 0, model, theme)
}
fn column_center(model: &Model, theme: &Theme) -> View<Msg> {
@@ -543,10 +657,10 @@ fn column_center(model: &Model, theme: &Theme) -> View<Msg> {
children.push(section_title("Acciones"));
children.push(button_row(theme));
panel_view(children, theme)
panel_scrolleable(children, ALTO_COL_CEN, 1, model, theme)
}
fn column_right(_model: &Model, theme: &Theme) -> View<Msg> {
fn column_right(model: &Model, theme: &Theme) -> View<Msg> {
let mut children: Vec<View<Msg>> = Vec::new();
children.push(section_title("Identidades"));
@@ -562,7 +676,7 @@ fn column_right(_model: &Model, theme: &Theme) -> View<Msg> {
width: length(8.0_f32),
height: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
..Default::default()
})
.children(vec![
@@ -586,7 +700,7 @@ fn column_right(_model: &Model, theme: &Theme) -> View<Msg> {
width: length(10.0_f32),
height: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
..Default::default()
})
.children(vec![
@@ -612,7 +726,7 @@ fn column_right(_model: &Model, theme: &Theme) -> View<Msg> {
width: length(16.0_f32),
height: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
..Default::default()
})
.children(vec![
@@ -686,8 +800,8 @@ fn column_right(_model: &Model, theme: &Theme) -> View<Msg> {
height: length(32.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
@@ -711,7 +825,20 @@ fn column_right(_model: &Model, theme: &Theme) -> View<Msg> {
children.push(section_title("Iconografía"));
children.push(icon_grid(theme));
panel_view(children, theme)
// ── La tanda nueva de widgets, en su primer caller vivo ────────────
children.push(section_title("Plegables"));
children.push(bloque_acordeon(model, theme));
children.push(section_title("Asistente"));
children.push(bloque_asistente(model, theme));
children.push(section_title("Formulario validado"));
children.push(bloque_formulario(model, theme));
children.push(section_title("Fecha y hora"));
children.push(bloque_pickers(model, theme));
panel_scrolleable(children, ALTO_COL_DER, 2, model, theme)
}
// ---------------------------------------------------------------------
@@ -746,6 +873,44 @@ fn section_title(text: &str) -> View<Msg> {
)
}
/// [`panel_view`] con **scroll real** del contenido: la vitrina crece con cada
/// widget nuevo y hasta acá lo que no entraba se recortaba y punto (el panel
/// tiene `clip`). `col` es cuál de las tres columnas, para que cada una lleve
/// su propio desplazamiento.
fn panel_scrolleable(
children: Vec<View<Msg>>,
alto_contenido: f32,
col: usize,
model: &Model,
theme: &Theme,
) -> View<Msg> {
let cuerpo = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: length(alto_contenido),
},
gap: Size {
width: length(0.0_f32),
height: length(10.0_f32),
},
..Default::default()
})
.children(children);
let area = cuerpo_scrolleable(
cuerpo,
alto_contenido,
DesplazamientoV {
offset: model.col_offset[col],
viewport_len: model.col_alto[col].px(),
on_scroll: Arc::new(move |d: f32| Msg::ColScroll(col, d)),
},
Some(model.col_alto[col].clone()),
&ScrollPalette::from_theme(theme),
);
panel_view(vec![area], theme)
}
fn panel_view(children: Vec<View<Msg>>, theme: &Theme) -> View<Msg> {
let style = PanelStyle::from_theme(theme);
View::new(Style {
@@ -781,8 +946,8 @@ fn switch_row(label: &str, value: bool, msg: Msg, theme: &Theme) -> View<Msg> {
width: percent(1.0_f32),
height: length(28.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::SPACE_BETWEEN),
..Default::default()
})
.children(vec![
@@ -792,7 +957,7 @@ fn switch_row(label: &str, value: bool, msg: Msg, theme: &Theme) -> View<Msg> {
height: percent(1.0_f32),
},
flex_grow: 1.0,
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
..Default::default()
})
.text_aligned(label.to_string(), 12.0, theme.fg_text, Alignment::Start),
@@ -812,7 +977,7 @@ fn fake_text_input(text: &str, theme: &Theme) -> View<Msg> {
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
align_items: Some(AlignItems::CENTER),
flex_shrink: 0.0,
..Default::default()
})
@@ -848,8 +1013,8 @@ fn btn(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
width: length(w),
height: length(32.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
flex_shrink: 0.0,
..Default::default()
})
@@ -926,8 +1091,8 @@ fn wawa_frame(side: f32) -> View<Msg> {
width: length(side),
height: length(side),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
flex_shrink: 0.0,
..Default::default()
})
@@ -962,6 +1127,347 @@ fn demo_tooltip(viewport: (f32, f32), text: &str, theme: &Theme) -> View<Msg> {
})
}
// ---------------------------------------------------------------------
// La tanda nueva: acordeón · asistente · formulario validado · pickers
// ---------------------------------------------------------------------
/// Alto del contenido de cada columna. Es lo que el scroll necesita saber para
/// clampear; se estima por lo que hay adentro (la vitrina es fija, no crece en
/// caliente).
const ALTO_COL_IZQ: f32 = 1180.0;
const ALTO_COL_CEN: f32 = 1120.0;
const ALTO_COL_DER: f32 = 1500.0;
/// El «hoy» de la vitrina: fijo, para que una captura de pantalla no cambie de
/// día entre corridas.
const HOY: NaiveDate = match NaiveDate::from_ymd_opt(2026, 8, 5) {
Some(d) => d,
None => panic!("fecha fija válida"),
};
fn bloque_acordeon(model: &Model, theme: &Theme) -> View<Msg> {
let t1 = *theme;
let t2 = *theme;
accordion_view(AccordionSpec {
secciones: vec![
Seccion::new("Qué es", move || {
text_line("Secciones plegables; el cuerpo cerrado ni se construye.", 11.5, t1.fg_muted)
})
.con_resumen("expansion panel"),
Seccion::new("Cómo se usa", move || {
text_line("alternar(&abiertas, i, Modo::Una) en el update.", 11.5, t2.fg_muted)
})
.con_resumen("una línea"),
Seccion::new("Deshabilitada", || text_line("no debería verse", 11.5, Color::WHITE))
.habilitada(false),
],
abiertas: &model.plegables,
modo: Modo::Una,
on_toggle: Msg::Plegar,
palette: AccordionPalette::from_theme(theme),
})
}
/// Los tres pasos del asistente de muestra.
fn pasos_de(m: &Model) -> Vec<Paso> {
["Datos", "Pago", "Listo"]
.iter()
.enumerate()
.map(|(i, t)| {
let estado = if m.pasos_hechos[i] {
EstadoPaso::Hecho
} else if i == m.paso {
EstadoPaso::Actual
} else {
EstadoPaso::Pendiente
};
Paso::new(*t, estado)
})
.collect()
}
fn bloque_asistente(model: &Model, theme: &Theme) -> View<Msg> {
let pasos = pasos_de(model);
let cuerpo = text_line(
match model.paso {
0 => "Paso 1 · los datos ya están cargados.",
1 => "Paso 2 · «Siguiente» valida el formulario de abajo.",
_ => "Listo. El pie dice Finalizar, no Siguiente.",
},
11.5,
theme.fg_muted,
);
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(160.0_f32) },
..Default::default()
})
.children(vec![stepper_view(StepperSpec {
pasos: &pasos,
actual: model.paso,
cuerpo,
on_ir: Msg::IrAPaso,
on_finalizar: Some(Msg::Enviar),
palette: StepperPalette::from_theme(theme),
})])
}
/// El formulario de muestra. Las reglas son la fuente del asterisco y del
/// error; la vista no los repite.
fn formulario() -> Formulario<CampoForm> {
Formulario::new(vec![
CampoDef::new(CampoForm::Nombre, "Nombre")
.regla(Regla::Requerido)
.ayuda("Como figura en el documento"),
CampoDef::new(CampoForm::Correo, "Correo")
.reglas(vec![Regla::Requerido, Regla::Correo]),
])
}
/// Valores fijos de la vitrina: el nombre va cargado y el correo, mal escrito
/// —así se ve el error de formato sin tener que tipear.
fn valores_form() -> Vec<(CampoForm, &'static str)> {
vec![(CampoForm::Nombre, "Sergio"), (CampoForm::Correo, "sergio(arroba)gioser")]
}
fn bloque_formulario(model: &Model, theme: &Theme) -> View<Msg> {
let form = formulario();
let errores = form.validar(&valores_form());
let visibles = model.form_estado.visibles(&errores);
let pal = FormPalette::from_theme(theme);
let mut hijos: Vec<View<Msg>> = Vec::new();
hijos.extend(resumen_errores_view(&visibles, |_| Msg::Enviar, &pal));
for (id, valor) in valores_form() {
// El control es un campo de muestra; el click marca el campo como
// «tocado», que es lo que destapa SU error y no el de los otros.
let control = fake_text_input(valor, theme).on_click(Msg::CampoBlur(id));
hijos.push(campo_view(&form, id, control, &visibles, &pal));
}
hijos.push(text_line(
if model.form_estado.se_intento_enviar() {
"Tras «Finalizar» se muestran todos los errores."
} else {
"Clickeá un campo (blur) o «Finalizar» para ver los errores."
},
10.5,
theme.fg_muted,
));
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: length(230.0_f32) },
gap: Size { width: length(0.0_f32), height: length(6.0_f32) },
..Default::default()
})
.children(hijos)
}
fn bloque_pickers(model: &Model, theme: &Theme) -> View<Msg> {
let pal = PickerPalette::from_theme(theme);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(34.0_f32) },
gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
..Default::default()
})
.children(vec![
date_picker_trigger(DateTrigger {
valor: model.fecha,
formato: FormatoFecha::DiaMesAnio,
placeholder: "Elegí una fecha".into(),
enabled: true,
on_open: Msg::AbrirFecha,
palette: pal,
}),
time_picker_trigger(TimeTrigger {
valor: model.hora,
formato: FormatoHora::H24,
placeholder: "Elegí una hora".into(),
enabled: true,
on_open: Msg::AbrirHora,
palette: pal,
}),
])
}
/// El popup abierto, montado en el overlay como manda el widget (un panel con
/// scrim no puede colgar del árbol de una columna).
fn popup_pane(model: &Model, cual: Popup, theme: &Theme) -> View<Msg> {
let pal = PickerPalette::from_theme(theme);
match cual {
Popup::Fecha => date_picker_popup(DatePopup {
anchor: (model.viewport.0 * 0.62, model.viewport.1 * 0.35),
viewport: model.viewport,
valor: model.fecha,
view: model.cal_view,
today: Some(HOY),
week_start: WeekStart::Monday,
opciones: CalendarOpciones {
// Una fecha de muestra no se agenda para ayer.
limites: Limites::desde(HOY),
navegacion_anual: true,
..Default::default()
},
on_pick: Arc::new(Msg::ElegirFecha),
on_view_change: Arc::new(Msg::VerMes),
on_dismiss: Msg::CerrarPopup,
on_clear: Some(Msg::LimpiarFecha),
acciones: Acciones::hoy_y_limpiar(),
palette: pal,
theme: *theme,
}),
Popup::Hora => time_picker_popup(TimePopup {
anchor: (model.viewport.0 * 0.62, model.viewport.1 * 0.40),
viewport: model.viewport,
valor: model.hora,
formato: FormatoHora::H24,
paso_minutos: 15,
on_pick: Arc::new(Msg::ElegirHora),
on_dismiss: Msg::CerrarPopup,
palette: pal,
}),
}
}
fn main() {
// `--censo`: mide el reparto `ViewRare` (Bloque 21, palanca 2b) contra este
// árbol real, sin abrir ventana ni tocar la GPU. Ver `llimphi_ui::censo`.
if std::env::args().any(|a| a == "--censo") {
llimphi_ui::censo_app::<Gallery>();
return;
}
llimphi_ui::run::<Gallery>();
}
// ---------------------------------------------------------------------
// La vitrina como CALLER: que los widgets estén en el árbol y respondan
// ---------------------------------------------------------------------
//
// Un `cargo check` verde sólo dice que compila. Lo que se prueba acá es que la
// tanda nueva quedó de verdad enchufada: que sus nodos existen en el árbol que
// la gallery pinta, que los clicks emiten el `Msg` que el `update` espera, y
// que el ciclo completo (click → update → vista nueva) cambia lo que se ve.
#[cfg(test)]
mod tests {
use super::*;
use llimphi_test::query::{Sel, Ui};
use llimphi_ui::{Handle, Role};
fn modelo() -> Model {
Gallery::init(&Handle::<Msg>::for_test_with(|_| {}))
}
fn vista(m: &Model) -> Ui<Msg> {
Ui::new(Gallery::view(m), 1280.0, 800.0)
}
fn aplicar(m: Model, msg: Msg) -> Model {
Gallery::update(m, msg, &Handle::<Msg>::for_test_with(|_| {}))
}
#[test]
fn el_acordeon_esta_en_la_vitrina_y_pliega() {
let m = modelo();
let ui = vista(&m);
let cab = ui.get(Sel::new().role(Role::Button).label("Cómo se usa"));
assert_eq!(cab.is_expanded(), Some(false), "{}", ui.dump());
// Clickear la cabecera abre ESA y cierra la que estaba (Modo::Una).
let m = aplicar(m, cab.click().expect("la cabecera emite su Msg"));
let ui = vista(&m);
assert_eq!(
ui.get(Sel::new().role(Role::Button).label("Cómo se usa")).is_expanded(),
Some(true)
);
assert_eq!(
ui.get(Sel::new().role(Role::Button).label("Qué es")).is_expanded(),
Some(false),
"el modo acordeón tiene que haber cerrado la otra"
);
}
#[test]
fn el_asistente_muestra_sus_pasos_con_estado() {
let ui = vista(&modelo());
assert!(ui.find("Paso 1 de 3: Datos (hecho)").is_some(), "{}", ui.dump());
assert!(ui.find("Paso 2 de 3: Pago (en curso)").is_some());
assert!(ui.find("Paso 3 de 3: Listo (pendiente)").is_some());
}
#[test]
fn el_formulario_no_grita_hasta_que_se_intenta_enviar() {
let m = modelo();
// Recién abierto: el correo de muestra está mal, pero no se dice.
assert!(vista(&m).find("Correo no parece un correo").is_none());
// «Finalizar» del asistente = intentar enviar: ahí sí.
let m = aplicar(m, Msg::Enviar);
let ui = vista(&m);
assert!(ui.find("Correo no parece un correo").is_some(), "{}", ui.dump());
// Y el resumen se anuncia como alerta, que es el punto de entrada.
assert_eq!(ui.get(Role::Alert).name(), Some("Hay 1 error que corregir"));
}
#[test]
fn tocar_un_campo_destapa_solo_su_error() {
let m = aplicar(modelo(), Msg::CampoBlur(CampoForm::Correo));
assert!(vista(&m).find("Correo no parece un correo").is_some());
// El nombre está bien cargado, así que no hay nada más que decir.
assert_eq!(vista(&m).find_all(Sel::new().role(Role::Alert)).len(), 1);
}
#[test]
fn los_pickers_abren_su_popup_y_lo_que_se_elige_vuelve_al_disparador() {
let m = modelo();
let ui = vista(&m);
let campo = ui.get(Sel::new().role(Role::Button).label("Elegir fecha"));
let m = aplicar(m, campo.click().expect("el disparador emite"));
assert_eq!(m.popup, Some(Popup::Fecha));
// El popup vive en el overlay, no en la vista.
let overlay = Gallery::view_overlay(&m).expect("hay popup");
let ov = Ui::new(overlay, 1280.0, 800.0);
let dia = ov.get(Sel::new().role(Role::Button).label("14 de agosto de 2026"));
let m = aplicar(m, dia.click().expect("el día emite su fecha"));
// Se cerró y el disparador ahora muestra la fecha elegida.
assert!(m.popup.is_none());
assert!(vista(&m).find("14/08/2026").is_some(), "{}", vista(&m).dump());
}
#[test]
fn el_limite_del_calendario_llega_al_popup_de_la_vitrina() {
// La vitrina pide `Limites::desde(HOY)`: ayer no se puede elegir.
let m = aplicar(modelo(), Msg::AbrirFecha);
let ov = Ui::new(Gallery::view_overlay(&m).expect("hay popup"), 1280.0, 800.0);
let ayer = ov.get(Sel::new().role(Role::Button).label("4 de agosto de 2026"));
assert!(!ayer.is_enabled(), "{}", ov.dump());
}
#[test]
fn las_columnas_scrollean_de_verdad() {
// Antes las columnas recortaban y lo que no entraba no se alcanzaba.
// Se mide sobre la cabecera del acordeón —que sí lleva nombre
// accesible— y no sobre el rótulo de sección, que es texto decorativo
// y no está en el árbol semántico.
let ancla = |m: &Model| {
vista(m)
.get(Sel::new().role(Role::Button).label("Qué es"))
.rect()
.y
};
let m = modelo();
// El alto visible lo publica el widget al PINTAR, y este test no pinta.
// Sin ese dato el scroll se queda en cero a propósito (`offset_efectivo`:
// mejor un cuadro desde arriba que un salto a un lugar que todavía no se
// puede validar), así que acá se deja el alto que un cuadro real ya
// habría dejado. Ese es el frame de latencia del diseño, dicho en un
// test en vez de en un comentario.
m.col_alto[2].escribir(620.0);
let y_antes = ancla(&m);
let m = aplicar(m, Msg::ColScroll(2, 120.0));
let y_despues = ancla(&m);
assert!(
(y_antes - y_despues - 120.0).abs() < 0.5,
"la columna tenía que correrse 120 px: {y_antes} → {y_despues}"
);
}
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "llimphi-glifos"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-glifos — atlas de cobertura de glifos para el camino GPU-directo. Rasteriza cada glifo una sola vez a un mapa de 8 bits, lo empaqueta en una textura por estantes y devuelve el rect UV + la colocación. Es lo que le faltaba a `llimphi_raster::GpuBatch` para poder pintar texto: el batcher instanciado sabe de quads, no de contornos."
[dependencies]
# parley DIRECTO y con la MISMA declaración que `llimphi-text`: los dos crates
# tienen que ver el mismo `parley::Layout`, y una versión suelta acá metería dos
# parley en el grafo — el error más desorientador posible («expected Layout,
# found parley::Layout»). Sin defaults por la misma razón que allá: su
# `default = ["system"]` arrastra fontconfig/objc2/windows por fontique.
#
# Con `std` porque este crate es de anfitrión: rasteriza para una GPU, que en
# wawa no existe. Sin él, el skrifa que parley trae por dentro queda `no_std` y
# no compila (le faltan `sqrt`/`hypot`/`round` de f32) — el modo `libm` es para
# el camino bare-metal, y éste no lo es.
parley = { version = "0.6", default-features = false, features = ["std"] }
# swash con `scale` + `render`, que es lo único que este crate agrega al árbol.
# parley ya lo trae —lo usa para shapear— pero con `default-features = false`,
# así que el *rasterizador* no está enlazado hasta que alguien lo pida. Este
# crate es quien lo pide, y ésa es la razón de que sea un crate aparte y no un
# módulo de `llimphi-text`: nadie más quiere `yazi` ni `zeno` en su binario.
swash = { version = "0.2", default-features = false, features = ["std", "scale", "render"] }
+731
View File
@@ -0,0 +1,731 @@
//! **Atlas de cobertura de glifos** — lo que le faltaba al camino GPU-directo
//! para poder pintar texto.
//!
//! `llimphi_raster::GpuBatch` sabe emitir cientos de miles de rects, discos,
//! líneas y triángulos en una draw call por tipo. Lo que no sabe es qué forma
//! tiene una «a»: el batcher instanciado habla de quads, no de contornos. Por
//! eso el path directo estaba fichado como «sin texto» en
//! `PLAN-MOTOR-PROFUNDO.md` §4a — y era el último hueco real de esa iniciativa.
//!
//! La solución es la de siempre en un rasterizador de texto por GPU: **cada
//! glifo se rasteriza una sola vez** a un mapa de cobertura de 8 bits, se
//! empaqueta en una textura grande, y de ahí en más pintar texto es pintar
//! quads con una UV. Un renglón de 80 caracteres pasa de 80 contornos rellenos
//! a 80 instancias de 48 B.
//!
//! ## Por qué un crate aparte y no un módulo de `llimphi-text`
//!
//! Porque agrega `swash` con sus features `scale` + `render` (y detrás, `yazi`
//! y `zeno`). parley ya trae swash —lo usa para shapear— pero con
//! `default-features = false`, así que el **rasterizador** no está enlazado
//! hasta que alguien lo pida. Quien no pinte texto por GPU-directo no tiene por
//! qué cargarlo. Y `llimphi-text` está en ~1.900 LOC, en el borde del umbral de
//! split de la Regla 1 del repo.
//!
//! ## Lo que este crate NO hace, dicho de frente
//!
//! - **No shapea.** Recibe un `parley::Layout` ya resuelto. Quién lo arma
//! —`llimphi_text::Typesetter`— sigue siendo el mismo de siempre, así que el
//! texto por GPU-directo y el texto por vello parten del **mismo** layout: si
//! difieren, difieren en el rasterizado y en nada más.
//! - **No pinta emoji ni glifos de color.** El atlas es de un canal
//! (`Alpha`/R8): una carita de color no entra ahí. Sólo se piden contornos
//! (`Source::Outline`). Un glifo de color pediría un atlas RGBA y un pipeline
//! propio, y meterlos «por las dudas» sería construir sin caller.
//! - **No hintea.** Es deliberado y no una simplificación: vello rasteriza el
//! contorno crudo, y si acá se hinteara, el mismo texto saldría con métricas
//! distintas por cada camino. La comparación entre los dos —que es cómo se
//! certifica esto— dejaría de significar algo.
//! - **No crece ni desaloja.** El atlas es de tamaño fijo; lleno, devuelve
//! `None` y cuenta el fallo. Una política de desalojo sin una app que la
//! presione se diseña a ciegas; lo honesto es que el número esté a la vista
//! (`AtlasGlifos::fallos`) y que se decida cuando alguien lo choque.
use std::collections::HashMap;
use std::hash::{BuildHasherDefault, Hasher};
pub use swash;
/// Hasher barato para las claves de glifo, en vez del SipHash de `std`.
///
/// **No es una micro-optimización a ciegas: sale de una medición, y la
/// medición dice que ayuda pero no alcanza.** El bench (`llimphi-gpu-bench`,
/// sección «Texto») separa *preparar* —armar la escena de vello contra armar
/// los quads— del total, y en esa mitad el atlas sale **más lento** que vello
/// aunque gane 3-5× en el total. Con ~12.000 glifos por cuadro se hacen 12.000
/// búsquedas, y ahí el hash deja de ser gratis.
///
/// A/B en la misma máquina (Iris Xe, 12.096 glifos, 60 muestras, 3 corridas):
///
/// | hasher | preparar |
/// |---|---:|
/// | SipHash (el de `std`) | ~3,4 ms |
/// | éste | ~2,5 ms |
/// | *vello, para comparar* | *~1,9 ms* |
///
/// O sea **27 %**, pero sin dar vuelta el signo: preparar sigue costando más
/// que por vello, que lo único que hace por glifo es apilar id y posición.
/// Queda anotado como lo que es: una mejora medida, no la solución.
///
/// Es el algoritmo de rustc (FxHash): multiplicar, rotar, xor. No sirve para
/// entradas hostiles —no hay resistencia a colisiones a propósito— y acá eso da
/// igual: las claves las genera el shaper, no la red.
#[derive(Default)]
pub struct HasherGlifo(u64);
impl Hasher for HasherGlifo {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for b in bytes {
self.agregar(*b as u64);
}
}
fn write_u8(&mut self, v: u8) {
self.agregar(v as u64);
}
fn write_u16(&mut self, v: u16) {
self.agregar(v as u64);
}
fn write_u32(&mut self, v: u32) {
self.agregar(v as u64);
}
fn write_i32(&mut self, v: i32) {
self.agregar(v as u32 as u64);
}
fn write_u64(&mut self, v: u64) {
self.agregar(v);
}
fn write_usize(&mut self, v: usize) {
self.agregar(v as u64);
}
}
impl HasherGlifo {
#[inline]
fn agregar(&mut self, v: u64) {
const SEMILLA: u64 = 0x51_7c_c1_b7_27_22_0a_95;
self.0 = (self.0.rotate_left(5) ^ v).wrapping_mul(SEMILLA);
}
}
type MapaGlifos = HashMap<ClaveGlifo, Option<Colocacion>, BuildHasherDefault<HasherGlifo>>;
/// Cuántas posiciones subpíxel se distinguen por eje. El glifo se rasteriza una
/// vez por cada fracción, así que la misma «a» a 14 px puede ocupar hasta
/// `SUBPIXELES²` entradas del atlas.
///
/// Cuatro por eje —y no una— porque el texto no cae en la grilla: parley da
/// posiciones fraccionarias, y redondearlas corre cada glifo hasta medio píxel.
/// En un renglón eso se ve como espaciado irregular. En los dos ejes y no sólo
/// en X porque la línea base también es fraccionaria, y ahí el error no se
/// reparte: **corre el renglón entero**.
///
/// En la práctica el costo es mucho menor que el peor caso: un texto normal
/// tiene una sola fracción de línea base por renglón.
pub const SUBPIXELES: u32 = 4;
/// Píxeles de aire entre glifos vecinos dentro del atlas. Sin esto, el filtrado
/// del sampler puede traer un pedazo del glifo de al lado.
const AIRE: u32 = 1;
/// Lo que identifica a un glifo ya rasterizado. Dos pedidos con la misma clave
/// dan el mismo mapa de cobertura, y por eso se puede cachear.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ClaveGlifo {
/// Identidad del archivo de fuente. Es el `id` del `Blob` que trae parley: un
/// contador global asignado al construirlo, O(1) y estable entre clones —
/// el mismo truco que usa el caché de boundaries del compositor. Dos blobs
/// con bytes idénticos reciben ids distintos, así que a lo sumo se
/// rasteriza de más; nunca se devuelve el glifo equivocado.
pub fuente: u64,
/// Índice dentro de una colección (`.ttc`). 0 para un archivo suelto.
pub indice: u32,
/// Id del glifo dentro de la fuente (ya shapeado, no un carácter).
pub glifo: u16,
/// Tamaño en px × 64, redondeado. Cuantizar evita que un zoom continuo
/// llene el atlas con una entrada por frame.
pub tam_64: u32,
/// Fracción subpíxel: `sx + sy * SUBPIXELES`, con cada una en `0..SUBPIXELES`.
pub subpx: u8,
/// Negrita sintética (la fuente no tiene corte bold y el shaper lo pidió).
pub engrosado: bool,
/// Itálica sintética, en centésimas de grado de inclinación.
pub inclinacion_c: i32,
}
impl ClaveGlifo {
/// El caso simple: un glifo suelto de una fuente suelta, en posición
/// entera y sin síntesis. Es lo que pide quien **no** viene de un shaper —
/// una grilla de terminal, que mapea `char` → glifo por charmap y coloca
/// todo en celdas enteras.
pub fn de_glifo(glifo: u16, tam_px: f32) -> Self {
Self {
fuente: 0,
indice: 0,
glifo,
tam_64: (tam_px * 64.0).round().max(1.0) as u32,
subpx: 0,
engrosado: false,
inclinacion_c: 0,
}
}
}
/// Un glifo rasterizado **suelto**: la cobertura y dónde colocarla, sin atlas
/// de por medio.
///
/// Existe para quien empaqueta a su manera. `llimphi_widget_terminal::GlyphAtlas`
/// es el caso: una grilla de celdas iguales es la estructura correcta para un
/// terminal —y parte de por qué es rápido— pero el **rasterizador** no tiene por
/// qué ser otro. Antes lo era (`fontdue`), y llimphi cargaba dos.
#[derive(Debug, Clone, PartialEq)]
pub struct MapaGlifo {
pub ancho: u32,
pub alto: u32,
/// Igual que en [`Colocacion`]: `izq` hacia la derecha, `arriba` hacia
/// **arriba** (convención OpenType).
pub izq: i32,
pub arriba: i32,
/// `ancho × alto` bytes, fila por fila.
pub cobertura: Vec<u8>,
}
/// El rasterizador de glifos, sin el atlas.
///
/// Guarda el `ScaleContext` de swash, que cachea el trabajo de abrir la fuente
/// entre pedidos — por eso conviene tener uno vivo y no crearlo por glifo.
#[derive(Default)]
pub struct Rasterizador {
ctx: swash::scale::ScaleContext,
}
impl Rasterizador {
pub fn new() -> Self {
Self::default()
}
/// Rasteriza un glifo. `None` si la fuente no parsea o si el glifo no tiene
/// mapa (un espacio).
///
/// `clave.fuente` **no se mira acá**: los bytes llegan por `datos`. Ese
/// campo identifica la fuente para cachear, no para rasterizar.
pub fn glifo(&mut self, datos: &[u8], clave: ClaveGlifo) -> Option<MapaGlifo> {
let fuente = swash::FontRef::from_index(datos, clave.indice as usize)?;
let tam = clave.tam_64 as f32 / 64.0;
let sx = (clave.subpx as u32 % SUBPIXELES) as f32 / SUBPIXELES as f32;
let sy = (clave.subpx as u32 / SUBPIXELES) as f32 / SUBPIXELES as f32;
let mut escalador = self
.ctx
.builder(fuente)
.size(tam)
// Sin hinting: ver el encabezado del crate.
.hint(false)
.build();
let mut render = swash::scale::Render::new(&[swash::scale::Source::Outline]);
render.format(swash::zeno::Format::Alpha);
// El desplazamiento subpíxel se aplica ANTES de rasterizar: así el
// antialiasing corresponde a la posición real del glifo y no a una
// versión corrida después.
render.offset(swash::zeno::Vector::new(sx, sy));
if clave.engrosado {
render.embolden(tam * 0.02);
}
if clave.inclinacion_c != 0 {
let grados = clave.inclinacion_c as f32 / 100.0;
render.transform(Some(swash::zeno::Transform::skew(
swash::zeno::Angle::from_degrees(-grados),
swash::zeno::Angle::from_degrees(0.0),
)));
}
let img = render.render(&mut escalador, clave.glifo)?;
let (w, h) = (img.placement.width, img.placement.height);
if w == 0 || h == 0 {
return None;
}
Some(MapaGlifo {
ancho: w,
alto: h,
izq: img.placement.left,
arriba: img.placement.top,
cobertura: img.data,
})
}
}
/// Métricas de fuente que **no** necesitan shaping, en píxeles ya escalados.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MetricasFuente {
pub ascenso: f32,
pub descenso: f32,
pub interlinea: f32,
}
impl MetricasFuente {
/// Alto de renglón: la suma de las tres. Es la misma definición que usa
/// `fontdue` para su `new_line_size`, y da el **mismo número hasta la
/// tercera decimal** en Liberation Mono a 11/12/13/14/16/18 px — verificado
/// antes de migrar el rasterizador del terminal, porque un píxel de más en
/// la celda cambiaría la grilla y eso se ve.
pub fn alto_linea(&self) -> f32 {
self.ascenso + self.descenso + self.interlinea
}
}
/// Métricas de la fuente a un tamaño dado.
pub fn metricas(datos: &[u8], indice: u32, tam_px: f32) -> Option<MetricasFuente> {
let f = swash::FontRef::from_index(datos, indice as usize)?;
let m = f.metrics(&[]).scale(tam_px);
Some(MetricasFuente {
ascenso: m.ascent,
descenso: m.descent,
interlinea: m.leading,
})
}
/// Id del glifo de un carácter según el `cmap` de la fuente. **0 = notdef**
/// (la fuente no lo tiene), igual que en el formato.
///
/// Esto NO es shaping: no resuelve ligaduras, ni marcas combinantes, ni
/// escrituras complejas. Sirve para quien ya decidió que un carácter es un
/// glifo — una grilla de terminal— y sería incorrecto para texto general.
pub fn id_de_caracter(datos: &[u8], indice: u32, ch: char) -> Option<u16> {
let f = swash::FontRef::from_index(datos, indice as usize)?;
Some(f.charmap().map(ch))
}
/// Avance horizontal de un glifo, en píxeles.
pub fn avance(datos: &[u8], indice: u32, tam_px: f32, glifo: u16) -> Option<f32> {
let f = swash::FontRef::from_index(datos, indice as usize)?;
Some(f.glyph_metrics(&[]).scale(tam_px).advance_width(glifo))
}
/// Dónde quedó un glifo dentro del atlas y cómo colocarlo en pantalla.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Colocacion {
/// Rect en el atlas, en coordenadas de textura normalizadas `0..1`.
pub u0: f32,
pub v0: f32,
pub u1: f32,
pub v1: f32,
/// Desplazamiento del mapa respecto del origen del glifo, en px. `izq` es
/// hacia la derecha; `arriba` es hacia **arriba** (convención OpenType, eje
/// Y para arriba), así que en pantalla el borde superior del mapa va a
/// `y_base - arriba`.
pub izq: i32,
pub arriba: i32,
/// Tamaño del mapa en px.
pub ancho: u32,
pub alto: u32,
}
/// Un quad listo para el batcher: dónde va en pantalla y de dónde sale su
/// cobertura. El color no viaja acá porque es del run entero, no del glifo.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct QuadGlifo {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub u0: f32,
pub v0: f32,
pub u1: f32,
pub v1: f32,
}
/// Región rectangular del atlas que cambió desde la última subida a GPU.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Sucio {
pub x: u32,
pub y: u32,
pub ancho: u32,
pub alto: u32,
}
/// El atlas: una textura cuadrada de un canal, empaquetada por **estantes**.
///
/// Estantes y no un empaquetador óptimo a propósito. Los glifos de un texto
/// tienen alturas parecidas, así que una fila por rango de altura desperdicia
/// poco, y el algoritmo cabe en veinte líneas que se pueden leer. Un packer con
/// árbol se justifica cuando se empaquetan sprites de tamaños arbitrarios.
pub struct AtlasGlifos {
lado: u32,
pixeles: Vec<u8>,
mapa: MapaGlifos,
/// Cursor del estante actual.
x: u32,
y: u32,
alto_estante: u32,
sucio: Option<Sucio>,
/// Sube cada vez que se escribe en el atlas. Quien tiene una copia en GPU
/// compara este número para saber si hay que volver a subir.
version: u64,
/// Glifos que no entraron porque el atlas está lleno. Que sea un contador
/// público y no un panic es lo que permite que una app siga andando (sin
/// ese texto) en vez de morirse.
pub fallos: u64,
/// Cuántas veces un pedido se resolvió con lo que ya estaba.
pub aciertos: u64,
/// Cuántas veces hubo que rasterizar.
pub rasterizados: u64,
/// El rasterizador, compartido con quien empaqueta a su manera.
rasterizador: Rasterizador,
}
impl AtlasGlifos {
/// Atlas cuadrado de `lado × lado` px, un byte por px. 1024 son 1 MiB y
/// alcanzan de sobra para el juego de caracteres de una UI en dos o tres
/// tamaños.
pub fn new(lado: u32) -> Self {
let lado = lado.max(16);
Self {
lado,
pixeles: vec![0u8; (lado * lado) as usize],
mapa: MapaGlifos::default(),
x: 0,
y: 0,
alto_estante: 0,
sucio: None,
version: 0,
fallos: 0,
aciertos: 0,
rasterizados: 0,
rasterizador: Rasterizador::new(),
}
}
pub fn lado(&self) -> u32 {
self.lado
}
/// Los bytes del atlas, en orden de filas, un byte de cobertura por píxel.
pub fn pixeles(&self) -> &[u8] {
&self.pixeles
}
/// Versión del contenido. Cambia sólo cuando se escribió algo nuevo.
pub fn version(&self) -> u64 {
self.version
}
/// Cuántos glifos distintos hay guardados (incluye los que no entraron).
pub fn entradas(&self) -> usize {
self.mapa.len()
}
/// La región que cambió desde la última llamada, y la limpia. Quien sube el
/// atlas a una textura puede subir sólo eso en vez del millón de bytes.
pub fn tomar_sucio(&mut self) -> Option<Sucio> {
self.sucio.take()
}
/// Vacía el atlas. Es la única salida cuando `fallos` empieza a subir: se
/// tira todo y se vuelve a llenar con lo que el frame siguiente pida. Es
/// brusco —un frame paga la re-rasterización entera— pero es honesto y no
/// finge una política de desalojo que nadie midió.
pub fn limpiar(&mut self) {
self.pixeles.fill(0);
self.mapa.clear();
self.x = 0;
self.y = 0;
self.alto_estante = 0;
self.version += 1;
self.sucio = Some(Sucio { x: 0, y: 0, ancho: self.lado, alto: self.lado });
}
/// Busca un glifo, rasterizándolo si es la primera vez.
///
/// `datos`/`indice` son el archivo de fuente; `clave.fuente` es su
/// identidad. Devuelve `None` si el glifo está vacío (un espacio no tiene
/// mapa) o si el atlas está lleno — el caller no distingue, y no necesita:
/// en los dos casos no hay nada que dibujar. Para saber si fue por lleno,
/// mirar `fallos`.
pub fn glifo(&mut self, datos: &[u8], clave: ClaveGlifo) -> Option<Colocacion> {
if let Some(v) = self.mapa.get(&clave) {
self.aciertos += 1;
return *v;
}
self.rasterizados += 1;
let col = self.rasterizar(datos, clave);
self.mapa.insert(clave, col);
col
}
/// Rasteriza y **empaqueta**. El rasterizado en sí es de
/// [`Rasterizador`] — acá sólo se decide dónde va.
fn rasterizar(&mut self, datos: &[u8], clave: ClaveGlifo) -> Option<Colocacion> {
let m = self.rasterizador.glifo(datos, clave)?;
let (w, h) = (m.ancho, m.alto);
let (px, py) = self.reservar(w, h)?;
for fila in 0..h {
let orig = (fila * w) as usize;
let dest = ((py + fila) * self.lado + px) as usize;
self.pixeles[dest..dest + w as usize]
.copy_from_slice(&m.cobertura[orig..orig + w as usize]);
}
self.ensuciar(px, py, w, h);
self.version += 1;
let l = self.lado as f32;
Some(Colocacion {
u0: px as f32 / l,
v0: py as f32 / l,
u1: (px + w) as f32 / l,
v1: (py + h) as f32 / l,
izq: m.izq,
arriba: m.arriba,
ancho: w,
alto: h,
})
}
/// Reserva `w × h` px por estantes. `None` = no entra.
fn reservar(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
if w > self.lado || h > self.lado {
self.fallos += 1;
return None;
}
if self.x + w > self.lado {
// Estante siguiente.
self.y += self.alto_estante + AIRE;
self.x = 0;
self.alto_estante = 0;
}
if self.y + h > self.lado {
self.fallos += 1;
return None;
}
let pos = (self.x, self.y);
self.x += w + AIRE;
self.alto_estante = self.alto_estante.max(h);
Some(pos)
}
fn ensuciar(&mut self, x: u32, y: u32, w: u32, h: u32) {
self.sucio = Some(match self.sucio {
None => Sucio { x, y, ancho: w, alto: h },
Some(s) => {
let x0 = s.x.min(x);
let y0 = s.y.min(y);
let x1 = (s.x + s.ancho).max(x + w);
let y1 = (s.y + s.alto).max(y + h);
Sucio { x: x0, y: y0, ancho: x1 - x0, alto: y1 - y0 }
}
});
}
}
/// Cuantiza una coordenada a la grilla subpíxel: devuelve la parte entera y el
/// índice de fracción.
fn cuantizar(v: f32) -> (f32, u32) {
let ent = v.floor();
let frac = v - ent;
let i = (frac * SUBPIXELES as f32).round() as u32;
// La fracción puede redondear al escalón siguiente (0,95 → 4/4): eso es el
// píxel de al lado con fracción 0, no un índice fuera de rango.
if i >= SUBPIXELES {
(ent + 1.0, 0)
} else {
(ent, i)
}
}
/// Recorre un layout ya shapeado y emite un quad por glifo visible.
///
/// `origen` es la esquina superior-izquierda del bloque en píxeles de pantalla,
/// el mismo argumento que `llimphi_text::draw_layout`. Devuelve cuántos quads
/// se agregaron; los glifos sin mapa (espacios) y los que no entraron en el
/// atlas simplemente no aparecen.
///
/// Es genérico en el brush del layout porque acá el color no se mira: quien
/// llama sabe de qué color pinta el run. Un layout multicolor
/// (`Typesetter::layout_runs`) se recorre igual, run por run, con
/// [`quads_de_run`].
pub fn quads_de_layout<B: parley::Brush>(
atlas: &mut AtlasGlifos,
layout: &parley::Layout<B>,
origen: (f32, f32),
salida: &mut Vec<QuadGlifo>,
) -> usize {
let antes = salida.len();
for linea in layout.lines() {
for item in linea.items() {
if let parley::PositionedLayoutItem::GlyphRun(run) = item {
quads_de_run(atlas, &run, origen, salida);
}
}
}
salida.len() - antes
}
/// Un solo run. Se expone aparte para que un layout multicolor pueda mirar el
/// brush de cada run y emitir sus quads con el color que corresponde.
pub fn quads_de_run<B: parley::Brush>(
atlas: &mut AtlasGlifos,
run: &parley::GlyphRun<'_, B>,
origen: (f32, f32),
salida: &mut Vec<QuadGlifo>,
) {
let interno = run.run();
let fuente = interno.font();
let datos: &[u8] = fuente.data.as_ref();
let id = fuente.data.id();
let indice = fuente.index;
let tam_64 = (interno.font_size() * 64.0).round().max(1.0) as u32;
let sintesis = interno.synthesis();
let engrosado = sintesis.embolden();
let inclinacion_c = sintesis
.skew()
.map(|g| (g * 100.0).round() as i32)
.unwrap_or(0);
for g in run.positioned_glyphs() {
let (gx, sx) = cuantizar(origen.0 + g.x);
let (gy, sy) = cuantizar(origen.1 + g.y);
let clave = ClaveGlifo {
fuente: id,
indice,
// parley da el id en `u32`; swash lo pide en `u16`. No se pierde
// nada: `numGlyphs` es un `uint16` en el formato, así que una
// fuente no puede tener más de 65.536 glifos.
glifo: g.id as u16,
tam_64,
subpx: (sx + sy * SUBPIXELES) as u8,
engrosado,
inclinacion_c,
};
let Some(c) = atlas.glifo(datos, clave) else {
continue;
};
salida.push(QuadGlifo {
x: gx + c.izq as f32,
// `arriba` va hacia arriba (OpenType); en pantalla Y crece hacia
// abajo, así que el borde superior del mapa cae por encima de la
// línea base.
y: gy - c.arriba as f32,
w: c.ancho as f32,
h: c.alto as f32,
u0: c.u0,
v0: c.v0,
u1: c.u1,
v1: c.v1,
});
}
}
#[cfg(test)]
mod pruebas {
use super::*;
#[test]
fn la_cuantizacion_reparte_las_fracciones_y_no_se_sale() {
// El caso normal: cuatro escalones por píxel.
assert_eq!(cuantizar(10.0), (10.0, 0));
assert_eq!(cuantizar(10.25), (10.0, 1));
assert_eq!(cuantizar(10.5), (10.0, 2));
assert_eq!(cuantizar(10.75), (10.0, 3));
// Y el borde que rompería un índice sin guarda: 0,95 redondea al
// escalón 4, que no existe — es el píxel siguiente con fracción 0.
assert_eq!(cuantizar(10.95), (11.0, 0));
assert_eq!(cuantizar(10.9), (11.0, 0));
// Negativos: `floor` los manda para abajo, la fracción sigue en 0..1.
assert_eq!(cuantizar(-0.25), (-1.0, 3));
}
#[test]
fn el_hasher_barato_reparte_y_no_colapsa() {
// Un hasher degenerado —uno que devolviera una constante, o que ignorara
// algún campo— seguiría siendo **correcto**: la tabla compara las claves
// igual y nunca devuelve el glifo equivocado. Lo que haría es convertir
// el mapa en una lista, y entonces el cambio que se hizo para acelerar
// el `preparar` lo volvería más lento **en silencio**. Ningún test de
// píxeles agarraría eso.
use std::hash::Hash;
fn h(c: &ClaveGlifo) -> u64 {
let mut x = HasherGlifo::default();
c.hash(&mut x);
x.finish()
}
let base = ClaveGlifo {
fuente: 7,
indice: 0,
glifo: 42,
tam_64: 11 * 64,
subpx: 0,
engrosado: false,
inclinacion_c: 0,
};
// Cada campo, por separado, tiene que mover el hash: si uno no lo mueve,
// ese campo no está entrando en la clave efectiva.
let variantes = [
ClaveGlifo { fuente: 8, ..base },
ClaveGlifo { indice: 1, ..base },
ClaveGlifo { glifo: 43, ..base },
ClaveGlifo { tam_64: 12 * 64, ..base },
ClaveGlifo { subpx: 1, ..base },
ClaveGlifo { engrosado: true, ..base },
ClaveGlifo { inclinacion_c: 1400, ..base },
];
for (i, v) in variantes.iter().enumerate() {
assert_ne!(h(&base), h(v), "el campo {i} no mueve el hash");
}
// Y el caso realista: el juego de claves de un texto —un puñado de
// glifos por las 16 fracciones subpíxel— tiene que repartirse. Se exige
// que no haya colisiones sobre 800 claves; con un hash decente la
// probabilidad de una colisión de 64 bits ahí es ~1 en 10^14.
let mut vistos = std::collections::HashSet::new();
for glifo in 0..50u16 {
for subpx in 0..16u8 {
vistos.insert(h(&ClaveGlifo { glifo, subpx, ..base }));
}
}
assert_eq!(vistos.len(), 800, "el hasher colapsó claves distintas");
}
#[test]
fn el_estante_avanza_y_el_atlas_lleno_no_revienta() {
let mut a = AtlasGlifos::new(16);
// Dos de 6×4 entran en el primer estante (6+1+6 = 13 ≤ 16).
assert_eq!(a.reservar(6, 4), Some((0, 0)));
assert_eq!(a.reservar(6, 4), Some((7, 0)));
// El tercero no: pasa al estante siguiente, que arranca bajo el más
// alto del anterior más el aire.
assert_eq!(a.reservar(6, 4), Some((0, 5)));
// Y algo más alto que el atlas entero es un fallo contado, no un panic.
assert_eq!(a.reservar(6, 99), None);
assert_eq!(a.fallos, 1);
}
#[test]
fn lo_sucio_es_la_union_de_lo_escrito_y_se_vacia_al_tomarlo() {
let mut a = AtlasGlifos::new(64);
assert_eq!(a.tomar_sucio(), None);
a.ensuciar(2, 3, 4, 5);
a.ensuciar(10, 1, 2, 2);
// Union: x 2..12, y 1..8.
assert_eq!(a.tomar_sucio(), Some(Sucio { x: 2, y: 1, ancho: 10, alto: 7 }));
// Tomarlo lo limpia: quien ya subió esa región no la vuelve a subir.
assert_eq!(a.tomar_sucio(), None);
}
#[test]
fn limpiar_ensucia_todo_y_sube_la_version() {
let mut a = AtlasGlifos::new(32);
a.ensuciar(0, 0, 1, 1);
let v = a.version();
a.tomar_sucio();
a.limpiar();
assert!(a.version() > v);
// Todo el atlas quedó sucio: quien tenga una copia en GPU tiene que
// volver a subirla entera, porque los UV viejos ya no valen.
assert_eq!(
a.tomar_sucio(),
Some(Sucio { x: 0, y: 0, ancho: 32, alto: 32 })
);
assert_eq!(a.entradas(), 0);
}
}
+5
View File
@@ -11,6 +11,11 @@ repository.workspace = true
[dependencies]
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" }
# El texto: el layout lo arma `llimphi-text` y los glifos los rasteriza y
# empaqueta `llimphi-glifos`. Los dos caminos del bench de texto comparten el
# mismo `parley::Layout`, así que el shaping queda fuera de la comparación.
llimphi-text = { path = "../llimphi-text", version = "0.1.0" }
llimphi-glifos = { path = "../llimphi-glifos", version = "0.1.0" }
vello = { workspace = true }
pollster = { workspace = true }
png = { workspace = true }
+299 -34
View File
@@ -22,6 +22,9 @@
//!
//! Corre con: `cargo run -p llimphi-gpu-bench --release`.
mod scissor;
mod texto;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::time::Instant;
@@ -29,7 +32,7 @@ use std::time::Instant;
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::kurbo::{Affine, Rect};
use llimphi_raster::peniko::{color::palette, Color, Fill};
use llimphi_raster::{vello, GpuBatch, GpuPipelines};
use llimphi_raster::{units_per_chunk, vello, GpuBatch, GpuPipelines, RECT_INSTANCE_STRIDE};
const W: u32 = 1024;
const H: u32 = 1024;
@@ -154,6 +157,42 @@ fn main() {
println!();
print_scale_verdict(&scale_rows);
// ----------------------------------------------------------------
// Texto. El atlas de glifos (2026-08-05) está certificado como
// CORRECTO —`llimphi-test/tests/texto_gpu_directo.rs`, Δ64:0— pero
// nunca se midió si CONVIENE. Sin ese número sería justo lo que la
// Iniciativa #4 viene a curar: algo construido que nadie usa porque
// nadie sabe si paga.
// ----------------------------------------------------------------
let gpu_real =
hal.adapter.get_info().device_type != wgpu::DeviceType::Cpu;
let filas_texto = texto::correr(
&hal,
&mut vello_renderer,
&pipelines,
&view,
W,
H,
// Más muestras que el resto del bench, y no por gusto: con las 15 de
// `MEASURED` la tabla salía NO MONÓTONA —7.200 glifos más lentos que
// 12.000—, que es la firma de que el ruido de la máquina pesa más que
// lo que se mide. Un cuadro de texto cuesta milisegundos; los rects,
// decenas.
texto::WARMUP,
texto::MEDIDOS,
);
texto::imprimir(&filas_texto, gpu_real);
let _ = std::io::stdout().flush();
// ----------------------------------------------------------------
// Scissor. El 14,8 % → 97,9 % que figura en el plan salió de una
// medición ad-hoc que nunca quedó en el repo: no se podía repetir.
// Acá es un bench, con su control.
// ----------------------------------------------------------------
let filas_scissor = scissor::correr(&hal, &pipelines);
scissor::imprimir(&filas_scissor);
let _ = std::io::stdout().flush();
// ----------------------------------------------------------------
// Variantes persistentes: el rebuild del batch/scene por frame es
// el peor caso. En apps reales (cosmos starfield Gaia, tinkuy
@@ -194,14 +233,25 @@ fn main() {
println!();
println!("### GPU directo (buffer + bind group persistentes)");
println!();
println!("| N | ms / frame | fps (1000/ms) | Mprim/s |");
println!("|---:|---:|---:|---:|");
println!("| N | ms / frame | fps (1000/ms) | Mprim/s | px pintados |");
println!("|---:|---:|---:|---:|---:|");
let mut directo_persist_rows: Vec<ScaleRow> = Vec::new();
for n in scale_sizes() {
let ms = bench_directo_persistent(&hal, &pipelines, &view, n);
let (ms, pintados) = bench_directo_persistent(&hal, &pipelines, &target, &view, n);
let fps = 1000.0 / ms;
let mps = (n as f64 / 1_000_000.0) / (ms / 1000.0);
println!("| {} | {:.2} | {:.1} | {:.2} |", fmt_int(n), ms, fps, mps);
println!(
"| {} | {:.2} | {:.1} | {:.2} | {} |",
fmt_int(n),
ms,
fps,
mps,
if pintados == 0 {
"⚠️ 0 px — NO DIBUJÓ".to_string()
} else {
fmt_int(pintados.min(u32::MAX as u64) as u32)
}
);
let _ = std::io::stdout().flush();
directo_persist_rows.push(ScaleRow { n, ms, fps, mps });
}
@@ -406,7 +456,7 @@ fn bench_vello(
},
)
.expect("vello render");
hal.device.poll(wgpu::PollType::wait_indefinitely());
hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP {
samples.push(dt);
@@ -415,6 +465,70 @@ fn bench_vello(
median(&mut samples)
}
/// Cuántos píxeles del target quedaron distintos del negro opaco con que se
/// limpia el pase. Es la contraprueba barata de que el draw llegó a la pantalla:
/// texto, no un PNG que haya que mirar (Regla 8 del repo).
fn contar_pixeles_pintados(hal: &Hal, target: &wgpu::Texture) -> u64 {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("verificacion-readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(encoder.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
if rx.recv().is_err() {
return 0;
}
let data = slice.get_mapped_range();
let mut n = 0u64;
for fila in 0..H as usize {
let base = fila * padded;
for px in 0..W as usize {
let o = base + px * 4;
// Distinto del negro en cualquiera de los tres canales de color.
if data[o] != 0 || data[o + 1] != 0 || data[o + 2] != 0 {
n += 1;
}
}
}
drop(data);
buf.unmap();
n
}
fn bench_directo(
hal: &Hal,
pipelines: &GpuPipelines,
@@ -448,7 +562,7 @@ fn bench_directo(
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
);
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::PollType::wait_indefinitely());
hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP {
samples.push(dt);
@@ -501,7 +615,7 @@ fn bench_vello_persistent(
},
)
.expect("vello render");
hal.device.poll(wgpu::PollType::wait_indefinitely());
hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP {
samples.push(dt);
@@ -518,14 +632,28 @@ fn bench_vello_persistent(
/// (instance stride 20 B = [x:f32, y:f32, w:f32, h:f32, rgba:u32]),
/// usando el `rects` pipeline + `bind_layout` expuestos por
/// `GpuPipelines`.
///
/// **El attachment tiene que ser multisample.** Los cuatro pipelines de
/// `GpuPipelines` se compilan con `MSAA_SAMPLES = 4`, así que un pase contra
/// una textura de un solo sample es incompatible y wgpu lo **rechaza entero**:
/// no dibuja nada y el bucle termina midiendo un submit vacío. Así estuvo hasta
/// 2026-08-04, y por eso esta tabla reportaba cifras imposibles —23.000 fps,
/// 178.000 Mprim/s a 10M— con un «PASA» que no significaba nada. Ahora se
/// rasteriza al MSAA y se resuelve a un scratch, que es exactamente lo que hace
/// `GpuBatch::flush` por dentro.
fn bench_directo_persistent(
hal: &Hal,
pipelines: &GpuPipelines,
target: &wgpu::Texture,
view: &wgpu::TextureView,
n: u32,
) -> f64 {
// Empaquetar instancias UNA vez.
let mut bytes = Vec::with_capacity(n as usize * 20);
) -> (f64, u64) {
// Empaquetar instancias UNA vez, con el stride que declara el propio
// `llimphi-raster`. Escribirlo a mano acá fue el bug: cuando el color del
// vértice se ensanchó a `Float32x4` el stride pasó de 20 a 32 B, esto siguió
// en 20, y wgpu rechazó cada draw por buffer corto — sin que nadie se
// enterara, porque el bench sólo miraba el reloj.
let mut bytes = Vec::with_capacity(n as usize * RECT_INSTANCE_STRIDE as usize);
let mut state: u32 = 0x1234_5678;
for _ in 0..n {
let (x, y, rgba) = lcg_point(&mut state);
@@ -533,25 +661,74 @@ fn bench_directo_persistent(
bytes.extend_from_slice(&y.to_ne_bytes());
bytes.extend_from_slice(&POINT_PX.to_ne_bytes());
bytes.extend_from_slice(&POINT_PX.to_ne_bytes());
bytes.extend_from_slice(&rgba.to_ne_bytes());
// Color como 4 × f32 en 0..1, desempaquetando el rgba del LCG.
for canal in [rgba & 0xFF, (rgba >> 8) & 0xFF, (rgba >> 16) & 0xFF, (rgba >> 24) & 0xFF] {
bytes.extend_from_slice(&(canal as f32 / 255.0).to_ne_bytes());
}
}
assert_eq!(
bytes.len() as u32 / n,
RECT_INSTANCE_STRIDE,
"el empaquetado del bench dejó de casar con el layout de llimphi-raster"
);
// Techo duro del dispositivo: **un** buffer de instancias no puede pasar
// `max_buffer_size`. A 32 B por rect eso son ~8,4 M de primitivas en una
// Iris Xe (256 MB). Hasta 2026-08-05 esta fila se saltaba con «no entra» —
// honesto, pero era el bench declarando un techo del camino directo que en
// realidad era del bench. Ahora se parte en lotes con la MISMA aritmética
// que usa `GpuBatch::flush` (`units_per_chunk`), así el bench no puede
// volver a divergir del motor como pasó con el stride.
let techo = hal.device.limits().max_buffer_size;
let por_lote = units_per_chunk(RECT_INSTANCE_STRIDE, techo, 1) as usize;
let lotes: Vec<(wgpu::Buffer, u32)> = bytes
.chunks(por_lote * RECT_INSTANCE_STRIDE as usize)
.map(|trozo| {
let b = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("persist-rects"),
size: trozo.len() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
hal.queue.write_buffer(&b, 0, trozo);
(b, (trozo.len() / RECT_INSTANCE_STRIDE as usize) as u32)
})
.collect();
if lotes.len() > 1 {
println!(
" <sub>{} en {} lotes de ≤{} (techo {} MB)</sub>",
fmt_int(n),
lotes.len(),
fmt_int(por_lote as u32),
techo / (1024 * 1024)
);
}
let inst_buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("persist-rects"),
size: bytes.len() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
hal.queue.write_buffer(&inst_buf, 0, &bytes);
// Uniforms (viewport + line_width).
let u_data: [f32; 4] = [W as f32, H as f32, 1.0, 0.0];
let mut u_bytes = Vec::with_capacity(16);
// Uniforms. **32 bytes, no 16**, y ésta es la SEGUNDA vez que este bench se
// desincroniza del motor de la misma manera (la primera fue el stride de
// instancia, ver el comentario de arriba).
//
// El uniform del shader creció de 16 a 32 B el 2026-08-05, cuando el scissor
// sumó `origin` (la esquina del rect sucio, para que el vertex shader corra
// las coordenadas) y `line_width` se fue a viajar por instancia. Este bench
// siguió escribiendo 16, y wgpu rechazó **todos** los draws del camino
// persistente con «bound with size 16 where the shader expects 32». Como los
// errores de wgpu acá son no-fatales, el bucle igual cronometró: reportaba
// **37.844 fps y 427.186 Mprim/s**, o sea la nada medida con precisión
// admirable. Lo que lo delató no fue el tiempo —era espectacular— sino la
// columna `px pintados`, constante en 603.014 para todos los N.
//
// Layout, igual que `Uniforms` en el WGSL de `llimphi-raster::gpu`:
// dst_size: vec2<f32> — tamaño del target
// origin: vec2<f32> — esquina del rect sucio; (0,0) sin scissor
// _pad0: vec4<f32> — relleno hasta 32 B
let u_data: [f32; 8] = [W as f32, H as f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let mut u_bytes = Vec::with_capacity(32);
for v in u_data {
u_bytes.extend_from_slice(&v.to_ne_bytes());
}
let uniforms = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("persist-uniforms"),
size: 16,
size: 32,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -566,10 +743,37 @@ fn bench_directo_persistent(
}],
});
// Attachment multisample + resolve al `view` single-sample. Se crea UNA
// vez, fuera del bucle: lo que se mide es el redraw, no la reserva.
let msaa_tex = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("persist-msaa"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 4,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default());
// **Borrar el target a negro ANTES de medir.** Sin esto, `px pintados`
// cuenta lo que dejó la corrida anterior y un pase que wgpu rechaza entero
// sigue reportando cientos de miles de píxeles «pintados». Fue justo lo que
// pasó con el uniform de 16 B: el número quedó clavado en 603.014 para todos
// los N y la tabla parecía sana. Con el borrado, un pase que no dibuja deja
// el lienzo negro y el conteo cae a cero, que es una señal que no se puede
// leer mal.
limpiar_target(hal, view);
// Asegurar que toda la escritura previa esté en la GPU antes de
// empezar a medir frames — si no, el primer frame paga el upload.
hal.queue.submit(std::iter::empty::<wgpu::CommandBuffer>());
hal.device.poll(wgpu::PollType::wait_indefinitely());
hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
for frame in 0..(WARMUP + MEASURED) {
@@ -583,8 +787,8 @@ fn bench_directo_persistent(
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("persist-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
view: &msaa_view,
resolve_target: Some(view),
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
@@ -594,27 +798,88 @@ fn bench_directo_persistent(
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(&pipelines.rects);
pass.set_bind_group(0, &bind_group, &[]);
pass.set_vertex_buffer(0, inst_buf.slice(..));
pass.draw(0..6, 0..n);
for (buf, cuantos) in &lotes {
pass.set_vertex_buffer(0, buf.slice(..));
pass.draw(0..6, 0..*cuantos);
}
}
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::PollType::wait_indefinitely());
hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP {
samples.push(dt);
}
}
median(&mut samples)
// **Verificar que se dibujó algo.** Un bench que sólo cronometra puede estar
// midiendo la nada con una precisión admirable: si el pase no emite píxeles
// —porque wgpu lo rechazó, porque el buffer no casa con el layout, porque
// el viewport está mal— el bucle igual devuelve un número, y encima
// buenísimo. Ese fue exactamente el caso hasta 2026-08-04. El conteo va al
// lado del tiempo para que la tabla se pueda desestimar de un vistazo.
let pintados = contar_pixeles_pintados(hal, target);
// Y ahora sí se JUZGA, no sólo se imprime. Una tabla con un número al lado
// que nadie mira no protege de nada: el uniform desincronizado sobrevivió
// exactamente así, con la columna a la vista.
assert!(
pintados > 0,
"el camino persistente no pintó UN solo píxel con N={n}, así que los \
milisegundos que acaba de medir son la nada. Casi siempre es que el \
bench se desincronizó del motor el stride de instancia o el tamaño del \
uniform y wgpu rechaza los draws sin ser fatal. Corré sin tapar stderr: \
`llimphi-hal` imprime el error de validación con el número exacto."
);
(median(&mut samples), pintados)
}
/// Deja el target en negro opaco. Es la línea de base de
/// [`contar_pixeles_pintados`]: sin esto, el conteo mide sedimento de la corrida
/// anterior en vez del cuadro que se acaba de dibujar.
fn limpiar_target(hal: &Hal, view: &wgpu::TextureView) {
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("limpiar-target"),
});
{
let _p = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("limpiar-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
}
hal.queue.submit(std::iter::once(enc.finish()));
hal.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
}
fn lcg_point(state: &mut u32) -> (f32, f32, u32) {
// **Los bits ALTOS, no el módulo.** En un LCG mod 2^32 el bit k tiene
// período 2^k, así que `state % 1024` —los 10 bits bajos— se repite cada
// 1024 pasos: el bench dibujaba las mismas ~1.500 posiciones una y otra vez
// sin importar si le pedías 100 mil puntos o 5 millones. Se veía en que el
// conteo de píxeles pintados daba 9.198 para TODAS las tallas. Con los bits
// altos el reparto cubre el lienzo de verdad, que es lo que hace que el
// número de fill-rate signifique algo.
*state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let x = (*state % W) as f32;
let x = ((*state >> 16) % W) as f32;
*state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let y = (*state % H) as f32;
let y = ((*state >> 16) % H) as f32;
*state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
// Colores: piso 128 por canal para que las PNGs de verificación
// se vean (sin esto el LCG produce muchos negros casi puros, y
@@ -866,7 +1131,7 @@ fn export_directo_png(
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
);
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::PollType::wait_indefinitely());
hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
write_texture_png(hal, target, path)
}
@@ -917,7 +1182,7 @@ fn write_texture_png(hal: &Hal, target: &wgpu::Texture, path: &str) -> Result<()
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
rx.recv().map_err(|e| e.to_string())?.map_err(|e| e.to_string())?;
let data = slice.get_mapped_range();
+216
View File
@@ -0,0 +1,216 @@
//! **¿Cuánto ahorra de verdad el `scissor` del GPU-directo?**
//!
//! `GpuBatch::scissor` es el primitivo de damage / partial-present: cuando sólo
//! cambió una región chica —un cursor que parpadea, un spinner, un nodo que se
//! anima— el caller declara el rect sucio y el `flush` no toca el resto.
//!
//! El número que había en `PLAN-MOTOR-PROFUNDO.md` (14,8 % antes del arreglo,
//! 97,9 % después) salió de una medición **ad-hoc que nunca quedó en el repo**.
//! O sea que no se podía repetir, ni en otra máquina ni después de un cambio.
//! Esto lo vuelve un bench: mismo escenario, una orden.
//!
//! ## Por qué el número puede cambiar con el driver
//!
//! El arreglo de 2026-08-05 fue dimensionar las texturas intermedias (MSAA 4× +
//! resolve) **al rect sucio** en vez de al viewport. Antes se rasterizaba a
//! pantalla completa y se recortaba con `set_scissor_rect`, que es estado de
//! rasterización: no toca el `LoadOp::Clear` del attachment ni el resolve del
//! MSAA, que son de **toda** la superficie por especificación.
//!
//! El sentido del cambio no depende del driver. La **magnitud** sí: una GPU con
//! fast-clear paga menos por limpiar que una CPU rasterizando, así que el ahorro
//! medido en llvmpipe es un techo, no una promesa de hardware. Por eso vale
//! correrlo en metal.
use std::time::Instant;
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{GpuBatch, GpuPipelines};
/// El escenario del plan: una pantalla de laptop.
const ANCHO: u32 = 1920;
const ALTO: u32 = 1080;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const WARMUP: usize = 20;
const MEDIDOS: usize = 120;
/// Lados del rect sucio. El primero es el cursor de 32 px del que habla el plan
/// (0,05 % de la pantalla); el último es la pantalla entera, que existe como
/// **control**: ahí el scissor no puede ahorrar nada, y si el bench dijera que
/// ahorra, estaría midiendo cualquier cosa.
const SUCIOS: &[(u32, u32)] = &[(32, 32), (128, 128), (512, 512), (ANCHO, ALTO)];
pub struct FilaScissor {
pub ancho: u32,
pub alto: u32,
pub fraccion: f64,
pub sin_ms: f64,
pub con_ms: f64,
}
impl FilaScissor {
pub fn ahorro(&self) -> f64 {
1.0 - self.con_ms / self.sin_ms.max(1e-9)
}
}
/// **Mínimo, no mediana.** Y no es para que el número quede lindo: acá se mide
/// una latencia con piso, donde todo el ruido —planificador, frecuencia,
/// el resto del sistema— sólo puede **sumar**. La mediana de una muestra
/// contaminada mide contaminación; el mínimo mide el trabajo.
///
/// Se llegó a esto midiendo: con mediana, la columna «sin scissor» —que por
/// construcción hace siempre lo mismo, la pantalla entera— salía 1,1 / 4,0 /
/// 0,9 / 2,3 ms en las cuatro filas. Que un número que debe ser constante
/// varíe 4× es la firma de estar midiendo la máquina y no el código.
fn piso(v: &[f64]) -> f64 {
v.iter().copied().fold(f64::INFINITY, f64::min)
}
/// Un cuadro: unas pocas primitivas dentro del rect sucio, con o sin scissor.
///
/// Pocas primitivas **a propósito**: lo que se mide es el costo fijo del
/// `flush` —crear las intermedias, limpiar, resolver, componer—, no el de
/// rasterizar. Meter un millón de rects taparía justo lo que se quiere ver.
fn cuadro(
hal: &Hal,
pipelines: &GpuPipelines,
vista: &wgpu::TextureView,
sucio: (u32, u32, u32, u32),
usar_scissor: bool,
) -> f64 {
let t0 = Instant::now();
let mut b = GpuBatch::new(pipelines);
if usar_scissor {
b.scissor(sucio.0, sucio.1, sucio.2, sucio.3);
}
// El "cursor": un rect y un disco dentro de la región sucia.
b.add_rect(
sucio.0 as f32,
sucio.1 as f32,
sucio.2 as f32,
sucio.3 as f32,
Color::from_rgba8(220, 220, 240, 255),
);
b.add_disc(
sucio.0 as f32 + sucio.2 as f32 * 0.5,
sucio.1 as f32 + sucio.3 as f32 * 0.5,
(sucio.2.min(sucio.3) as f32 * 0.3).max(1.0),
Color::from_rgba8(240, 120, 60, 255),
);
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("bench-scissor-enc"),
});
b.flush(
&hal.device,
&hal.queue,
&mut enc,
vista,
(ANCHO as f32, ALTO as f32),
wgpu::LoadOp::Load,
);
hal.queue.submit(std::iter::once(enc.finish()));
hal.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
t0.elapsed().as_secs_f64() * 1000.0
}
pub fn correr(hal: &Hal, pipelines: &GpuPipelines) -> Vec<FilaScissor> {
// Target propio: el escenario del plan es una pantalla de laptop, no el
// lienzo cuadrado del resto del bench.
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("bench-scissor-target"),
size: wgpu::Extent3d {
width: ANCHO,
height: ALTO,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let vista = target.create_view(&wgpu::TextureViewDescriptor::default());
let mut filas = Vec::new();
for &(w, h) in SUCIOS {
let rect = (100, 100, w.min(ANCHO - 100.min(ANCHO)), h.min(ALTO));
let rect = (
rect.0.min(ANCHO.saturating_sub(w)),
rect.1.min(ALTO.saturating_sub(h)),
w,
h,
);
let mut sin: Vec<f64> = Vec::new();
let mut con: Vec<f64> = Vec::new();
for i in 0..(WARMUP + MEDIDOS) {
let a = cuadro(hal, pipelines, &vista, rect, false);
let b = cuadro(hal, pipelines, &vista, rect, true);
if i >= WARMUP {
sin.push(a);
con.push(b);
}
}
filas.push(FilaScissor {
ancho: w,
alto: h,
fraccion: (w as f64 * h as f64) / (ANCHO as f64 * ALTO as f64),
sin_ms: piso(&sin),
con_ms: piso(&con),
});
}
filas
}
pub fn imprimir(filas: &[FilaScissor]) {
println!("## Scissor: cuánto ahorra pintar sólo el rect sucio");
println!();
println!(
"Target {ANCHO}×{ALTO}. Cada cuadro emite **pocas** primitivas dentro de \
la región sucia, a propósito: lo que se mide es el costo fijo del \
`flush` crear las intermedias, limpiar, resolver, componer, no el de \
rasterizar. La última fila es la pantalla entera y está como **control**: \
ahí el scissor no puede ahorrar nada."
);
println!();
println!("| rect sucio | % de pantalla | sin scissor ms | con scissor ms | ahorro |");
println!("|---|---:|---:|---:|---:|");
for f in filas {
println!(
"| {}×{} | {:.3} % | {:.3} | {:.3} | {:.1} % |",
f.ancho,
f.alto,
f.fraccion * 100.0,
f.sin_ms,
f.con_ms,
f.ahorro() * 100.0
);
}
println!();
// El control se evalúa acá y no se deja a la vista de quien lee: un bench
// que no se juzga a sí mismo es una tabla de números bonitos.
if let Some(ctrl) = filas.last() {
if ctrl.fraccion > 0.99 {
if ctrl.ahorro().abs() <= 0.15 {
println!(
"**Control OK**: a pantalla completa el ahorro es {:.1} % \
(0, como debe ser).",
ctrl.ahorro() * 100.0
);
} else {
println!(
"**CONTROL FALLADO**: a pantalla completa el scissor \
«ahorra» {:.1} %. El bench está midiendo otra cosa.",
ctrl.ahorro() * 100.0
);
}
}
}
}
+272
View File
@@ -0,0 +1,272 @@
//! **¿El texto por GPU-directo sale más barato que por vello?**
//!
//! El atlas de glifos (`llimphi-glifos` + `GpuBatch::add_glyph`, 2026-08-05)
//! está certificado como **correcto**: `llimphi-test/tests/texto_gpu_directo.rs`
//! pone los dos caminos sobre el mismo `parley::Layout` y mide `Δ64:0`. Lo que
//! no estaba medido es si **vale la pena** — y sin ese número el atlas sería
//! justo la enfermedad que la Iniciativa #4 del `PLAN-MOTOR-PROFUNDO` viene a
//! curar: algo construido que nadie usa porque nadie sabe si conviene.
//!
//! El argumento a favor era «80 contornos rellenos contra 80 instancias de
//! 48 B». Eso es un argumento, no una medición.
//!
//! ## Qué se mide y qué NO
//!
//! El **shaping es el mismo para los dos** —los dos consumen un `parley::Layout`
//! ya resuelto— así que queda fuera del bucle medido a propósito: meterlo sería
//! sumarle a los dos el mismo número y achatar el factor.
//!
//! De cada camino se miden dos cosas por separado, y la distinción importa:
//!
//! - **preparar** — construir la escena de vello (un `draw_glyphs` por run) o
//! armar los quads (una búsqueda en el atlas por glifo). Es CPU pura, así que
//! este número **vale en cualquier máquina**, incluso en llvmpipe.
//! - **total** — preparar + rasterizar + esperar a la GPU. En una máquina sin
//! GPU real esto mide una CPU rasterizando, y **no decide nada** sobre el
//! comportamiento en hardware. Se imprime igual, marcado.
//!
//! El atlas se llena **antes** del bucle medido y la textura se sube una vez:
//! lo que se mide es el régimen estable, que es donde vive una app. El primer
//! frame de un atlas frío paga la rasterización de cada glifo y no es
//! representativo de nada.
use std::time::Instant;
use llimphi_glifos::{quads_de_layout, AtlasGlifos, QuadGlifo};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::gpu_atlas::AtlasTextura;
use llimphi_raster::peniko::{color::palette, Color};
use llimphi_raster::{vello, GpuBatch, GpuPipelines};
/// La frase de prueba. 56 caracteres con acentos y dígitos: un repertorio
/// realista, no veinte aes que compartirían la misma entrada del atlas y
/// harían ver el caché mucho mejor de lo que es.
const FRASE: &str = "El zorro marrón salta sobre el perro perezoso 0123456789";
const TAM: f32 = 11.0;
const ALTO_LINEA: f32 = 12.0;
/// Cuántos renglones entran en una columna sin salirse del target.
const POR_COLUMNA: u32 = 84;
const ANCHO_COLUMNA: f32 = 330.0;
/// Cuántos renglones por corrida. El tope son 3 columnas × 84 = 252, o sea
/// ~14.000 glifos: más que eso empezaría a pintar fuera del target, y un glifo
/// que el rasterizador descarta por estar afuera haría mentir al número.
const RENGLONES: &[u32] = &[10, 50, 150, 252];
/// Muestras del bench de texto, más que las del resto del binario a propósito
/// (ver el comentario en `main`). Un cuadro de texto cuesta pocos ms, así que
/// el ruido de la máquina se le nota mucho más que a un cuadro de 1 M de rects.
pub const WARMUP: usize = 15;
pub const MEDIDOS: usize = 60;
pub struct FilaTexto {
pub renglones: u32,
pub glifos: u32,
pub vello_prep_ms: f64,
pub vello_total_ms: f64,
pub atlas_prep_ms: f64,
pub atlas_total_ms: f64,
}
impl FilaTexto {
pub fn factor_prep(&self) -> f64 {
self.vello_prep_ms / self.atlas_prep_ms.max(1e-9)
}
pub fn factor_total(&self) -> f64 {
self.vello_total_ms / self.atlas_total_ms.max(1e-9)
}
}
fn origen(i: u32) -> (f32, f32) {
let col = i / POR_COLUMNA;
let fila = i % POR_COLUMNA;
(8.0 + col as f32 * ANCHO_COLUMNA, 12.0 + fila as f32 * ALTO_LINEA)
}
/// Mediana de una muestra. Copiada de `main` a propósito: no vale la pena
/// exportarla, y una función de tres líneas duplicada se lee mejor que un
/// `pub(crate)` que obliga a saltar de archivo.
fn mediana(v: &mut [f64]) -> f64 {
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = v.len();
if n == 0 {
0.0
} else if n % 2 == 1 {
v[n / 2]
} else {
(v[n / 2 - 1] + v[n / 2]) / 2.0
}
}
pub fn correr(
hal: &Hal,
renderer: &mut vello::Renderer,
pipelines: &GpuPipelines,
view: &wgpu::TextureView,
w: u32,
h: u32,
warmup: usize,
medidos: usize,
) -> Vec<FilaTexto> {
// El layout, UNA vez. Los dos caminos lo comparten: el shaping no está en
// discusión y meterlo adentro del bucle le sumaría lo mismo a cada uno.
let mut ts = llimphi_text::Typesetter::new();
let color = Color::from_rgba8(235, 235, 245, 255);
let bloque = llimphi_text::TextBlock::simple(FRASE, TAM, color, (0.0, 0.0));
let layout = llimphi_text::layout_block(&mut ts, &bloque);
let mut filas = Vec::new();
for &n in RENGLONES {
// ── Atlas caliente ANTES de medir ──────────────────────────────
// Un atlas frío paga la rasterización de cada glifo en el primer
// frame. Eso es real y hay que decirlo, pero no es lo que hace una
// app cuadro a cuadro, que es lo que este bench compara.
let mut atlas = AtlasGlifos::new(1024);
let mut quads: Vec<QuadGlifo> = Vec::new();
for i in 0..n {
quads_de_layout(&mut atlas, &layout, origen(i), &mut quads);
}
let glifos = quads.len() as u32;
let mut tex = AtlasTextura::new(&hal.device, pipelines, atlas.lado());
if let Some(s) = atlas.tomar_sucio() {
tex.subir(&hal.queue, atlas.pixeles(), (s.x, s.y, s.ancho, s.alto));
}
// ── Camino vello ───────────────────────────────────────────────
let mut scene = vello::Scene::new();
let (mut prep_v, mut total_v) = (Vec::new(), Vec::new());
for frame in 0..(warmup + medidos) {
let t0 = Instant::now();
scene.reset();
for i in 0..n {
let (x, y) = origen(i);
llimphi_text::draw_layout(&mut scene, &layout, color, (x as f64, y as f64));
}
let t_prep = t0.elapsed().as_secs_f64() * 1000.0;
renderer
.render_to_texture(
&hal.device,
&hal.queue,
&scene,
view,
&vello::RenderParams {
base_color: palette::css::BLACK,
width: w,
height: h,
antialiasing_method: vello::AaConfig::Area,
},
)
.expect("vello render");
hal.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
let t_total = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= warmup {
prep_v.push(t_prep);
total_v.push(t_total);
}
}
// ── Camino atlas ───────────────────────────────────────────────
let (mut prep_a, mut total_a) = (Vec::new(), Vec::new());
let mut buf: Vec<QuadGlifo> = Vec::new();
for frame in 0..(warmup + medidos) {
let t0 = Instant::now();
let mut batch = GpuBatch::new(pipelines);
batch.atlas(&tex);
// Se rearman los quads cada cuadro, igual que vello rearma su
// escena: es la comparación honesta. Guardarlos entre frames sería
// una optimización que el otro camino no tiene.
buf.clear();
for i in 0..n {
quads_de_layout(&mut atlas, &layout, origen(i), &mut buf);
}
for q in &buf {
batch.add_glyph(q.x, q.y, q.w, q.h, (q.u0, q.v0, q.u1, q.v1), color);
}
let t_prep = t0.elapsed().as_secs_f64() * 1000.0;
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("bench-texto-enc"),
});
batch.flush(
&hal.device,
&hal.queue,
&mut enc,
view,
(w as f32, h as f32),
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
);
hal.queue.submit(std::iter::once(enc.finish()));
hal.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
let t_total = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= warmup {
prep_a.push(t_prep);
total_a.push(t_total);
}
}
// Contraprueba de que el atlas no se llenó: si hubiera descartado
// glifos, estaría midiendo un dibujo incompleto contra uno entero.
assert_eq!(
atlas.fallos, 0,
"el atlas descartó glifos: la comparación no es entre dos dibujos iguales"
);
filas.push(FilaTexto {
renglones: n,
glifos,
vello_prep_ms: mediana(&mut prep_v),
vello_total_ms: mediana(&mut total_v),
atlas_prep_ms: mediana(&mut prep_a),
atlas_total_ms: mediana(&mut total_a),
});
}
filas
}
pub fn imprimir(filas: &[FilaTexto], gpu_real: bool) {
println!("## Texto: vello contra el atlas de glifos");
println!();
println!(
"Frase de {} caracteres a {TAM} px, repetida N veces. El **shaping está \
fuera del bucle**: los dos caminos consumen el mismo `parley::Layout`, \
así que meterlo sumaría lo mismo a cada uno. El atlas se llena antes de \
medir se compara el régimen estable, no el primer cuadro.",
FRASE.chars().count()
);
println!();
println!("| renglones | glifos | vello prep ms | atlas prep ms | prep × | vello total ms | atlas total ms | total × |");
println!("|---:|---:|---:|---:|---:|---:|---:|---:|");
for f in filas {
println!(
"| {} | {} | {:.3} | {:.3} | {:.2}× | {:.2} | {:.2} | {:.2}× |",
f.renglones,
f.glifos,
f.vello_prep_ms,
f.atlas_prep_ms,
f.factor_prep(),
f.vello_total_ms,
f.atlas_total_ms,
f.factor_total()
);
}
println!();
println!("**Cómo leer esto.** La columna `prep` es CPU pura —armar la escena \
contra armar los quads y vale en cualquier máquina.");
if gpu_real {
println!();
println!("La columna `total` incluye el rasterizado y la espera a la GPU, \
y en esta corrida hay GPU real: el número decide.");
} else {
println!();
println!("La columna `total` incluye el rasterizado, pero **esta corrida \
no tiene GPU real** (adapter de software): ahí se está midiendo \
una CPU rasterizando, no una GPU. Ese número NO decide nada \
sobre hardware hay que volver a correr esto en metal.");
}
}
+33
View File
@@ -14,6 +14,39 @@ raw-window-handle = { workspace = true }
winit = { workspace = true }
pollster = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# El `<canvas>` al que se ata [`CanvasSurface`]. Sólo se usan los tipos DOM
# mínimos: el elemento, sus atributos width/height y el `devicePixelRatio` de
# la ventana. Nada de eventos ni de `requestAnimationFrame` — eso es del
# eventloop (winit ya lo hace).
web-sys = { version = "0.3", features = [
"Document",
"Element",
"HtmlCanvasElement",
"Window",
] }
# `webgl` habilita el backend GL de wgpu sobre WebGL2, el fallback para los
# navegadores sin WebGPU (Safari viejo, Firefox sin flag). Va target-gated
# para que el escritorio NO lo arrastre: allí el backend GL de Mesa tiene el
# bug de teardown documentado en `Hal::new_inner`, y justamente por eso el
# default de escritorio es `Backends::PRIMARY`.
wgpu = { workspace = true, features = ["webgl"] }
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
# Sólo para el example `canvas_clear`: `spawn_local` (el arranque del `Hal` es
# async y el main thread del navegador no se puede bloquear), `console` para el
# log, y `JsCast` para bajar de `Element` a `HtmlCanvasElement`.
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["console"] }
[[example]]
name = "clear_screen"
path = "examples/clear_screen.rs"
# El HAL del navegador. En targets nativos es un `main` que sólo avisa: así
# `cargo check --workspace --examples` lo cubre sin romperse, y
# `--target wasm32-unknown-unknown --examples` compila el camino real.
[[example]]
name = "canvas_clear"
path = "examples/canvas_clear.rs"
+101
View File
@@ -0,0 +1,101 @@
//! El `clear_screen` del navegador: pinta un `<canvas>` de gris plomo con
//! [`CanvasSurface`], el HAL de wasm32.
//!
//! Es el gemelo web de `clear_screen.rs` y cumple la misma función que aquel:
//! ser el consumidor mínimo del HAL, para que un cambio de API rompa acá y no
//! meses después en una app. Ejercita el camino entero —canvas del DOM →
//! `Hal::for_canvas` → `resize_to_css` → `acquire` → render pass → `present`—
//! sin depender del event loop de `llimphi-ui`, que todavía no cruzó a web.
//!
//! Verificación (no linkea, no necesita navegador ni wasm-bindgen-cli):
//!
//! ```sh
//! cargo check -p llimphi-hal --target wasm32-unknown-unknown --examples
//! ```
//!
//! Para correrlo de verdad hace falta empaquetarlo con `wasm-bindgen-cli` y
//! servir un HTML con un `<canvas id="llimphi">`. No hay script de eso todavía
//! — ver `PORTING.md` §wasm32.
// En cualquier target nativo este example no aplica: la `CanvasSurface` sólo
// existe en wasm32. Un `main` vacío deja que `--examples` lo compile en el
// smoke test del workspace sin ensuciar la salida.
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!(
"llimphi · canvas_clear — este example es sólo para wasm32-unknown-unknown \
(usa CanvasSurface). En escritorio el equivalente es `clear_screen`."
);
}
#[cfg(target_arch = "wasm32")]
fn main() {
wasm_bindgen_futures::spawn_local(async {
if let Err(e) = correr().await {
web_sys::console::error_1(&format!("llimphi · canvas_clear: {e}").into());
}
});
}
#[cfg(target_arch = "wasm32")]
const LEAD_GRAY: llimphi_hal::wgpu::Color = llimphi_hal::wgpu::Color {
r: 0.235,
g: 0.239,
b: 0.247,
a: 1.0,
};
#[cfg(target_arch = "wasm32")]
async fn correr() -> Result<(), String> {
use llimphi_hal::{wgpu, Hal, Surface};
use wasm_bindgen::JsCast;
let canvas: web_sys::HtmlCanvasElement = web_sys::window()
.ok_or("sin `window`")?
.document()
.ok_or("sin `document`")?
.get_element_by_id("llimphi")
.ok_or("no hay ningún elemento con id=\"llimphi\"")?
.dyn_into()
.map_err(|_| "el elemento #llimphi no es un <canvas>")?;
// Surface-first, igual que en Android: el adapter se pide contra ESTA
// surface. `for_canvas` es async porque en el main thread del navegador no
// se puede bloquear.
let (hal, mut surface) = Hal::for_canvas(canvas).await.map_err(|e| e.to_string())?;
// El backing store del canvas por `devicePixelRatio`. Sin esto el canvas se
// ve borroso en HiDPI (el navegador escala un buffer chico).
let (w, h) = surface.resize_to_css();
web_sys::console::log_1(&format!("llimphi · canvas_clear — surface {w}×{h} px físicos").into());
let frame = surface.acquire().map_err(|e| e.to_string())?;
let mut encoder = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("canvas_clear-encoder"),
});
{
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("canvas_clear-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
// La intermedia, no el swapchain: es el mismo contrato que en
// escritorio (vello pinta acá y `present` blittea).
view: frame.view(),
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(LEAD_GRAY),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
}
hal.queue.submit(std::iter::once(encoder.finish()));
surface.present(frame, &hal);
Ok(())
}
+1
View File
@@ -104,6 +104,7 @@ impl ApplicationHandler for App {
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
}
state.hal.queue.submit(std::iter::once(encoder.finish()));
+983 -90
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
//! El selector de formato de swapchain, certificado **sin GPU**.
//!
//! `elegir_formato` es puro: recibe la lista de formatos que ofrece una surface
//! y el objetivo de color pedido, y devuelve con cuál configurar el swapchain.
//! Por eso se puede probar contra listas inventadas —incluidas las que ninguna
//! máquina de acá tiene, como un Vulkan con colorspace extendido— en vez de
//! esperar a tener el hardware delante (Regla 8 de `CLAUDE.md`: evidencia de
//! texto).
//!
//! Lo que estos tests fijan, en orden de importancia:
//!
//! 1. **Cero regresión**: sin pedir nada, la elección es la misma de siempre.
//! 2. **Nunca un formato sRGB de hardware**, que lavaría la imagen aplicando la
//! curva dos veces (la intermedia ya es lineal).
//! 3. **Degradación**, no fallo: pedir 10 bits donde no hay devuelve 8.
use llimphi_hal::{elegir_formato, ColorTarget};
use wgpu::TextureFormat as F;
/// Lo que ofrece un compositor Wayland de escritorio normal (Mesa/Vulkan).
const ESCRITORIO: &[F] = &[F::Bgra8Unorm, F::Bgra8UnormSrgb, F::Rgba8Unorm, F::Rgba8UnormSrgb];
/// Una surface con colorspace extendido: además de lo de siempre, 10 bits y
/// float. Ninguna máquina de este repo la lista hoy; el selector igual tiene
/// que saber qué hacer con ella.
const AMPLIA: &[F] = &[
F::Bgra8Unorm,
F::Rgba8Unorm,
F::Rgb10a2Unorm,
F::Rgba16Float,
];
#[test]
fn el_default_elige_lo_de_siempre() {
// El comportamiento histórico, palabra por palabra: el primer Bgra8Unorm o
// Rgba8Unorm no-sRGB de la lista.
assert_eq!(elegir_formato(ESCRITORIO, ColorTarget::Srgb8), Some(F::Bgra8Unorm));
assert_eq!(elegir_formato(AMPLIA, ColorTarget::Srgb8), Some(F::Bgra8Unorm));
// Y si el compositor sólo ofrece Rgba8 (orden invertido), ése.
assert_eq!(
elegir_formato(&[F::Rgba8UnormSrgb, F::Rgba8Unorm], ColorTarget::Srgb8),
Some(F::Rgba8Unorm)
);
}
#[test]
fn nunca_elige_un_formato_srgb_de_hardware() {
// Una surface que SÓLO ofrece sRGB: no hay elección válida, y devolver
// `None` es lo correcto — el caller cae al primero de la lista a sabiendas,
// en vez de que el selector bendiga un formato que lava la imagen.
assert_eq!(
elegir_formato(&[F::Bgra8UnormSrgb, F::Rgba8UnormSrgb], ColorTarget::Srgb8),
None
);
for objetivo in [ColorTarget::Srgb8, ColorTarget::Rgb10, ColorTarget::Float16] {
let elegido = elegir_formato(ESCRITORIO, objetivo).unwrap();
assert!(
!format!("{elegido:?}").ends_with("Srgb"),
"{objetivo:?} eligió {elegido:?}, que es sRGB de hardware"
);
}
}
#[test]
fn diez_bits_cuando_se_puede_y_ocho_cuando_no() {
assert_eq!(elegir_formato(AMPLIA, ColorTarget::Rgb10), Some(F::Rgb10a2Unorm));
// El mismo pedido contra un escritorio normal degrada a 8 bits en vez de
// fallar: pedir precisión que no hay no debe dejar la app sin ventana.
assert_eq!(elegir_formato(ESCRITORIO, ColorTarget::Rgb10), Some(F::Bgra8Unorm));
}
#[test]
fn float16_degrada_por_toda_la_cadena() {
assert_eq!(elegir_formato(AMPLIA, ColorTarget::Float16), Some(F::Rgba16Float));
// Sin float pero con 10 bits: el escalón intermedio, no el piso.
assert_eq!(
elegir_formato(&[F::Bgra8Unorm, F::Rgb10a2Unorm], ColorTarget::Float16),
Some(F::Rgb10a2Unorm)
);
// Sin ninguno de los dos: el piso.
assert_eq!(elegir_formato(ESCRITORIO, ColorTarget::Float16), Some(F::Bgra8Unorm));
}
#[test]
fn una_surface_sin_formatos_no_paniquea() {
// `get_capabilities` devuelve la lista vacía cuando la surface es
// incompatible con el adapter. El selector no indexa: contesta `None` y el
// caller decide (dos de los tres call sites devuelven un error legible).
assert_eq!(elegir_formato(&[], ColorTarget::Srgb8), None);
assert_eq!(elegir_formato(&[], ColorTarget::Float16), None);
}
#[test]
fn el_default_del_enum_es_el_comportamiento_historico() {
assert_eq!(ColorTarget::default(), ColorTarget::Srgb8);
}
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "llimphi-hybrid"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-hybrid — reproduce la `Escena` grabada de llimphi-cpu contra `vello_hybrid`, el rasterizador CPU+GPU SIN compute shaders. Es el único camino a píxeles en GPUs cuyo compilador de shaders no digiere el compute de vello (Mali-G57 del Redmi Note 13 Pro, medido en 02_ruway/llimphi/android/DIAGNOSTICO.md)."
[dependencies]
# La grabadora. `Escena`/`Orden`/`Forma`/`Glifo` no tocan wgpu ni GPU: son
# peniko + kurbo y nada más. Eso fue lo que permitió escribir y verificar este
# crate ANTES de migrar el workspace a wgpu 29 — vivía en 29 mientras el resto
# seguía en 27, porque no hay ningún tipo de wgpu que cruce esa frontera.
llimphi-cpu = { path = "../llimphi-cpu", version = "0.1.0" }
# El rasterizador sin compute. Desde la migración del workspace a wgpu 29
# (2026-07-28) comparte versión —y por lo tanto `Device`— con `llimphi-hal` y
# con `vello`: los tres cuelgan del mismo wgpu, así que un `Pintor` de acá puede
# rendir sobre la surface de allá sin conversión. Antes de esa migración no
# podía, y ése fue justamente el motivo de hacerla.
vello_hybrid.workspace = true
wgpu.workspace = true
# `PaintType`, `ImageSource`, `Mask`, `Pixmap` — el sustrato COMPARTIDO. Que
# vello_cpu 0.0.9 y vello_hybrid 0.0.9 resuelvan al MISMO `vello_common 0.0.9`
# es lo que hace barato todo esto: una `Mask` rasterizada en CPU se le entrega a
# hybrid tal cual, sin conversión.
vello_common = "0.0.9"
# El `Glyph` posicionado que consume el builder de texto de hybrid. Es un crate
# de Linebender como kurbo o peniko (esperanto: «glifo»), y se declara directo
# porque en hybrid es dep OPCIONAL detrás de su feature `text` — que está en su
# default, pero apoyarse en eso sería frágil: si alguien arma este grafo con
# `default-features = false`, el tipo desaparece y el error sale lejos de acá.
glifo = "0.1.1"
[dev-dependencies]
pollster.workspace = true
# Sólo para `Hal::new_headless(true)`: fuerza el adapter de software (llvmpipe),
# que es lo que hace determinista el número de `tests/hybrid_vs_cpu.rs` en
# cualquier máquina y en CI. Es el mismo arnés que usa `llimphi-test`.
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
+80
View File
@@ -0,0 +1,80 @@
// =============================================================================
// llimphi-hybrid :: la tercera técnica de rasterización
// -----------------------------------------------------------------------------
// Llimphi tiene ya dos rasterizadores y ninguno sirve en el teléfono:
//
// llimphi-raster vello sobre wgpu — COMPUTE SHADERS. El camino de siempre.
// llimphi-cpu vello_cpu — sin GPU del todo. El camino de wawa bare-metal.
//
// Este es el tercero, y nació de una medición, no de un gusto. El diagnóstico
// de `02_ruway/llimphi/android/DIAGNOSTICO.md` probó, cambiando UNA variable
// por vez sobre el mismo Redmi Note 13 Pro (Mali-G57 MC2, driver
// `libGLES_mali.so v1.r54p1-09bet0`):
//
// rasterizador backend resultado
// ninguno (clear) Vulkan anda · 176 MB GL
// vello (compute) Vulkan DEVICE LOST al primer frame · 437 MB GL
// vello (compute) GLES SIGABRT en «Vello shader init»
// vello_hybrid Vulkan SIGSEGV en cmpbe_v2_compile_multiple_shaders
// vello_hybrid GLES ✅ 119 fps · 0,0% negro · 205 MB GL
//
// O sea: el compilador de shaders de ese driver no digiere el compute de vello
// por NINGÚN camino, y sólo digiere los pipelines de render de vello_hybrid por
// GLES. Una sola celda de esa matriz llega a la pantalla, y ésta es la pieza
// que la usa.
//
// ## Lo que este crate NO reinventa
//
// La mitad difícil ya estaba escrita. `vello::Scene` es un buffer CODIFICADO y
// `vello_hybrid::Scene` es imperativa y con estado — la misma brecha que
// `llimphi-cpu` cerró para vello_cpu, con la misma forma de solución. Así que
// acá se reusa su [`Escena`](llimphi_cpu::Escena) grabadora tal cual, y lo
// único propio es el reproductor.
//
// Que se pueda reusar no es suerte: `vello_cpu::RenderContext` y
// `vello_hybrid::Scene` son la MISMA API, método por método —`set_paint`,
// `set_transform`, `fill_path`, `push_layer` con la misma firma de cinco
// `Option`—, porque las dos son fachadas del mismo `vello_common`. De ahí que
// este pintor sea casi renglón por renglón el de `llimphi-cpu`.
//
// ## Las dos cosas que sí cambian, y por qué
//
// **Las imágenes.** vello_cpu muestrea un `Pixmap` en RAM; hybrid quiere la
// imagen ya subida a un atlas de texturas y referida por `ImageId` (los propios
// autores marcan el pixmap como fuera de alcance en `scene.rs`). Eso parte el
// trabajo en dos pasadas —subir, después reproducir—, porque subir necesita
// `device`/`queue`/`encoder` y reproducir no. Ver [`Pintor::pintar`].
//
// **La máscara de luminancia.** vello_hybrid directamente no soporta máscaras
// —`unimplemented!()` en `scene.rs:921`—, así que hay que rodearla. El rodeo
// resulta exacto: la máscara siempre nace de una FORMA, y la luminancia de una
// forma blanca sobre negro es idéntica a la cobertura de un recorte por esa
// misma forma. Ver [`Pintor::pintar`] y el comentario del método.
//
// ## Lo que falta cubrir
//
// El texto (`Orden::Glifos`) está implementado pero **sin test**: probarlo
// headless exigiría una fuente, y el puente que produce esas órdenes vive en
// `llimphi-text`. Se verifica de punta a punta en el teléfono (F2), que es
// donde de todos modos hay que mirarlo. Las otras seis órdenes sí tienen test
// con conteo exacto de píxeles contra GPU real.
// =============================================================================
mod pintor;
#[cfg(test)]
mod pruebas;
pub use pintor::{limites_seguros, Pintor, RenderError};
// Se reexporta el sustrato por el mismo motivo que lo hace `llimphi-cpu`: para
// que quien use este crate no declare `vello_hybrid` ni `wgpu` por su cuenta y
// termine con DOS versiones y un «expected Device, found Device». Acá el riesgo
// vale aunque hoy el workspace entero comparta wgpu 29: un consumidor que
// declare `wgpu` suelto puede derivar de esa versión sin que nadie lo note.
pub use vello_hybrid;
pub use wgpu;
// Y la grabadora, para que el consumidor no tenga que saber que vive en otro
// crate: desde afuera, grabar y reproducir son una sola pieza.
pub use llimphi_cpu::{escena, Escena, Forma, Glifo, Orden};
+514
View File
@@ -0,0 +1,514 @@
// =============================================================================
// pintor :: reproducir una `Escena` grabada sobre `vello_hybrid`
// -----------------------------------------------------------------------------
// Hermano del `llimphi_cpu::pintor`, y a propósito casi renglón por renglón: la
// `Escena` guarda órdenes autocontenidas —cada una trae su transform, su pincel
// y su forma— y la `Scene` de hybrid tiene ESTADO, así que reproducir es, para
// cada orden, fijar el estado que declara y disparar la primitiva.
//
// Igual que allá, no se intenta minimizar los `set_*` comparando con el estado
// anterior. Sigue siendo prematuro: escribir un `Affine` en un struct no se ve
// al lado de rasterizar el path que viene atrás.
//
// Lo único que NO es igual son las imágenes, y la diferencia es estructural:
// hybrid las quiere en un atlas de la GPU, y subirlas necesita `device`/`queue`/
// `encoder`. De ahí las dos pasadas de [`Pintor::pintar`].
// =============================================================================
use std::collections::HashMap;
use std::sync::Arc;
use llimphi_cpu::escena::{Escena, Forma, Orden};
use llimphi_cpu::kurbo::{Affine, Rect};
use llimphi_cpu::peniko::{Brush, Color, Fill, ImageData};
use vello_common::paint::{Image, ImageId, ImageSource};
use vello_common::pixmap::Pixmap;
pub use vello_hybrid::RenderError;
/// El rasterizador: una `Scene` de vello_hybrid con su `Renderer` y su atlas,
/// lista para recibir escenas una y otra vez.
///
/// Se sostiene entre fotogramas a propósito, y acá importa más que en el pintor
/// CPU: el `Renderer` es dueño del **atlas de texturas**, así que recrearlo
/// tiraría todas las imágenes subidas y las obligaría a volver a cruzar el bus.
pub struct Pintor {
renderer: vello_hybrid::Renderer,
recursos: vello_hybrid::Resources,
/// La escena de hybrid, reusada entre frames. `reset()` conserva los búferes
/// internos; recrearla por frame sería regalarle trabajo al asignador.
gpu: vello_hybrid::Scene,
/// Imágenes ya subidas al atlas, por `Blob::id()` de peniko.
///
/// La clave es la identidad del blob y no su contenido: peniko ya garantiza
/// que dos `Blob` con el mismo id son el mismo buffer, y hashear los bytes
/// de cada imagen en cada frame sería exactamente el costo que este caché
/// existe para evitar.
imagenes: HashMap<u64, ImageId>,
ancho: u32,
alto: u32,
}
impl Pintor {
/// Un pintor que rinde a un destino de `ancho` x `alto` en `formato`.
///
/// `formato` tiene que ser el de la textura contra la que se va a rendir —el
/// del swapchain, normalmente—, porque el `Renderer` compila sus pipelines
/// contra él en este mismo llamado. En el Mali eso es justamente lo que hay
/// que sobrevivir: es acá donde el driver decide si el proceso sigue vivo.
pub fn new(
device: &wgpu::Device,
backend: wgpu::Backend,
formato: wgpu::TextureFormat,
ancho: u32,
alto: u32,
) -> Self {
let (ancho, alto) = (ancho.max(1), alto.max(1));
Self {
renderer: vello_hybrid::Renderer::new_with(
device,
&vello_hybrid::RenderTargetConfig {
format: formato,
width: ancho,
height: alto,
},
ajustes_para(backend),
),
recursos: vello_hybrid::Resources::new(),
gpu: vello_hybrid::Scene::new(dim(ancho), dim(alto)),
imagenes: HashMap::new(),
ancho,
alto,
}
}
/// Ancho del destino en píxeles.
pub fn ancho(&self) -> u32 {
self.ancho
}
/// Alto del destino en píxeles.
pub fn alto(&self) -> u32 {
self.alto
}
/// Ajusta el pintor a un destino de otro tamaño.
///
/// **No recrea el `Renderer`, y eso es lo importante.** El `width`/`height`
/// del `RenderTargetConfig` sólo siembra un buffer de configuración al
/// construirlo; el tamaño de cada fotograma viaja aparte, en el `RenderSize`
/// que recibe `render()`. Así que redimensionar es recrear la `Scene` —CPU
/// pura— y nada más.
///
/// Recrear el `Renderer` en cambio **recompilaría los pipelines**, que es
/// justo la operación que en el Mali del teléfono mata el proceso
/// (`cmpbe_v2_compile_multiple_shaders`, ver `../android/DIAGNOSTICO.md`).
/// Un rotar la pantalla no puede costar eso. De paso conserva el atlas de
/// imágenes, que si no habría que volver a subir entero por el bus.
///
/// Sin cambio real no toca nada. La guarda no es higiene: reconfigurar por
/// gusto fue el bug de `WinitSurface::resize` que se llevó una noche entera
/// (41.335 reconfiguraciones en 13 s).
pub fn redimensionar(&mut self, ancho: u32, alto: u32) {
let (ancho, alto) = (ancho.max(1), alto.max(1));
if (ancho, alto) == (self.ancho, self.alto) {
return;
}
self.ancho = ancho;
self.alto = alto;
self.gpu = vello_hybrid::Scene::new(dim(ancho), dim(alto));
}
/// Reproduce la escena y la rinde sobre `vista`.
///
/// Va en dos pasadas y no es un rodeo: la primera sube al atlas las imágenes
/// que aún no están —lo único que necesita `device`/`queue`/`encoder`— y la
/// segunda reproduce contra la `Scene`, que es CPU pura. Mezclarlas obligaría
/// a llevar los tres prestados por todo el reproductor para un caso que en
/// una UI se da un puñado de veces por frame y casi siempre pega en el caché.
///
/// `vista` tiene que ser del formato y tamaño con los que se construyó el
/// pintor.
pub fn pintar(
&mut self,
escena: &Escena,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
vista: &wgpu::TextureView,
color_base: Color,
) -> Result<(), RenderError> {
self.subir_imagenes(escena, device, queue, encoder);
self.gpu.reset();
self.pintar_base(color_base);
for orden in escena.ordenes() {
self.ejecutar(orden);
}
self.renderer.render(
&self.gpu,
&mut self.recursos,
device,
queue,
encoder,
&vello_hybrid::RenderSize {
width: self.ancho,
height: self.alto,
},
vista,
&vello_hybrid::TextureBindings::new(),
)
}
// -------------------------------------------------------------------------
/// Pinta `color_base` a sangre como primera orden de la escena.
///
/// **Acá hubo un error mío que costó medio diagnóstico, y queda escrito para
/// que nadie lo rehaga.** La versión anterior hacía esto con un pase de
/// `wgpu` propio, sólo por su `LoadOp::Clear`, sobre esta premisa: que
/// `vello::Renderer` limpia su target con el `base_color` de los
/// `RenderParams` y el de `vello_hybrid` entra con `LoadOp::Load`, o sea
/// componiendo sobre lo que ya había.
///
/// **La premisa es falsa.** `vello_hybrid` limpia el attachment por su
/// cuenta, así que pisaba el clear y el `color_base` **no llegaba nunca a
/// la pantalla**: donde la escena no pintaba quedaba negro, no el color
/// pedido. Y era invisible en la práctica porque el caso normal es que el
/// `color_base` coincida con el fondo que igual pinta la vista raíz — los
/// dos se ven idénticos hasta que se los hace distintos a propósito.
/// `el_color_base_pinta_donde_la_escena_no_llega` los hace distintos.
///
/// El corolario también se cae: la objeción de que «un relleno transparente
/// no escribe nada y el contenido viejo sobrevive» no aplica, porque el
/// contenido viejo ya no sobrevive — lo borra hybrid. Por eso con alfa cero
/// no hay nada que hacer y esto se saltea.
fn pintar_base(&mut self, color: Color) {
if color.components[3] <= 0.0 {
return;
}
self.gpu.set_transform(Affine::IDENTITY);
self.gpu.reset_paint_transform();
self.gpu.set_paint(color);
self.gpu.fill_rect(&Rect::new(
0.0,
0.0,
f64::from(self.ancho),
f64::from(self.alto),
));
}
/// Sube al atlas toda imagen de la escena que todavía no esté.
///
/// Recorre tanto `draw_image` como los pinceles-imagen de `fill`/`stroke`:
/// son dos caminos distintos hasta la misma textura, y olvidar el segundo
/// dejaría el degradado-imagen dibujando transparente sin decir por qué.
fn subir_imagenes(
&mut self,
escena: &Escena,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
) {
for orden in escena.ordenes() {
let datos = match orden {
Orden::Imagen { imagen, .. } => Some(&imagen.image),
Orden::Rellenar { pincel, .. } | Orden::Trazar { pincel, .. } => match pincel {
Brush::Image(img) => Some(&img.image),
_ => None,
},
_ => None,
};
let Some(datos) = datos else { continue };
if self.imagenes.contains_key(&datos.data.id()) {
continue;
}
let Some(pixmap) = pixmap_de(datos) else { continue };
let id = self
.renderer
.upload_image(&mut self.recursos, device, queue, encoder, &pixmap);
self.imagenes.insert(datos.data.id(), id);
}
}
fn ejecutar(&mut self, orden: &Orden) {
match orden {
Orden::Rellenar {
regla,
xf,
pincel,
xf_pincel,
forma,
} => {
self.gpu.set_fill_rule(*regla);
self.gpu.set_transform(*xf);
fijar_pincel(&mut self.gpu, &self.imagenes, pincel, *xf_pincel);
match forma {
Forma::Rect(r) => self.gpu.fill_rect(r),
otra => self.gpu.fill_path(&otra.a_camino()),
}
}
Orden::Trazar {
estilo,
xf,
pincel,
xf_pincel,
forma,
} => {
self.gpu.set_stroke(estilo.clone());
self.gpu.set_transform(*xf);
fijar_pincel(&mut self.gpu, &self.imagenes, pincel, *xf_pincel);
match forma {
Forma::Rect(r) => self.gpu.stroke_rect(r),
otra => self.gpu.stroke_path(&otra.a_camino()),
}
}
Orden::AbrirCapa {
regla,
mezcla,
alfa,
xf,
recorte,
} => {
self.gpu.set_fill_rule(*regla);
self.gpu.set_transform(*xf);
self.gpu.push_layer(
Some(&recorte.a_camino()),
Some(*mezcla),
Some(*alfa),
None,
None,
);
}
Orden::AbrirMascaraLuminancia {
regla,
alfa,
xf,
recorte,
} => self.abrir_mascara_luminancia(*regla, *alfa, *xf, recorte),
Orden::CerrarCapa => self.gpu.pop_layer(),
Orden::Imagen { imagen, xf } => {
// Si la imagen no llegó al atlas —blob mal formado, o más grande
// de lo que el atlas admite— se saltea el dibujo en vez de pintar
// un rectángulo de basura o abortar el fotograma.
let Some(id) = self.imagenes.get(&imagen.image.data.id()).copied() else {
return;
};
let (w, h) = (
f64::from(imagen.image.width),
f64::from(imagen.image.height),
);
self.gpu.set_transform(*xf);
self.gpu.set_paint(Image {
image: ImageSource::opaque_id(id),
sampler: imagen.sampler,
});
self.gpu.reset_paint_transform();
self.gpu.set_fill_rule(Fill::NonZero);
self.gpu.fill_rect(&Rect::new(0.0, 0.0, w, h));
}
Orden::Glifos {
fuente,
tamano,
xf,
pincel,
hint,
glifos,
} => {
// El pincel y el transform se fijan ANTES de abrir el builder:
// `glyph_run` presta la escena hasta que se consume.
self.gpu.set_transform(*xf);
fijar_pincel(&mut self.gpu, &self.imagenes, pincel, None);
self.gpu
.glyph_run(&mut self.recursos, fuente)
.font_size(*tamano)
.hint(*hint)
.fill_glyphs(glifos.iter().map(|g| glifo::Glyph {
id: g.id,
x: g.x,
y: g.y,
}));
}
Orden::RectDifuso {
xf,
rect,
color,
radio,
desvio,
} => {
self.gpu.set_transform(*xf);
self.gpu.set_paint(*color);
self.gpu.reset_paint_transform();
self.gpu
.fill_blurred_rounded_rect(rect, *radio as f32, *desvio as f32);
}
}
}
/// Abre una capa enmascarada por luminancia.
///
/// **vello_hybrid no soporta máscaras**: su `push_layer` lleva un
/// `unimplemented!()` literal cuando el parámetro `mask` viene con algo
/// (`scene.rs:921`, «Mask is unsupported»). Así que hay que dar la vuelta —y
/// la vuelta resulta ser exacta, no una aproximación.
///
/// El razonamiento: la máscara que este método recibe no es una imagen
/// arbitraria, es **una forma**. El pintor CPU la materializa rasterizando
/// esa forma en blanco sobre negro y tomando la luminancia; eso da 1 adentro,
/// 0 afuera y cobertura parcial en el borde antialiaseado. Que es, dato por
/// dato, lo mismo que produce un recorte por esa misma forma. Se implementa
/// entonces como capa recortada, sin lienzo extra ni rasterizado en CPU.
///
/// La equivalencia depende de que la máscara SIEMPRE nazca de una forma
/// blanca — hoy garantizado porque el único constructor es
/// `Escena::push_luminance_mask_layer`, que recibe un `impl Shape`. Si algún
/// día la `Escena` admite enmascarar con un degradado o una imagen, esto deja
/// de valer y hay que volver a la máscara de verdad.
fn abrir_mascara_luminancia(&mut self, regla: Fill, alfa: f32, xf: Affine, recorte: &Forma) {
self.gpu.set_fill_rule(regla);
self.gpu.set_transform(xf);
self.gpu
.push_layer(Some(&recorte.a_camino()), None, Some(alfa), None, None);
}
}
/// Traduce un pincel de peniko al `PaintType` de vello_common.
///
/// Sólido y degradado son el mismo tipo de un lado y del otro. La variante
/// imagen no: peniko la lleva como bytes crudos y hybrid como un handle al
/// atlas, así que se resuelve por el caché que llenó `subir_imagenes`. Si no
/// está —imagen mal formada—, se cae a transparente en vez de abortar el frame.
fn fijar_pincel(
gpu: &mut vello_hybrid::Scene,
imagenes: &HashMap<u64, ImageId>,
pincel: &Brush,
xf_pincel: Option<Affine>,
) {
// La transformación del pincel es la que ancla un degradado a la caja que lo
// lleva en vez de al origen del mundo. vello la compone como
// `transform * brush_transform`, y hybrid hace lo mismo con su
// `paint_transform`, así que el mapeo es directo.
match xf_pincel {
Some(t) => gpu.set_paint_transform(t),
None => gpu.reset_paint_transform(),
}
match pincel {
Brush::Solid(c) => gpu.set_paint(*c),
Brush::Gradient(g) => gpu.set_paint(g.clone()),
Brush::Image(img) => match imagenes.get(&img.image.data.id()).copied() {
Some(id) => gpu.set_paint(Image {
image: ImageSource::opaque_id(id),
sampler: img.sampler,
}),
None => gpu.set_paint(llimphi_cpu::peniko::color::AlphaColor::<
llimphi_cpu::peniko::color::Srgb,
>::TRANSPARENT),
},
}
}
/// Los ajustes del renderer para un backend dado.
///
/// **Existe por un bug real, no por gusto.** El backend GLES de wgpu decide por
/// *heurística* si una textura es `D2` o `D2Array`: cuando
/// `depth_or_array_layers == 1` la trata como `D2` **aunque se haya creado como
/// array**. El atlas de imágenes de vello_hybrid es un array, así que con una
/// sola capa el muestreo queda desalineado y **las imágenes no se dibujan** —
/// sin error de validación, sin warning, sin nada: salen transparentes y el
/// síntoma es una UI sin iconos.
///
/// vello_hybrid conoce el problema y lo esquiva forzando dos capas, pero sólo
/// bajo `#[cfg(target_arch = "wasm32")]`, porque asume que GLES es sinónimo de
/// WebGL (su propio TODO se pregunta cómo detectar el backend en vez del
/// target). En Android GLES es **nativo**, así que caemos del lado no cubierto.
///
/// Por eso el backend es un parámetro **obligatorio** de [`Pintor::new`] y no un
/// `Default`: olvidarlo no rompe el build ni los tests en una máquina con
/// Vulkan — se cobra callado, y sólo en el teléfono.
/// **Lo que cuesta, medido, y una optimización PROBADA Y DESCARTADA.** El atlas
/// default es de 4096x4096 por capa, o sea 67 MB de memoria GPU por capa;
/// forzar la segunda llevó el `GL mtrack` del demo Android de 219 a 401 MB y los
/// fps de 119 a 57, en un equipo donde el lowmemorykiller ya había matado
/// procesos ajenos. Parece obvio bajar `atlas_size` a 2048 —dos capas sumarían
/// 33 MB, menos que la única capa de antes—, y **no funciona**: probado en el
/// teléfono, la imagen deja de dibujarse otra vez (0 px) y los fps caen a 8. Sin
/// entender por qué, no se toca; el tamaño se deja en el default.
fn ajustes_para(backend: wgpu::Backend) -> vello_hybrid::RenderSettings {
let mut ajustes = vello_hybrid::RenderSettings::default();
if backend == wgpu::Backend::Gl {
ajustes.atlas_config.initial_atlas_count =
ajustes.atlas_config.initial_atlas_count.max(2);
}
ajustes
}
/// Los límites con los que hay que pedir el `Device` para que vello_hybrid
/// rasterice bien. **Usala siempre en vez de `Limits::default().using_resolution(...)`.**
///
/// `vello_hybrid` dimensiona su textura de alfas —donde vive la cobertura
/// parcial de todo camino— con `max_texture_dimension_2d` de ancho, y la
/// direcciona **por máscara y corrimiento**: el shader hace
/// `tex_x = i & (ancho - 1)` y `tex_y = i >> alphas_tex_width_bits`, con
/// `alphas_tex_width_bits = ancho.trailing_zeros()`. Las dos cuentas suponen que
/// el ancho es **potencia de dos**, y nadie lo verifica.
///
/// El Mali-G57 del Redmi reporta **16383**. `16383.trailing_zeros()` es **0**,
/// así que `tex_y = i`: cada texel se busca en una fila muy afuera de la textura
/// y GLES devuelve ceros. Resultado: alfa 0 en toda tira de cobertura parcial, o
/// sea **cero antialiasing** y cada forma perdiendo su primer tile de 4 px. Sin
/// error de validación y sin warning — la escena se ve, sólo que cuantizada a
/// bloques de 4x4 y con los bordes comidos.
///
/// Redondear el techo hacia abajo a la potencia de dos anterior lo arregla
/// entero, medido en metal: el círculo de la regla pasó de 0 a 1.660 píxeles
/// antialiaseados y los caminos volvieron a caer en la columna exacta que se
/// pidió, sin costo de fps (119 antes y después). Ver
/// `02_ruway/llimphi/android/DIAGNOSTICO.md` §F8.
///
/// En una GPU que ya reporta una potencia de dos —o sea, todas las de
/// escritorio— esto no cambia nada.
pub fn limites_seguros(adapter: &wgpu::Adapter) -> wgpu::Limits {
let mut limites = wgpu::Limits::default().using_resolution(adapter.limits());
limites.max_texture_dimension_2d = potencia_de_dos_hacia_abajo(limites.max_texture_dimension_2d);
limites
}
/// La mayor potencia de dos que no pasa a `v`. Con `v == 0` devuelve 1: pedir un
/// techo de cero texeles no es una opción representable, y saturar hacia arriba
/// es menos malo que devolver algo que hace `trailing_zeros() == 32`.
pub(crate) fn potencia_de_dos_hacia_abajo(v: u32) -> u32 {
if v == 0 {
return 1;
}
1 << (u32::BITS - 1 - v.leading_zeros())
}
/// Los pixmaps de vello_common se miden en `u16`. Saturar en vez de truncar:
/// una pantalla de más de 65.535 px no existe, pero un `as u16` a la ligera
/// convertiría un ancho absurdo en uno chiquito y el bug saldría como «se
/// dibuja en una esquina», que es de los caros de diagnosticar.
fn dim(v: u32) -> u16 {
v.clamp(1, u32::from(u16::MAX)) as u16
}
/// Convierte los bytes de una imagen de peniko en un `Pixmap` premultiplicado,
/// que es lo que el atlas sabe recibir.
///
/// Se apoya en `ImageSource::from_peniko_image_data`, que es el conversor de la
/// propia vello_common —premultiplica y reordena canales—, en vez de repetirlo
/// acá. Lo único que agrega esta función es la guarda de tamaño: ese conversor
/// **paniquea** si la imagen pasa de `u16::MAX` en algún lado, y un blob raro no
/// debe tumbar el proceso.
fn pixmap_de(datos: &ImageData) -> Option<Arc<Pixmap>> {
if datos.width > u32::from(u16::MAX) || datos.height > u32::from(u16::MAX) {
return None;
}
if datos.width == 0 || datos.height == 0 {
return None;
}
match ImageSource::from_peniko_image_data(datos) {
ImageSource::Pixmap(p) => Some(p),
ImageSource::OpaqueId { .. } => None,
}
}
+711
View File
@@ -0,0 +1,711 @@
// =============================================================================
// pruebas :: que los píxeles salgan, y contarlos como texto
// -----------------------------------------------------------------------------
// La Regla 8 del repo dice que renderizar a PNG y MIRARLO es el último recurso,
// no el primero, porque cuesta tokens. Estas pruebas son la forma cara-cero de
// la misma evidencia: se rinde de verdad contra la GPU, se lee el framebuffer
// de vuelta y se cuentan los píxeles POR COLOR. Un histograma es una aserción;
// una captura es una opinión.
//
// Todas necesitan una GPU real. Si no hay adaptador —CI headless sin
// Vulkan/GL— se saltean con un aviso en vez de fallar: un test rojo por falta
// de hardware enseña a ignorar los rojos, que es peor que no tener el test.
// =============================================================================
use std::collections::HashMap;
use llimphi_cpu::kurbo::{Affine, Rect, RoundedRect};
use llimphi_cpu::peniko::color::{AlphaColor, Srgb};
use llimphi_cpu::peniko::Fill;
use llimphi_cpu::Escena;
use crate::Pintor;
const ANCHO: u32 = 256;
const ALTO: u32 = 256;
fn color(r: u8, g: u8, b: u8) -> AlphaColor<Srgb> {
AlphaColor::<Srgb>::from_rgba8(r, g, b, 255)
}
/// El stack GPU mínimo para rendir a una textura y leerla de vuelta.
///
/// Devuelve `None` si la máquina no ofrece adaptador — ver el encabezado.
struct Banco {
device: wgpu::Device,
queue: wgpu::Queue,
/// El backend elegido: el `Pintor` lo necesita para configurar el atlas —
/// ver `ajustes_para` en `pintor.rs`.
backend: wgpu::Backend,
}
impl Banco {
fn nuevo() -> Option<Self> {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
// `WGPU_BACKEND=gl` para reproducir el camino de Android desde la
// laptop. No es un lujo: el defecto de bordes que persigue
// `el_relleno_a_sangre_cubre_los_bordes_laterales` **no aparece** por
// Vulkan y el teléfono va por GLES, así que sin esta perilla el test
// corre siempre por el backend que no tiene el problema.
if let Some(b) = wgpu::Backends::from_env() {
desc.backends = b;
}
let instancia = wgpu::Instance::new(desc);
let adapter =
pollster::block_on(instancia.request_adapter(&wgpu::RequestAdapterOptions::default()))
.ok()?;
// Se anuncia el adaptador elegido: un `WGPU_BACKEND` mal escrito se
// ignora en silencio, y entonces «probé por GLES» sería mentira.
let info = adapter.get_info();
eprintln!(
"banco sobre {:?} · {} · {}",
info.backend, info.name, info.driver_info
);
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("llimphi-hybrid-pruebas"),
..Default::default()
}))
.ok()?;
// Sin esto wgpu se traga los errores de validación y una prueba que
// falla no dice por qué: el síntoma llega como píxeles que no están.
device.on_uncaptured_error(std::sync::Arc::new(|e| eprintln!("WGPU ERROR: {e}")));
eprintln!(
" límites: max_texture_array_layers={} max_texture_dimension_2d={}",
adapter.limits().max_texture_array_layers,
adapter.limits().max_texture_dimension_2d
);
Some(Self {
device,
queue,
backend: info.backend,
})
}
/// Rinde la escena y devuelve el framebuffer en RGBA8, fila por fila sin
/// relleno (el padding a 256 bytes que exige `copy_texture_to_buffer` se
/// recorta acá para que el llamador cuente píxeles y no bytes de alineación).
fn rendir(&self, escena: &Escena) -> Vec<u8> {
self.rendir_en(escena, ANCHO, ALTO)
}
/// Igual que `rendir`, pero a la medida que se le pida.
///
/// Existe porque el tamaño **importa**: 256 es múltiplo de 16 y de 64, así
/// que el caso cómodo esconde los dos problemas de borde que hay acá —el
/// de vello_hybrid, que se mide en `el_relleno_a_sangre_cubre_los_bordes`,
/// y el de la alineación a 256 B que exige `copy_texture_to_buffer`.
fn rendir_en(&self, escena: &Escena, ancho: u32, alto: u32) -> Vec<u8> {
// Negro OPACO como base: así un píxel negro significa «nadie pintó
// acá», que es lo que afirman casi todas las pruebas de este archivo.
self.rendir_con_base(escena, ancho, alto, color(0, 0, 0))
}
/// Igual, pero eligiendo el `color_base` que recibe `Pintor::pintar`.
///
/// Lo necesita `el_color_base_pinta_donde_la_escena_no_llega`, que tiene que
/// poder distinguir el color del clear del color que pinta la escena — con
/// los dos iguales, un clear que no hace nada se ve idéntico a uno que sí.
fn rendir_con_base(&self, escena: &Escena, ancho: u32, alto: u32, base: AlphaColor<Srgb>) -> Vec<u8> {
let formato = wgpu::TextureFormat::Rgba8Unorm;
let textura = self.device.create_texture(&wgpu::TextureDescriptor {
label: Some("destino"),
size: wgpu::Extent3d {
width: ancho,
height: alto,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: formato,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let vista = textura.create_view(&wgpu::TextureViewDescriptor::default());
// `copy_texture_to_buffer` exige que `bytes_per_row` sea múltiplo de
// 256, y un ancho cualquiera no lo cumple (1080 * 4 = 4320, que no lo
// es). Se redondea para arriba y el relleno se recorta al leer, para
// que quien cuente píxeles no tenga que saber nada de alineación.
let bytes_utiles = ancho * 4;
let bytes_por_fila = bytes_utiles.div_ceil(256) * 256;
let lectura = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("lectura"),
size: u64::from(bytes_por_fila * alto),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut pintor = Pintor::new(&self.device, self.backend, formato, ancho, alto);
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
pintor
.pintar(
escena,
&self.device,
&self.queue,
&mut encoder,
&vista,
base,
)
.expect("vello_hybrid no debe fallar al rendir");
encoder.copy_texture_to_buffer(
textura.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &lectura,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(bytes_por_fila),
rows_per_image: Some(alto),
},
},
wgpu::Extent3d {
width: ancho,
height: alto,
depth_or_array_layers: 1,
},
);
self.queue.submit(std::iter::once(encoder.finish()));
let rebanada = lectura.slice(..);
rebanada.map_async(wgpu::MapMode::Read, |_| {});
// Sin `submission_index` espera a que se vacíe TODO lo encolado, que es
// justo lo que hace falta acá; sin `timeout`, indefinidamente. Un test
// que se cuelga se diagnostica; uno que lee un buffer a medio copiar da
// un histograma casi correcto, que es mucho peor.
self.device
.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
})
.expect("la GPU debe terminar la copia");
// `to_vec` copia y el préstamo muere en el mismo statement, así que
// `unmap` no tiene con qué chocar.
let datos = rebanada.get_mapped_range().to_vec();
lectura.unmap();
// Recortar el relleno de alineación: el llamador recibe filas contiguas
// de `ancho * 4` bytes, como si wgpu no tuviera esa exigencia.
if bytes_por_fila == bytes_utiles {
return datos;
}
let mut apretado = Vec::with_capacity((bytes_utiles * alto) as usize);
for fila in datos
.chunks_exact(bytes_por_fila as usize)
.take(alto as usize)
{
apretado.extend_from_slice(&fila[..bytes_utiles as usize]);
}
apretado
}
}
/// Cuántos píxeles hay de cada color RGB (se ignora el alfa, que en estas
/// escenas es siempre opaco).
fn histograma(pixeles: &[u8]) -> HashMap<(u8, u8, u8), usize> {
let mut h = HashMap::new();
for p in pixeles.chunks_exact(4) {
*h.entry((p[0], p[1], p[2])).or_insert(0) += 1;
}
h
}
/// El caso base: tres rectángulos de colores planos sobre un fondo.
///
/// Es a propósito la misma escena que el probe que corrió en el Mali a 119 fps,
/// para que el número de acá y el del teléfono se puedan comparar de frente.
#[test]
fn rellenos_planos_llegan_a_los_pixeles() {
let Some(banco) = Banco::nuevo() else {
eprintln!("sin adaptador wgpu: se saltea");
return;
};
let fondo = color(60, 61, 63);
let (a, b) = (color(200, 60, 90), color(60, 170, 120));
let mut escena = Escena::new();
let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO));
escena.fill(Fill::NonZero, Affine::IDENTITY, fondo, None, &todo);
// Coordenadas enteras: sin bordes fraccionarios no hay antialiasing, y el
// histograma da cuentas exactas en vez de una nube de tonos intermedios.
escena.fill(
Fill::NonZero,
Affine::IDENTITY,
a,
None,
&Rect::new(32.0, 32.0, 224.0, 96.0),
);
escena.fill(
Fill::NonZero,
Affine::IDENTITY,
b,
None,
&Rect::new(32.0, 128.0, 224.0, 192.0),
);
let h = histograma(&banco.rendir(&escena));
// 192 x 64 = 12.288 px cada barra, exactos.
assert_eq!(h.get(&(200, 60, 90)).copied().unwrap_or(0), 12_288, "barra A");
assert_eq!(h.get(&(60, 170, 120)).copied().unwrap_or(0), 12_288, "barra B");
assert_eq!(
h.get(&(60, 61, 63)).copied().unwrap_or(0),
(ANCHO * ALTO) as usize - 2 * 12_288,
"fondo"
);
assert_eq!(h.get(&(0, 0, 0)).copied().unwrap_or(0), 0, "no debe quedar negro");
}
/// Una capa recortada tiene que recortar: lo que se pinta fuera del clip no
/// llega al framebuffer.
///
/// Es la orden que más usa el compositor después de `fill` —cada panel, cada
/// scroll, cada esquina redondeada abre una— y la que más caro sale si falla,
/// porque el síntoma es «se pinta encima de todo» y no un error.
#[test]
fn la_capa_recorta_de_verdad() {
let Some(banco) = Banco::nuevo() else {
eprintln!("sin adaptador wgpu: se saltea");
return;
};
let fondo = color(10, 20, 30);
let tinta = color(250, 240, 230);
let mut escena = Escena::new();
let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO));
escena.fill(Fill::NonZero, Affine::IDENTITY, fondo, None, &todo);
escena.push_layer(
Fill::NonZero,
llimphi_cpu::peniko::Mix::Normal,
1.0,
Affine::IDENTITY,
&Rect::new(64.0, 64.0, 192.0, 192.0),
);
// Se pinta la pantalla ENTERA: si el recorte no funciona, el fondo desaparece.
escena.fill(Fill::NonZero, Affine::IDENTITY, tinta, None, &todo);
escena.pop_layer();
let h = histograma(&banco.rendir(&escena));
assert_eq!(
h.get(&(250, 240, 230)).copied().unwrap_or(0),
128 * 128,
"la tinta debe quedar confinada al clip de 128x128"
);
assert_eq!(
h.get(&(10, 20, 30)).copied().unwrap_or(0),
(ANCHO * ALTO) as usize - 128 * 128,
"el resto debe seguir siendo fondo"
);
}
/// Una capa con alfa atenúa lo de adentro sin tocar lo de afuera.
///
/// Es la mitad de `push_layer` que el recorte no cubre, y la que usa toda
/// animación de aparecer/desaparecer del catálogo.
#[test]
fn la_capa_con_alfa_atenua() {
let Some(banco) = Banco::nuevo() else {
eprintln!("sin adaptador wgpu: se saltea");
return;
};
let mut escena = Escena::new();
let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO));
escena.fill(Fill::NonZero, Affine::IDENTITY, color(0, 0, 0), None, &todo);
escena.push_layer(
Fill::NonZero,
llimphi_cpu::peniko::Mix::Normal,
0.5,
Affine::IDENTITY,
&todo,
);
escena.fill(
Fill::NonZero,
Affine::IDENTITY,
color(255, 255, 255),
None,
&Rect::new(64.0, 64.0, 192.0, 192.0),
);
escena.pop_layer();
let pixeles = banco.rendir(&escena);
let i = ((128 * ANCHO + 128) * 4) as usize;
let gris = pixeles[i];
// Blanco al 50% sobre negro. Se admite ±2 por el redondeo del pipeline u8;
// exigir 127 clavado sería atarse a un detalle de implementación de vello.
assert!(
(126..=129).contains(&gris),
"el centro debería quedar a media luz, salió {gris}"
);
assert_eq!(
(pixeles[i], pixeles[i + 1], pixeles[i + 2]),
(gris, gris, gris),
"la atenuación debe ser pareja en los tres canales"
);
}
/// El trazo dibuja contorno y no relleno.
#[test]
fn el_trazo_deja_hueco_adentro() {
let Some(banco) = Banco::nuevo() else {
eprintln!("sin adaptador wgpu: se saltea");
return;
};
let fondo = color(9, 9, 9);
let mut escena = Escena::new();
escena.fill(
Fill::NonZero,
Affine::IDENTITY,
fondo,
None,
&Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)),
);
escena.stroke(
&llimphi_cpu::kurbo::Stroke::new(8.0),
Affine::IDENTITY,
color(240, 30, 30),
None,
&Rect::new(64.0, 64.0, 192.0, 192.0),
);
let pixeles = banco.rendir(&escena);
let en = |x: u32, y: u32| {
let i = ((y * ANCHO + x) * 4) as usize;
(pixeles[i], pixeles[i + 1], pixeles[i + 2])
};
assert_eq!(en(128, 64), (240, 30, 30), "el borde de arriba debe estar pintado");
assert_eq!(en(128, 128), (9, 9, 9), "el centro debe seguir siendo fondo");
}
/// **El camino nuevo de este crate**: una imagen sube al atlas de la GPU y se
/// dibuja desde ahí.
///
/// Es lo único que no se hereda del pintor CPU —allá la imagen se muestrea de un
/// `Pixmap` en RAM— así que es donde vale la pena gastar un test. Los bytes se
/// sintetizan acá: no hace falta ningún archivo en disco para ejercitar
/// `upload_image` + `ImageSource::opaque_id`.
#[test]
fn una_imagen_cruza_por_el_atlas() {
let Some(banco) = Banco::nuevo() else {
eprintln!("sin adaptador wgpu: se saltea");
return;
};
const LADO: u32 = 32;
let bytes: Vec<u8> = (0..LADO * LADO)
.flat_map(|_| [17_u8, 200, 90, 255])
.collect();
let imagen = llimphi_cpu::peniko::ImageBrush {
image: llimphi_cpu::peniko::ImageData {
data: llimphi_cpu::peniko::Blob::from(bytes),
format: llimphi_cpu::peniko::ImageFormat::Rgba8,
// Alfa separado (no premultiplicado): el caso normal de un PNG, y
// el que obliga al conversor a hacer la multiplicación.
alpha_type: llimphi_cpu::peniko::ImageAlphaType::Alpha,
width: LADO,
height: LADO,
},
sampler: llimphi_cpu::peniko::ImageSampler::default(),
};
let mut escena = Escena::new();
escena.fill(
Fill::NonZero,
Affine::IDENTITY,
color(0, 0, 0),
None,
&Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)),
);
escena.draw_image(&imagen, Affine::translate((64.0, 64.0)));
let h = histograma(&banco.rendir(&escena));
// Qué llegó, no sólo cuánto: un «0» pelado no distingue «la imagen no se
// dibujó» de «se dibujó de otro color», y son diagnósticos opuestos.
let mut top: Vec<_> = h.iter().collect();
top.sort_by_key(|(_, n)| std::cmp::Reverse(**n));
eprintln!("colores presentes (top 4): {:?}", &top[..top.len().min(4)]);
assert_eq!(
h.get(&(17, 200, 90)).copied().unwrap_or(0),
(LADO * LADO) as usize,
"la imagen debe llegar entera y con su color exacto"
);
}
/// La máscara de luminancia: rasterizada en CPU, consumida por la GPU.
///
/// El cruce sólo es posible porque vello_cpu y vello_hybrid cuelgan del mismo
/// `vello_common`, así que la `Mask` viaja sin conversión. Si algún día esa
/// unificación se rompe, este test es el que avisa.
#[test]
fn la_mascara_de_luminancia_recorta_por_brillo() {
let Some(banco) = Banco::nuevo() else {
eprintln!("sin adaptador wgpu: se saltea");
return;
};
let mut escena = Escena::new();
let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO));
escena.fill(Fill::NonZero, Affine::IDENTITY, color(20, 20, 20), None, &todo);
escena.push_luminance_mask_layer(
Fill::NonZero,
1.0,
Affine::IDENTITY,
&Rect::new(64.0, 64.0, 192.0, 192.0),
);
escena.fill(Fill::NonZero, Affine::IDENTITY, color(250, 250, 250), None, &todo);
escena.pop_layer();
let pixeles = banco.rendir(&escena);
let en = |x: u32, y: u32| {
let i = ((y * ANCHO + x) * 4) as usize;
pixeles[i]
};
assert!(
en(128, 128) > 200,
"dentro de la máscara (blanca) debe pasar la tinta, salió {}",
en(128, 128)
);
assert_eq!(
en(16, 16),
20,
"fuera de la máscara (negra) no debe pasar nada"
);
}
/// `append` con transformación: la razón de ser de la grabación.
///
/// Se graba un cuadrado en el origen, se lo incorpora trasladado, y se verifica
/// que los píxeles aparecieron **donde los mandó la traslación** y no donde se
/// dibujaron. Sin esto el compositor no puede reciclar subárboles cacheados.
#[test]
fn append_traslada_lo_grabado() {
let Some(banco) = Banco::nuevo() else {
eprintln!("sin adaptador wgpu: se saltea");
return;
};
let tinta = color(11, 222, 33);
let mut hija = Escena::new();
hija.fill(
Fill::NonZero,
Affine::IDENTITY,
tinta,
None,
&Rect::new(0.0, 0.0, 64.0, 64.0),
);
let mut madre = Escena::new();
madre.fill(
Fill::NonZero,
Affine::IDENTITY,
color(0, 0, 1),
None,
&Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)),
);
madre.append(&hija, Some(Affine::translate((128.0, 128.0))));
let pixeles = banco.rendir(&madre);
let en = |x: u32, y: u32| {
let i = ((y * ANCHO + x) * 4) as usize;
(pixeles[i], pixeles[i + 1], pixeles[i + 2])
};
assert_eq!(en(160, 160), (11, 222, 33), "debe haber pintado en el destino");
assert_eq!(en(32, 32), (0, 0, 1), "el origen debe haber quedado limpio");
assert_eq!(
histograma(&pixeles).get(&(11, 222, 33)).copied().unwrap_or(0),
64 * 64,
"el cuadrado no debe cambiar de tamaño al trasladarse"
);
}
/// **Un relleno a sangre tiene que llegar a las columnas del borde.**
///
/// Nació de un defecto medido en metal (Redmi Note 13 Pro, Mali-G57 MC2, a
/// 1080x2400): las 8 columnas laterales —`x 0..3` y `x 1076..1079`— salían
/// negro puro aunque la vista raíz cubría el ancho entero con su fondo. El eje
/// vertical **no** tenía el problema, y el `Pintor` pasa el ancho completo en el
/// `RenderSize` sin truncar en ningún lado.
///
/// La hipótesis con aritmética a favor era el ancho: 2400 es múltiplo de 16 y
/// 1080 no (67,5). Por eso el barrido mezcla a propósito anchos que **son**
/// múltiplos de 16 con otros que no: si la hipótesis es buena, sólo los que no
/// lo son pierden columnas, y el test queda como la prueba de regresión.
#[test]
fn el_relleno_a_sangre_cubre_los_bordes_laterales() {
let Some(banco) = Banco::nuevo() else {
eprintln!("sin adaptador wgpu: se saltea");
return;
};
const ALTO_P: u32 = 64;
let relleno = color(60, 170, 120);
let mut fallos = Vec::new();
for ancho in [256_u32, 1000, 1072, 1080, 1088] {
let mut escena = Escena::new();
escena.fill(
Fill::NonZero,
Affine::IDENTITY,
relleno,
None,
&Rect::new(0.0, 0.0, f64::from(ancho), f64::from(ALTO_P)),
);
let px = banco.rendir_en(&escena, ancho, ALTO_P);
// El banco limpia a negro OPACO, así que negro = nadie pintó acá. Una
// columna cuenta como vacía sólo si NINGUNA de sus filas se pintó.
let vacia = |x: u32| {
(0..ALTO_P).all(|y| {
let i = ((y * ancho + x) * 4) as usize;
px[i] == 0 && px[i + 1] == 0 && px[i + 2] == 0
})
};
let izq = (0..ancho).take_while(|&x| vacia(x)).count();
let der = (0..ancho).rev().take_while(|&x| vacia(x)).count();
eprintln!(
"ancho {ancho:5} (múltiplo de 16: {:5}) → vacías: {izq} izq · {der} der",
ancho % 16 == 0
);
if izq != 0 || der != 0 {
fallos.push((ancho, izq, der));
}
}
assert!(
fallos.is_empty(),
"un relleno a sangre dejó columnas sin pintar (ancho, izq, der): {fallos:?}"
);
}
/// **El techo de textura se redondea a potencia de dos.**
///
/// No necesita GPU: es la cuenta que decide si `vello_hybrid` sabe direccionar
/// su textura de alfas. El caso que importa es 16383 —lo que reporta el Mali del
/// Redmi—, cuyo `trailing_zeros()` es 0 y deja el corrimiento del shader en
/// nada. Ver `limites_seguros`.
#[test]
fn el_techo_de_textura_se_redondea_a_potencia_de_dos() {
for (entrada, esperado) in [
(16383_u32, 8192_u32),
(16384, 16384),
(8192, 8192),
(8191, 4096),
(1, 1),
(0, 1),
] {
let salida = crate::pintor::potencia_de_dos_hacia_abajo(entrada);
assert_eq!(salida, esperado, "techo para {entrada}");
assert!(
salida.is_power_of_two(),
"{salida} tiene que ser potencia de dos"
);
assert!(
salida <= entrada.max(1),
"el techo no puede subir: {entrada} → {salida}"
);
}
}
/// **El mismo relleno a sangre, pero por el camino que usa el compositor.**
///
/// El test de arriba rellena un `Rect`, que en el Pintor cae en `fill_rect`.
/// El compositor NUNCA emite eso: `render.rs` arma siempre un `RoundedRect`
/// con `node_rrect` —radio 0 incluido—, que se graba como `Forma::Redondeado`
/// y termina en `fill_path`. Son dos rasterizados distintos de la misma
/// geometría, y sólo el segundo es el que pinta el fondo de una app.
#[test]
fn el_relleno_a_sangre_redondeado_cubre_los_bordes_laterales() {
let Some(banco) = Banco::nuevo() else {
eprintln!("sin adaptador wgpu: se saltea");
return;
};
const ALTO_P: u32 = 64;
let relleno = color(60, 170, 120);
let mut fallos = Vec::new();
for ancho in [256_u32, 1000, 1072, 1080, 1088] {
let mut escena = Escena::new();
escena.fill(
Fill::NonZero,
Affine::IDENTITY,
relleno,
None,
&RoundedRect::new(0.0, 0.0, f64::from(ancho), f64::from(ALTO_P), 0.0),
);
let px = banco.rendir_en(&escena, ancho, ALTO_P);
let vacia = |x: u32| {
(0..ALTO_P).all(|y| {
let i = ((y * ancho + x) * 4) as usize;
px[i] == 0 && px[i + 1] == 0 && px[i + 2] == 0
})
};
let izq = (0..ancho).take_while(|&x| vacia(x)).count();
let der = (0..ancho).rev().take_while(|&x| vacia(x)).count();
eprintln!("ancho {ancho:5} redondeado r=0 → vacías: {izq} izq · {der} der");
if izq != 0 || der != 0 {
fallos.push((ancho, izq, der));
}
}
assert!(
fallos.is_empty(),
"el relleno redondeado dejó columnas sin pintar (ancho, izq, der): {fallos:?}"
);
}
/// **El `color_base` tiene que quedar donde la escena no pinta.**
///
/// Es la premisa entera del pase `limpiar` del `Pintor`: `vello::Renderer`
/// limpia su target con el `base_color` de los `RenderParams`, el de
/// `vello_hybrid` —según su doc— entra con `LoadOp::Load`, y por eso el Pintor
/// agrega un `LoadOp::Clear` propio. Si esa premisa fuera falsa, el pase sería
/// tanto inútil como invisible: con `color_base` igual al fondo que pinta la
/// escena —que es el caso normal— los dos se ven exactamente igual.
///
/// Nació de una medición en el Redmi que no cerraba: con el clear cambiado a
/// magenta, la pantalla no tenía **ni un** píxel magenta. Este test hace la
/// misma pregunta donde se puede depurar.
#[test]
fn el_color_base_pinta_donde_la_escena_no_llega() {
let Some(banco) = Banco::nuevo() else {
eprintln!("sin adaptador wgpu: se saltea");
return;
};
// La escena pinta SÓLO un cuadrado central: las esquinas quedan a cargo del
// color base, y son el sitio donde se lee la respuesta.
let mut escena = Escena::new();
escena.fill(
Fill::NonZero,
Affine::IDENTITY,
color(60, 170, 120),
None,
&Rect::new(64.0, 64.0, 192.0, 192.0),
);
let base = color(255, 0, 255);
let px = banco.rendir_con_base(&escena, ANCHO, ALTO, base);
let leer = |x: u32, y: u32| {
let i = ((y * ANCHO + x) * 4) as usize;
(px[i], px[i + 1], px[i + 2])
};
let centro = leer(128, 128);
let esquina = leer(4, 4);
eprintln!("centro {centro:?} · esquina {esquina:?} · base esperada (255, 0, 255)");
assert_eq!(centro, (60, 170, 120), "la escena tiene que pintar el centro");
assert_eq!(
esquina,
(255, 0, 255),
"el color_base tiene que sobrevivir donde la escena no pinta; si sale \
(0,0,0) es que vello_hybrid limpia el attachment por su cuenta y el \
pase `limpiar` del Pintor no sirve para nada"
);
}
+267
View File
@@ -0,0 +1,267 @@
//! **El camino del Mali contra el de referencia: ¿pintan lo mismo?**
//!
//! `llimphi-hybrid` reproduce la `Escena` grabada contra `vello_hybrid`, el
//! rasterizador CPU+GPU **sin compute shaders**. Es el único camino a píxeles en
//! GPUs cuyo compilador no digiere el compute de vello — el Mali-G57 del
//! teléfono, medido en `android/DIAGNOSTICO.md`.
//!
//! El crate ya traía 11 tests, y son buenos — pero todos preguntan lo mismo:
//! *¿hace lo que dice?* «La capa con alfa atenúa», «el trazo deja hueco
//! adentro», «la máscara de luminancia recorta por brillo». Son afirmaciones
//! **absolutas**, verificadas contra la intención.
//!
//! Lo que ninguno pregunta es *¿hace lo MISMO?*. Un `push_layer` que aplicara
//! el alfa un 10 % distinto sigue atenuando, y sigue recortando, y pasa los
//! once — pero el usuario del teléfono vería otra pantalla que el de
//! escritorio. Esa comparación es la que faltaba, y necesita el otro
//! rasterizador al lado, no una lista de propiedades.
//!
//! Acá los dos pintores consumen la **misma `Escena`**, así que no hay
//! compositor ni layout de por medio: lo único que se compara es el
//! rasterizado. Y como la `Escena` se arma a mano con la API de `llimphi-cpu`,
//! este test no necesita ninguno de los backends excluyentes de `llimphi-text`
//! y vive tranquilo dentro del workspace.
//!
//! **Sin glifos, y por la misma razón que el cable no lleva texto**: emitir una
//! orden de texto pide un shaper, y el shaper vive detrás de un backend de
//! `llimphi-text` que este build no tiene. El texto de wawa ya está comparado
//! contra el de Linux en `llimphi-test/tests/cpu_vs_gpu.rs`.
//!
//! Certificado por texto (§8): conteos de píxeles, llvmpipe.
use llimphi_cpu::escena::Escena;
use llimphi_cpu::lienzo::FormatoPixel;
use llimphi_cpu::pintor::Pintor as PintorCpu;
use llimphi_hybrid::Pintor as PintorHybrid;
use llimphi_cpu::kurbo::{Affine, Rect, RoundedRect, Stroke};
use llimphi_cpu::peniko::color::{AlphaColor, Srgb};
use llimphi_cpu::peniko::{BlendMode, Fill};
const ANCHO: u32 = 160;
const ALTO: u32 = 120;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn c(r: u8, g: u8, b: u8, a: u8) -> AlphaColor<Srgb> {
AlphaColor::from_rgba8(r, g, b, a)
}
/// La escena de prueba, armada con las órdenes que una UI de verdad emite: un
/// relleno alineado a la grilla (sin AA), uno redondeado y su trazo (AA de
/// curva), una capa con alfa (composición) y un rect difuso (blur).
///
/// Cada rasgo va en una zona distinta del lienzo para que, si divergen, el
/// conteo por franja diga en cuál.
fn escena_patron() -> Escena {
let mut e = Escena::new();
// 1. Relleno alineado a la grilla.
e.fill(
Fill::NonZero,
Affine::IDENTITY,
c(220, 60, 60, 255),
None,
&Rect::new(8.0, 8.0, 68.0, 34.0),
);
// 2. Redondeado + trazo: acá entra el antialiasing de curvas.
let rr = RoundedRect::new(80.0, 8.0, 150.0, 40.0, 9.0);
e.fill(Fill::NonZero, Affine::IDENTITY, c(60, 200, 120, 255), None, &rr);
e.stroke(
&Stroke::new(2.0),
Affine::IDENTITY,
c(240, 220, 60, 255),
None,
&rr,
);
// 3. Capa con alfa: componer es donde una diferencia de orden se ve entera.
e.push_layer(
Fill::NonZero,
BlendMode::default(),
0.5,
Affine::IDENTITY,
&Rect::new(8.0, 50.0, 150.0, 84.0),
);
e.fill(
Fill::NonZero,
Affine::IDENTITY,
c(240, 80, 40, 255),
None,
&Rect::new(8.0, 50.0, 150.0, 84.0),
);
e.fill(
Fill::NonZero,
Affine::IDENTITY,
c(40, 240, 200, 255),
None,
&Rect::new(20.0, 58.0, 80.0, 76.0),
);
e.pop_layer();
// 4. Rect difuso: el rasgo con más aritmética.
e.draw_blurred_rounded_rect(
Affine::IDENTITY,
Rect::new(30.0, 92.0, 120.0, 112.0),
c(200, 200, 255, 200),
8.0,
4.0,
);
e
}
fn por_cpu(e: &Escena) -> Vec<u8> {
let mut pintor = PintorCpu::new(ANCHO as u16, ALTO as u16);
let mut lienzo = pintor.lienzo_nuevo();
pintor.pintar(e, &mut lienzo);
let mut px = vec![0u8; (ANCHO * ALTO * 4) as usize];
llimphi_cpu::volcar(&lienzo, &mut px, (ANCHO * 4) as usize, FormatoPixel::Rgba8);
px
}
fn por_hybrid(e: &Escena) -> Vec<u8> {
// El adapter de software (llvmpipe), igual que el arnés de `llimphi-test`:
// así el número es el mismo en cualquier máquina y en CI.
let hal = pollster::block_on(llimphi_hal::Hal::new_headless(true)).expect("hal headless");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("hybrid-target"),
size: wgpu::Extent3d { width: ANCHO, height: ALTO, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let vista = target.create_view(&wgpu::TextureViewDescriptor::default());
let backend = hal.adapter.get_info().backend;
let mut pintor = PintorHybrid::new(&hal.device, backend, FMT, ANCHO, ALTO);
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
pintor
.pintar(
e,
&hal.device,
&hal.queue,
&mut enc,
&vista,
c(0, 0, 0, 255),
)
.expect("hybrid rindió");
hal.queue.submit(std::iter::once(enc.finish()));
// Readback con el padding de fila que exige wgpu.
let unpadded = ANCHO as usize * 4;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("hybrid-readback"),
size: (padded * ALTO as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(ALTO),
},
},
wgpu::Extent3d { width: ANCHO, height: ALTO, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut out = Vec::with_capacity((ANCHO * ALTO * 4) as usize);
for fila in 0..ALTO as usize {
let s = fila * padded;
out.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
out
}
/// Píxeles cuyo peor canal difiere más de `tol`.
fn distintos(a: &[u8], b: &[u8], tol: u8) -> usize {
a.chunks_exact(4)
.zip(b.chunks_exact(4))
.filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol))
.count()
}
/// Tinta total: agarra el desvío global de brillo que una tolerancia por píxel
/// puede dejar pasar repartido en muchos píxeles con delta chico.
fn tinta(v: &[u8]) -> u64 {
v.chunks_exact(4)
.map(|p| p[0] as u64 + p[1] as u64 + p[2] as u64)
.sum()
}
#[test]
fn el_rasterizador_sin_compute_pinta_lo_mismo_que_el_de_cpu() {
let e = escena_patron();
let cpu = por_cpu(&e);
let hybrid = por_hybrid(&e);
assert_eq!(cpu.len(), hybrid.len());
let total = (ANCHO * ALTO) as usize;
let escalera: Vec<String> = [0u8, 1, 4, 16, 64, 128]
.iter()
.map(|t| format!("Δ{t}:{}", distintos(&cpu, &hybrid, *t)))
.collect();
let (t_cpu, t_hy) = (tinta(&cpu) as f64, tinta(&hybrid) as f64);
// Evidencia por texto: los números quedan impresos corra o falle.
println!("hybrid vs cpu · {}", escalera.join(" "));
println!("hybrid vs cpu · tinta {:+.2} %", (t_hy / t_cpu - 1.0) * 100.0);
// Guarda del arnés: si la escena no pintara nada, dos lienzos negros
// coincidirían perfecto y el test firmaría cualquier cosa.
let pintados = cpu
.chunks_exact(4)
.filter(|p| p[0] > 8 || p[1] > 8 || p[2] > 8)
.count();
assert!(
pintados > total / 8,
"la escena sólo pintó {pintados} de {total} px; el test no compara un dibujo"
);
// **Umbral sobre lo medido, y salió mejor de lo que se suponía.** Medido el
// 2026-08-05: `Δ0:236 Δ1:0` — o sea que 236 de 19.200 px difieren, y
// **ninguno por más de 1/255**. Dos rasterizadores distintos, uno por CPU y
// otro por GPU sin compute, y la diferencia es redondeo.
//
// Por eso el umbral es duro: **cero píxeles pueden diferir más de Δ1**. Un
// tope del 5 % sobre Δ16, que era lo prudente antes de medir, no habría
// agarrado nunca nada — y un umbral que nada roza no es un gate.
let sobre = distintos(&cpu, &hybrid, 1);
assert_eq!(
sobre, 0,
"{sobre} de {total} px difieren más de Δ1 — el camino del Mali se \
separó del de referencia"
);
let desvio = (t_hy - t_cpu).abs() / t_cpu;
assert!(
desvio <= 0.002,
"la tinta total se corrió {:.2} % — un lado pinta más grueso que el otro",
desvio * 100.0
);
}
+16
View File
@@ -9,4 +9,20 @@ description = "llimphi-icons — set mínimo de iconos vectoriales (BezPath en g
repository.workspace = true
[dependencies]
# El COMPOSITOR, no el shell de escritorio: así los íconos sirven igual en
# Linux, en el kernel de wawa y adentro de una jaula WASM (W4.6).
llimphi-compositor = { workspace = true, default-features = false }
[dev-dependencies]
# El SHELL, sólo para el gallery demo y los tests (W4.6).
llimphi-ui = { workspace = true }
[features]
# El backend lo elige quien ensambla la app; este default existe sólo para
# compilar el crate SOLO. Ver el mismo bloque en cualquier widget (W4.6).
default = ["vello", "std"]
vello = ["llimphi-compositor/vello"]
# `to_svg` (kurbo svg es std-only) y demás exports de escritorio.
std = ["llimphi-compositor/std"]
cpu = ["llimphi-compositor/cpu"]
wire = ["llimphi-compositor/wire"]
+4 -4
View File
@@ -53,8 +53,8 @@ fn cell(icon: AppIcon) -> View<Msg> {
height: length(96.0_f32),
},
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
gap: Size {
width: length(0.0_f32),
height: length(8.0_f32),
@@ -112,8 +112,8 @@ impl App for Gallery {
height: percent(1.0_f32),
},
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
gap: Size {
width: length(0.0_f32),
height: length(14.0_f32),
+16 -7
View File
@@ -23,13 +23,16 @@
//! }
//! ```
use llimphi_ui::llimphi_layout::taffy::{
use llimphi_compositor::llimphi_layout::taffy::{
prelude::{percent, Size, Style},
Position,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Cap, Join, Stroke};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use alloc::format;
use alloc::string::ToString;
use alloc::string::String;
use llimphi_compositor::peniko::kurbo::{Affine, BezPath, Cap, Join, Stroke};
use llimphi_compositor::peniko::Color;
use llimphi_compositor::View;
/// Una app de tawasuyu con icono de marca. El identificador (`name`) coincide
/// con el `id` del `AppEntry` en `app-bus`.
@@ -263,6 +266,10 @@ pub fn app_icon_view_colored<Msg: Clone + 'static>(
/// los `.desktop` (freedesktop `scalable/apps/<id>.svg`), la web, o cualquier
/// consumidor que no renderice con Llimphi. El path es stroke-only con remates
/// redondos, igual que el pintor vectorial. `viewBox 0 0 24 24` (la grilla).
// GATEADO A `std`: `BezPath::to_svg` vive en el módulo `svg` de kurbo, que es
// std-only. El consumidor de este export son los `.desktop` y la web — mundos
// que en una jaula o en el kernel no existen.
#[cfg(feature = "std")]
pub fn app_icon_svg(icon: AppIcon, stroke_width: f32) -> String {
let d = icon.path().to_svg();
let [r, g, b, _] = icon.brand().components;
@@ -286,8 +293,8 @@ fn fmt_num(v: f32) -> String {
/// Pintor crudo — para stampear varios iconos de app dentro del mismo
/// `paint_with` (una grilla de launcher, por ejemplo).
pub fn paint_app_icon(
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
rect: llimphi_ui::PaintRect,
scene: &mut llimphi_compositor::Lienzo,
rect: llimphi_compositor::PaintRect,
icon: AppIcon,
color: Color,
stroke_width: f32,
@@ -316,7 +323,7 @@ pub fn paint_app_icon(
fn circle(cx: f64, cy: f64, r: f64, segments: usize) -> BezPath {
let mut p = BezPath::new();
for i in 0..=segments {
let theta = std::f64::consts::TAU * (i as f64) / (segments as f64);
let theta = core::f64::consts::TAU * (i as f64) / (segments as f64);
let x = cx + r * theta.cos();
let y = cy + r * theta.sin();
if i == 0 {
@@ -860,6 +867,8 @@ fn path_wawa_explorer() -> BezPath {
#[cfg(test)]
mod tests {
use alloc::vec::Vec;
use super::*;
#[test]
+45 -11
View File
@@ -49,15 +49,23 @@
#![forbid(unsafe_code)]
// Sin `std`: sólo geometría y `alloc` (W4.6).
#![no_std]
extern crate alloc;
use alloc::string::ToString;
pub mod app_icons;
use llimphi_ui::llimphi_layout::taffy::{
use llimphi_compositor::llimphi_layout::taffy::{
prelude::{percent, Size, Style},
Position,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Cap, Join, Stroke};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_compositor::peniko::kurbo::{Affine, BezPath, Cap, Join, Stroke};
use llimphi_compositor::peniko::Color;
use llimphi_compositor::View;
/// Catálogo de iconos del set canónico.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -166,6 +174,7 @@ pub enum Icon {
Refresh,
/// Pieza de rompecabezas (plugins/módulos).
Puzzle,
Copy,
}
impl Icon {
@@ -244,6 +253,7 @@ impl Icon {
Icon::Moon => "moon",
Icon::Refresh => "refresh",
Icon::Puzzle => "puzzle",
Icon::Copy => "copy",
}
}
@@ -322,6 +332,7 @@ impl Icon {
Icon::Moon => path_moon(),
Icon::Refresh => path_refresh(),
Icon::Puzzle => path_puzzle(),
Icon::Copy => path_copy(),
}
}
@@ -388,6 +399,7 @@ impl Icon {
'🔍' | '🔎' => Icon::Search,
'🔔' => Icon::Bell,
'🗑' => Icon::Trash,
'📋' | '⧉' | '⎘' => Icon::Copy,
'👤' | '🧑' | '🙍' => Icon::User,
'' | 'ⓘ' => Icon::Info,
'⚠' => Icon::Warning,
@@ -416,7 +428,7 @@ pub fn glyph_or_text_view<Msg: Clone + 'static>(
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.text_aligned(glifo.to_string(), size, color, llimphi_ui::llimphi_text::Alignment::Center),
.text_aligned(glifo.to_string(), size, color, llimphi_compositor::llimphi_text::Alignment::Center),
}
}
@@ -446,8 +458,8 @@ pub fn icon_view<Msg: Clone + 'static>(
/// dentro del mismo `paint_with` (paneles compuestos, toolbars
/// generadas dinámicamente).
pub fn paint_icon(
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
rect: llimphi_ui::PaintRect,
scene: &mut llimphi_compositor::Lienzo,
rect: llimphi_compositor::PaintRect,
icon: Icon,
color: Color,
stroke_width: f32,
@@ -683,7 +695,7 @@ fn path_search() -> BezPath {
let r = 5.5;
let segments = 24;
for i in 0..=segments {
let theta = std::f64::consts::TAU * (i as f64) / (segments as f64);
let theta = core::f64::consts::TAU * (i as f64) / (segments as f64);
let x = cx + r * theta.cos();
let y = cy + r * theta.sin();
if i == 0 {
@@ -760,7 +772,7 @@ fn path_settings() -> BezPath {
let outer_r = 9.5;
let teeth = 8;
for i in 0..teeth * 2 {
let theta = std::f64::consts::TAU * (i as f64) / (teeth as f64 * 2.0);
let theta = core::f64::consts::TAU * (i as f64) / (teeth as f64 * 2.0);
// Cada paso alterna entre inner y outer para formar los dientes.
let r = if i % 2 == 0 { outer_r } else { inner_r };
let x = cx + r * theta.cos();
@@ -799,7 +811,7 @@ fn path_more() -> BezPath {
fn path_circle(cx: f64, cy: f64, r: f64, segments: usize) -> BezPath {
let mut p = BezPath::new();
for i in 0..=segments {
let theta = std::f64::consts::TAU * (i as f64) / (segments as f64);
let theta = core::f64::consts::TAU * (i as f64) / (segments as f64);
let x = cx + r * theta.cos();
let y = cy + r * theta.sin();
if i == 0 {
@@ -1279,7 +1291,7 @@ fn path_clock() -> BezPath {
}
fn path_power() -> BezPath {
use std::f64::consts::{FRAC_PI_2, TAU};
use core::f64::consts::{FRAC_PI_2, TAU};
let mut p = BezPath::new();
let (cx, cy, r) = (12.0, 13.0, 7.0);
let segs = 28;
@@ -1463,6 +1475,24 @@ fn path_refresh() -> BezPath {
p
}
fn path_copy() -> BezPath {
// Dos hojas: la de adelante entera, la de atrás asomando arriba-izquierda
// como una "L". Es el glifo universal de copiar — se reconoce sin rótulo,
// que es justamente para lo que se agregó (los chips «copiar» de shuma).
let mut p = BezPath::new();
p.move_to((16.0, 8.0));
p.line_to((16.0, 4.0));
p.line_to((4.0, 4.0));
p.line_to((4.0, 16.0));
p.line_to((8.0, 16.0));
p.move_to((8.0, 8.0));
p.line_to((20.0, 8.0));
p.line_to((20.0, 20.0));
p.line_to((8.0, 20.0));
p.close_path();
p
}
fn path_puzzle() -> BezPath {
let mut p = BezPath::new();
p.move_to((5.0, 9.0));
@@ -1478,6 +1508,9 @@ fn path_puzzle() -> BezPath {
#[cfg(test)]
mod tests {
use super::*;
// Sólo los usan los tests: arriba quedaban como imports muertos. Deriva de
// la migración a `no_std`, donde todo lo de `alloc` se importa a mano.
use alloc::vec::Vec;
#[test]
fn all_icons_have_nonempty_path() {
@@ -1532,6 +1565,7 @@ mod tests {
Icon::Clock, Icon::Power, Icon::Mail, Icon::Keyboard, Icon::Palette,
Icon::Lock, Icon::Key, Icon::Monitor, Icon::Sparkle, Icon::Mic,
Icon::Mouse, Icon::Cloud, Icon::Moon, Icon::Refresh, Icon::Puzzle,
Icon::Copy,
];
for icon in nuevos {
assert!(!icon.path().elements().is_empty(), "{} sin path", icon.name());
+88 -12
View File
@@ -89,6 +89,47 @@ impl From<std::io::Error> for DecodeError {
/// El `rgba` debe ser exactamente `w * h * 4` bytes (4 canales). Si
/// no, el render mostrará basura — el helper no valida porque el
/// constructor de `peniko::Image` tampoco; queda al caller.
pub mod perfil;
pub use perfil::{Curva, Perfil};
/// Una imagen decodificada **más de dónde vino**.
///
/// [`decode_bytes`] y [`load_path`] devuelven sólo los píxeles, que es lo que
/// necesita quien va a pintarlos. Pero un editor no: `tullpu` tiene que poder
/// abrir una foto Display P3, editarla, y **guardarla de vuelta en P3** en vez
/// de degradar el archivo del usuario a sRGB por el solo hecho de haberlo
/// abierto. Para eso hace falta saber en qué espacio venía, y eso es esto.
pub struct Decodificada {
/// Los píxeles, ya en sRGB — idénticos a lo que devuelve [`decode_bytes`].
pub imagen: Image,
/// El perfil que traía el archivo, si era un matrix-shaper legible.
///
/// `None` **no** quiere decir «era sRGB»: quiere decir que no había perfil,
/// o que era de los que este crate no entiende (CMYK, LUT). En los dos
/// casos los píxeles quedaron como venían y no hay nada a lo que volver.
pub origen: Option<Perfil>,
/// Si de verdad se recorrieron los píxeles. `false` con `origen: Some(…)`
/// es el caso corriente del archivo que ya venía en sRGB.
pub convertida: bool,
}
impl Decodificada {
/// Lleva píxeles RGBA8 **de sRGB de vuelta al espacio de origen**, listos
/// para escribirlos junto al ICC del archivo. `false` si no hay a dónde
/// volver (sin perfil, o el perfil ya era sRGB) — y entonces lo que hay que
/// guardar es sRGB, tal cual está.
///
/// Ojo con lo que esto **no** deshace: si al abrir se recortó a gamut sRGB
/// (ver [`perfil::convertir_desde_srgb`]), ese recorte ya pasó. Lo que
/// evita es que el archivo cambie de espacio sin que nadie lo pidiera.
pub fn devolver_al_origen(&self, rgba: &mut [u8]) -> bool {
match &self.origen {
Some(p) => perfil::convertir_desde_srgb(rgba, p),
None => false,
}
}
}
pub fn from_rgba8(rgba: Vec<u8>, w: u32, h: u32) -> Image {
let blob = Blob::new(Arc::new(rgba));
Image::new(ImageData {
@@ -100,22 +141,56 @@ pub fn from_rgba8(rgba: Vec<u8>, w: u32, h: u32) -> Image {
})
}
/// Decodifica **y lleva la imagen a sRGB** si trae un perfil ICC que diga otra
/// cosa.
///
/// Se baja al decoder en vez de llamar a `reader.decode()` para poder
/// preguntarle el perfil: `decode()` lo consume y lo tira. Sin esto, una foto
/// Display P3 —lo que saca cualquier teléfono desde 2016— se pinta
/// **sobresaturada**, porque sus rojos, que son más rojos que los de sRGB, se
/// muestran como si fueran los de sRGB.
fn decodificar_con_perfil<'a, R: std::io::BufRead + std::io::Seek + 'a>(
reader: image::ImageReader<R>,
) -> Result<Decodificada, DecodeError> {
use image::ImageDecoder as _;
let mut decoder = reader
.into_decoder()
.map_err(|e| DecodeError::Decode(e.to_string()))?;
let icc = decoder.icc_profile().ok().flatten();
let img = image::DynamicImage::from_decoder(decoder)
.map_err(|e| DecodeError::Decode(e.to_string()))?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width(), rgba.height());
let mut datos = rgba.into_raw();
// Sin perfil se asume sRGB, que es la convención de la web y lo que se hacía
// antes. Con un perfil sRGB, `convertir_a_srgb` detecta que la matriz es la
// identidad y no recorre un solo píxel.
let origen = icc.as_deref().and_then(perfil::leer_icc);
let convertida = match &origen {
Some(p) => perfil::convertir_a_srgb(&mut datos, p),
None => false,
};
Ok(Decodificada { imagen: from_rgba8(datos, w, h), origen, convertida })
}
/// Decodifica bytes a un `peniko::Image` listo para `View::image()`.
/// El crate `image` adivina el formato por magic bytes (no por
/// extensión) — apto para data URIs, descargas, blobs de DB.
pub fn decode_bytes(bytes: &[u8]) -> Result<Image, DecodeError> {
decode_bytes_con_origen(bytes).map(|d| d.imagen)
}
/// Igual que [`decode_bytes`], pero además dice **de qué espacio de color
/// venía**. Para editores que tienen que re-exportar al espacio de origen; el
/// que sólo va a pintar usa [`decode_bytes`] y no se entera.
pub fn decode_bytes_con_origen(bytes: &[u8]) -> Result<Decodificada, DecodeError> {
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.map_err(DecodeError::Io)?;
if reader.format().is_none() {
return Err(DecodeError::UnsupportedFormat);
}
let img = reader
.decode()
.map_err(|e| DecodeError::Decode(e.to_string()))?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width(), rgba.height());
Ok(from_rgba8(rgba.into_raw(), w, h))
decodificar_con_perfil(reader)
}
/// Lee un path, valida tamaño en disco, decodifica. El cap se compara
@@ -123,6 +198,12 @@ pub fn decode_bytes(bytes: &[u8]) -> Result<Image, DecodeError> {
/// RGBA8 puede ser mucho mayor — un PNG 4K decomprimido ocupa ~64 MB).
/// `max_bytes = 0` deshabilita el cap.
pub fn load_path(path: &Path, max_bytes: u64) -> Result<Image, DecodeError> {
load_path_con_origen(path, max_bytes).map(|d| d.imagen)
}
/// Igual que [`load_path`], pero además dice de qué espacio de color venía.
/// Ver [`Decodificada`].
pub fn load_path_con_origen(path: &Path, max_bytes: u64) -> Result<Decodificada, DecodeError> {
if max_bytes > 0 {
let meta = std::fs::metadata(path).map_err(DecodeError::Io)?;
if meta.len() > max_bytes {
@@ -139,12 +220,7 @@ pub fn load_path(path: &Path, max_bytes: u64) -> Result<Image, DecodeError> {
if reader.format().is_none() {
return Err(DecodeError::UnsupportedFormat);
}
let img = reader
.decode()
.map_err(|e| DecodeError::Decode(e.to_string()))?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width(), rgba.height());
Ok(from_rgba8(rgba.into_raw(), w, h))
decodificar_con_perfil(reader)
}
/// Descarga + caché de imágenes por URL (feature `net`). Síncrono (ureq):
+810
View File
@@ -0,0 +1,810 @@
//! Perfiles ICC: en qué espacio de color viene una imagen, y cómo llevarla al
//! de trabajo (sRGB).
//!
//! ## El agujero que esto tapa
//!
//! Hasta 2026-08-05 el decodificador tiraba el perfil ICC del archivo y
//! entregaba los bytes RGB crudos. Para una imagen sRGB eso está bien; para
//! cualquier otra es **mostrar los números en el espacio equivocado**. Una foto
//! Display P3 —lo que saca cualquier teléfono desde 2016— se ve **sobresaturada**
//! porque sus rojos, que son más rojos que los de sRGB, se pintan como si fueran
//! los de sRGB. Un escaneo con perfil de imprenta se ve lavado. Y no es un
//! detalle estético: `tullpu` dice tener paridad Photoshop, y editar color sin
//! saber en qué espacio estás no es paridad de nada.
//!
//! ## Qué cubre y qué no
//!
//! Los perfiles **matrix-shaper** (matriz de 3×3 + una curva por canal), que es
//! lo que son sRGB, Display P3, Adobe RGB, Rec.2020 y la enorme mayoría de los
//! perfiles de pantalla y de cámara. **No** cubre los perfiles basados en LUT
//! (`A2B0`), que es como vienen los de imprenta CMYK: ahí devuelve `None` y la
//! imagen queda como estaba — sin conversión es mejor que con una inventada.
//!
//! La conversión es **colorimétrica relativa** y recorta a gamut sRGB por
//! saturación de canal. Un rojo P3 puro no existe en sRGB: se lo lleva al rojo
//! sRGB más cercano. Eso es un recorte, no un error — pero conviene saberlo, y
//! por eso está escrito acá y no oculto.
/// Curva de transferencia de un canal (el «shaper» del matrix-shaper).
#[derive(Debug, Clone, PartialEq)]
pub enum Curva {
/// Identidad — el perfil declara los valores ya lineales.
Lineal,
/// Una gamma sola: `lineal = v^γ`.
Gamma(f32),
/// Tabla de muestras equiespaciadas en 0..=1, con interpolación lineal
/// entre ellas. Es como vienen casi todos los perfiles reales.
Tabla(Vec<u16>),
/// Curva paramétrica (`para`): los cinco tipos de la especificación, con
/// sus parámetros `g, a, b, c, d, e, f`. Es la forma en que vienen la curva
/// de sRGB y la de L*, y **no** se puede aproximar por su exponente: con
/// ECI-RGBv2 (que usa L*) esa aproximación erraba un gris medio por unos
/// 20 valores de 8 bits.
Parametrica { tipo: u16, p: [f32; 7] },
}
impl Curva {
/// Lleva un valor codificado (0..=1) a lineal (0..=1).
pub fn a_lineal(&self, v: f32) -> f32 {
match self {
Curva::Lineal => v,
Curva::Gamma(g) => v.max(0.0).powf(*g),
Curva::Tabla(t) if t.len() >= 2 => {
let v = v.clamp(0.0, 1.0);
let pos = v * (t.len() - 1) as f32;
let i = pos.floor() as usize;
let f = pos - i as f32;
let a = t[i] as f32 / 65535.0;
let b = t[(i + 1).min(t.len() - 1)] as f32 / 65535.0;
a + (b - a) * f
}
// Una tabla de 0 ó 1 muestras no describe nada; identidad.
Curva::Tabla(_) => v,
// Los cinco tipos de la especificación, tal cual. El 3 es la
// familia de sRGB y de L*: un tramo recto abajo y una potencia
// arriba, que es justo lo que se pierde al mirar sólo `g`.
Curva::Parametrica { tipo, p } => {
let [g, a, b, c, d, e, f] = *p;
let pot = |x: f32| (a * x + b).max(0.0).powf(g);
match tipo {
0 => v.max(0.0).powf(g),
1 => {
if a != 0.0 && v >= -b / a {
pot(v)
} else {
0.0
}
}
2 => {
if a != 0.0 && v >= -b / a {
pot(v) + c
} else {
c
}
}
3 => {
if v >= d {
pot(v)
} else {
c * v
}
}
4 => {
if v >= d {
pot(v) + e
} else {
c * v + f
}
}
// Un tipo que la especificación no define todavía: mejor
// dejar el valor como está que inventar una curva.
_ => v,
}
}
}
}
/// Inversa de [`Curva::a_lineal`]: de un valor **lineal** (0..=1) al valor
/// **codificado** que este perfil espera.
///
/// Hace falta para *escribir* una imagen en el espacio del perfil, no sólo
/// para leerla: sin esto, exponer el perfil de origen no serviría de nada
/// porque no habría con qué volver a él.
pub fn desde_lineal(&self, lin: f32) -> f32 {
let lin = lin.clamp(0.0, 1.0);
let salida = match self {
Curva::Lineal => lin,
Curva::Gamma(g) if *g > 0.0 => lin.powf(1.0 / *g),
// Una gamma de 0 no describe una curva invertible; identidad.
Curva::Gamma(_) => lin,
Curva::Tabla(t) if t.len() >= 2 => {
let objetivo = lin * 65535.0;
// Las TRC reales son monótonas no decrecientes, así que se puede
// buscar el tramo por bisección. Si alguna no lo fuera, esto
// devuelve *un* valor válido, no basura.
let i = t.partition_point(|m| (*m as f32) < objetivo);
if i == 0 {
0.0
} else if i >= t.len() {
1.0
} else {
let (a, b) = (t[i - 1] as f32, t[i] as f32);
// Tramo plano: cualquier punto de él mapea al mismo lineal.
let f = if b > a { (objetivo - a) / (b - a) } else { 0.0 };
(i as f32 - 1.0 + f) / (t.len() - 1) as f32
}
}
Curva::Tabla(_) => lin,
Curva::Parametrica { tipo, p } => {
let [g, a, b, c, d, e, f] = *p;
// Despeje de `(a·x + b)^g = y`.
let raiz = |y: f32| -> f32 {
if g == 0.0 || a == 0.0 {
0.0
} else {
(y.max(0.0).powf(1.0 / g) - b) / a
}
};
let pot = |x: f32| (a * x + b).max(0.0).powf(g);
match tipo {
0 => {
if g > 0.0 {
lin.powf(1.0 / g)
} else {
lin
}
}
1 => raiz(lin),
2 => raiz(lin - c),
// El corte va sobre el valor lineal que le toca a `d`, no
// sobre `d` mismo: acá se entra por el otro lado de la curva.
3 => {
if lin >= pot(d) {
raiz(lin)
} else if c != 0.0 {
lin / c
} else {
0.0
}
}
4 => {
if lin >= pot(d) + e {
raiz(lin - e)
} else if c != 0.0 {
(lin - f) / c
} else {
0.0
}
}
_ => lin,
}
}
};
salida.clamp(0.0, 1.0)
}
}
/// Un perfil matrix-shaper leído de un ICC.
#[derive(Debug, Clone, PartialEq)]
pub struct Perfil {
/// Matriz RGB-del-perfil → XYZ del PCS, **por filas**. El PCS de un ICC de
/// display siempre es XYZ adaptado a D50: eso lo fija la especificación, no
/// una elección nuestra.
pub a_xyz_d50: [f32; 9],
pub curva: Curva,
}
// ── Constantes de colorimetría ───────────────────────────────────────────
//
// Las tres son valores publicados, no derivados acá. El test
// `un_perfil_srgb_no_cambia_nada` las cruza entre sí: si cualquiera estuviera
// mal, la matriz de sRGB no daría la identidad.
/// XYZ (D65) → sRGB lineal, por filas.
const XYZ_D65_A_SRGB: [f32; 9] = [
3.2404542, -1.5371385, -0.4985314, //
-0.9692660, 1.8760108, 0.0415560, //
0.0556434, -0.2040259, 1.0572252,
];
/// Adaptación cromática Bradford D50 → D65, por filas.
const BRADFORD_D50_A_D65: [f32; 9] = [
0.9555766, -0.0230393, 0.0631636, //
-0.0282895, 1.0099416, 0.0210077, //
0.0122982, -0.0204830, 1.3299098,
];
/// RGB sRGB → XYZ D50, por filas. Es la matriz que **trae adentro** cualquier
/// perfil sRGB; sirve de patrón para el test de identidad.
pub const SRGB_A_XYZ_D50: [f32; 9] = [
0.4360747, 0.3850649, 0.1430804, //
0.2225045, 0.7168786, 0.0606169, //
0.0139322, 0.0971045, 0.7141733,
];
fn multiplicar(a: &[f32; 9], b: &[f32; 9]) -> [f32; 9] {
let mut m = [0.0f32; 9];
for f in 0..3 {
for c in 0..3 {
m[f * 3 + c] = (0..3).map(|k| a[f * 3 + k] * b[k * 3 + c]).sum();
}
}
m
}
/// Matriz que lleva el RGB **lineal** del perfil al RGB **lineal** de sRGB.
pub fn matriz_a_srgb(a_xyz_d50: &[f32; 9]) -> [f32; 9] {
multiplicar(&XYZ_D65_A_SRGB, &multiplicar(&BRADFORD_D50_A_D65, a_xyz_d50))
}
/// Inversa de una 3×3. `None` si es singular — no pasa con primarios de verdad
/// (tres colores independientes), pero invertir a ciegas daría infinitos.
fn invertir(m: &[f32; 9]) -> Option<[f32; 9]> {
let det = m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6])
+ m[2] * (m[3] * m[7] - m[4] * m[6]);
if det.abs() < 1e-9 {
return None;
}
let k = 1.0 / det;
Some([
(m[4] * m[8] - m[5] * m[7]) * k,
(m[2] * m[7] - m[1] * m[8]) * k,
(m[1] * m[5] - m[2] * m[4]) * k,
(m[5] * m[6] - m[3] * m[8]) * k,
(m[0] * m[8] - m[2] * m[6]) * k,
(m[2] * m[3] - m[0] * m[5]) * k,
(m[3] * m[7] - m[4] * m[6]) * k,
(m[1] * m[6] - m[0] * m[7]) * k,
(m[0] * m[4] - m[1] * m[3]) * k,
])
}
/// Matriz que lleva el RGB **lineal** de sRGB al RGB **lineal** del perfil: la
/// inversa de [`matriz_a_srgb`]. Es el camino de **salida**, el que hace falta
/// para re-exportar al espacio del que vino la imagen.
pub fn matriz_desde_srgb(a_xyz_d50: &[f32; 9]) -> Option<[f32; 9]> {
invertir(&matriz_a_srgb(a_xyz_d50))
}
/// ¿Esta matriz es la identidad dentro de `tol`? Se usa para saltear la
/// conversión cuando el perfil ya es sRGB: recorrer ocho millones de píxeles
/// para multiplicarlos por 1 es la clase de trabajo que no se paga.
pub fn es_identidad(m: &[f32; 9], tol: f32) -> bool {
for f in 0..3 {
for c in 0..3 {
let esperado = if f == c { 1.0 } else { 0.0 };
if (m[f * 3 + c] - esperado).abs() > tol {
return false;
}
}
}
true
}
// ── Lectura del ICC ──────────────────────────────────────────────────────
fn u32_be(b: &[u8], o: usize) -> Option<u32> {
Some(u32::from_be_bytes(b.get(o..o + 4)?.try_into().ok()?))
}
/// s15Fixed16: entero con signo de 16 bits + 16 bits de fracción.
fn s15f16(b: &[u8], o: usize) -> Option<f32> {
Some(i32::from_be_bytes(b.get(o..o + 4)?.try_into().ok()?) as f32 / 65536.0)
}
/// Busca un tag por su firma de 4 letras y devuelve su rebanada.
fn tag<'a>(icc: &'a [u8], firma: &[u8; 4]) -> Option<&'a [u8]> {
let n = u32_be(icc, 128)? as usize;
for i in 0..n {
let e = 132 + i * 12;
if icc.get(e..e + 4)? == firma {
let off = u32_be(icc, e + 4)? as usize;
let len = u32_be(icc, e + 8)? as usize;
return icc.get(off..off.checked_add(len)?);
}
}
None
}
/// Lee un tag de tipo `XYZ ` y devuelve su terna.
fn xyz(icc: &[u8], firma: &[u8; 4]) -> Option<[f32; 3]> {
let t = tag(icc, firma)?;
if t.get(0..4)? != b"XYZ " {
return None;
}
Some([s15f16(t, 8)?, s15f16(t, 12)?, s15f16(t, 16)?])
}
/// Lee un tag de curva (`curv` o `para`).
fn curva(icc: &[u8], firma: &[u8; 4]) -> Option<Curva> {
let t = tag(icc, firma)?;
match t.get(0..4)? {
b"curv" => {
let n = u32_be(t, 8)? as usize;
match n {
0 => Some(Curva::Lineal),
// Una sola muestra es una gamma en u8Fixed8.
1 => {
let g = u16::from_be_bytes(t.get(12..14)?.try_into().ok()?) as f32 / 256.0;
Some(Curva::Gamma(g))
}
_ => {
let datos = t.get(12..12 + n * 2)?;
Some(Curva::Tabla(
datos
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect(),
))
}
}
}
// Curva paramétrica: u16 de tipo en el offset 8, y de ahí los
// parámetros en s15Fixed16, en el orden `g, a, b, c, d, e, f`. Cuántos
// hay lo dice el tipo; los que falten quedan en 0.
b"para" => {
let tipo = u16::from_be_bytes(t.get(8..10)?.try_into().ok()?);
let cuantos = match tipo {
0 => 1,
1 => 3,
2 => 4,
3 => 5,
4 => 7,
_ => return None,
};
let mut p = [0.0f32; 7];
for (i, v) in p.iter_mut().enumerate().take(cuantos) {
*v = s15f16(t, 12 + i * 4)?;
}
Some(Curva::Parametrica { tipo, p })
}
_ => None,
}
}
/// Lee un perfil ICC matrix-shaper. Devuelve `None` si no lo es —un perfil de
/// imprenta basado en LUT, un ICC truncado, cualquier cosa que no se entienda—
/// y entonces la imagen se deja como está: sin conversión es mejor que con una
/// inventada.
pub fn leer_icc(icc: &[u8]) -> Option<Perfil> {
if icc.len() < 132 {
return None;
}
// El espacio de datos tiene que ser RGB: un CMYK no se arregla con una
// matriz de 3×3.
if icc.get(16..20)? != b"RGB " {
return None;
}
let r = xyz(icc, b"rXYZ")?;
let g = xyz(icc, b"gXYZ")?;
let b = xyz(icc, b"bXYZ")?;
// Los tags vienen por columna (cada uno es el XYZ de un primario); la
// matriz se guarda por filas.
let a_xyz_d50 = [
r[0], g[0], b[0], //
r[1], g[1], b[1], //
r[2], g[2], b[2],
];
// Se usa la curva del rojo para los tres. Un perfil con curvas distintas
// por canal existe en el papel; en la práctica los tres tags apuntan al
// mismo offset. Si algún día aparece uno que no, esto lo trata como gris.
let curva = curva(icc, b"rTRC").unwrap_or(Curva::Gamma(2.2));
Some(Perfil { a_xyz_d50, curva })
}
// ── Conversión de píxeles ────────────────────────────────────────────────
/// Codifica un valor lineal 0..=1 a sRGB 0..=255.
fn a_srgb_u8(lin: f32) -> u8 {
let v = lin.clamp(0.0, 1.0);
let s = if v <= 0.0031308 {
v * 12.92
} else {
1.055 * v.powf(1.0 / 2.4) - 0.055
};
(s * 255.0 + 0.5) as u8
}
/// Decodifica un valor sRGB 0..=1 a lineal 0..=1. La inversa exacta de
/// [`a_srgb_u8`], con el mismo tramo recto abajo.
fn srgb_a_lineal(v: f32) -> f32 {
let v = v.clamp(0.0, 1.0);
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
/// Muestras de la tabla lineal→sRGB. 16.384 alcanzan para que el error de
/// cuantización quede debajo de 0,2 de un valor de 8 bits incluso en el tramo
/// recto de abajo, que es el más empinado.
const MUESTRAS_LUT: usize = 16_384;
/// Convierte una imagen RGBA8 del espacio de `perfil` a sRGB, **in situ**.
///
/// Devuelve `false` y no toca nada si el perfil ya es sRGB (o tan parecido que
/// la diferencia no llega a un valor de 8 bits): recorrer ocho millones de
/// píxeles para multiplicarlos por la identidad es trabajo que no se paga.
///
/// El alfa no se toca: no es color.
pub fn convertir_a_srgb(rgba: &mut [u8], perfil: &Perfil) -> bool {
let m = matriz_a_srgb(&perfil.a_xyz_d50);
// 1/512 en lineal es menos de medio valor de 8 bits en casi todo el rango.
if es_identidad(&m, 1.0 / 512.0) {
return false;
}
// Tabla de decodificación: 256 entradas, una por valor de entrada.
let mut a_lineal = [0.0f32; 256];
for (i, v) in a_lineal.iter_mut().enumerate() {
*v = perfil.curva.a_lineal(i as f32 / 255.0);
}
// Tabla de codificación: el `powf` de la curva sRGB por píxel y canal serían
// veinticinco millones de llamadas en una foto 4K.
let mut a_byte = vec![0u8; MUESTRAS_LUT];
for (i, v) in a_byte.iter_mut().enumerate() {
*v = a_srgb_u8(i as f32 / (MUESTRAS_LUT - 1) as f32);
}
let esc = (MUESTRAS_LUT - 1) as f32;
for px in rgba.chunks_exact_mut(4) {
let (r, g, b) = (
a_lineal[px[0] as usize],
a_lineal[px[1] as usize],
a_lineal[px[2] as usize],
);
for (c, salida) in px[..3].iter_mut().enumerate() {
let lin = m[c * 3] * r + m[c * 3 + 1] * g + m[c * 3 + 2] * b;
// El recorte a gamut es por saturación de canal: un rojo P3 puro no
// existe en sRGB y termina en el rojo sRGB más cercano.
let idx = (lin.clamp(0.0, 1.0) * esc + 0.5) as usize;
*salida = a_byte[idx];
}
}
true
}
/// Convierte una imagen RGBA8 **de sRGB al espacio de `perfil`**, in situ. Es
/// el camino de vuelta de [`convertir_a_srgb`]: lo que hace falta para
/// **re-exportar** una imagen al espacio del archivo del que salió.
///
/// Devuelve `false` y no toca nada si el perfil ya es sRGB, o si su matriz no
/// se puede invertir.
///
/// **No es una operación sin pérdida, y hay dos razones distintas.** La segunda
/// vuelta de cuantización a 8 bits mete ±1 casi en cualquier valor. Y si la
/// imagen ya pasó por [`convertir_a_srgb`] desde un espacio más ancho, lo que
/// se recortó al entrar (un rojo P3 que en sRGB no existe) **no vuelve**: se
/// perdió en el recorte, no acá. Editar sin pérdida un P3 pide trabajar en P3,
/// no ir y venir — para eso está el [`Perfil`] expuesto junto a los píxeles.
pub fn convertir_desde_srgb(rgba: &mut [u8], perfil: &Perfil) -> bool {
let Some(m) = matriz_desde_srgb(&perfil.a_xyz_d50) else {
return false;
};
if es_identidad(&m, 1.0 / 512.0) {
return false;
}
// Decodificación de sRGB: 256 entradas, una por valor de entrada.
let mut a_lineal = [0.0f32; 256];
for (i, v) in a_lineal.iter_mut().enumerate() {
*v = srgb_a_lineal(i as f32 / 255.0);
}
// Codificación con la curva del perfil, tabulada por la misma razón que en
// el otro sentido: la inversa de una paramétrica también es un `powf`.
let mut a_byte = vec![0u8; MUESTRAS_LUT];
for (i, v) in a_byte.iter_mut().enumerate() {
let cod = perfil.curva.desde_lineal(i as f32 / (MUESTRAS_LUT - 1) as f32);
*v = (cod * 255.0 + 0.5) as u8;
}
let esc = (MUESTRAS_LUT - 1) as f32;
for px in rgba.chunks_exact_mut(4) {
let (r, g, b) = (
a_lineal[px[0] as usize],
a_lineal[px[1] as usize],
a_lineal[px[2] as usize],
);
for (c, salida) in px[..3].iter_mut().enumerate() {
let lin = m[c * 3] * r + m[c * 3 + 1] * g + m[c * 3 + 2] * b;
let idx = (lin.clamp(0.0, 1.0) * esc + 0.5) as usize;
*salida = a_byte[idx];
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
/// Arma un ICC matrix-shaper mínimo pero **válido** con la matriz y la
/// curva que se le pidan. Sirve para probar el lector sin depender de
/// archivos del sistema.
fn icc_sintetico(a_xyz_d50: &[f32; 9], curva_gamma: f32) -> Vec<u8> {
fn s15(v: f32) -> [u8; 4] {
((v * 65536.0).round() as i32).to_be_bytes()
}
let tags: [(&[u8; 4], usize); 4] = [(b"rXYZ", 0), (b"gXYZ", 1), (b"bXYZ", 2), (b"rTRC", 3)];
let mut cuerpo: Vec<u8> = Vec::new();
let mut entradas: Vec<(&[u8; 4], u32, u32)> = Vec::new();
let base = 128 + 4 + tags.len() * 12;
for (firma, col) in tags {
let off = base + cuerpo.len();
if *firma == *b"rTRC" {
cuerpo.extend_from_slice(b"curv");
cuerpo.extend_from_slice(&[0; 4]);
cuerpo.extend_from_slice(&1u32.to_be_bytes());
cuerpo.extend_from_slice(&(((curva_gamma * 256.0) as u16).to_be_bytes()));
cuerpo.extend_from_slice(&[0, 0]); // relleno a 4
} else {
cuerpo.extend_from_slice(b"XYZ ");
cuerpo.extend_from_slice(&[0; 4]);
for fila in 0..3 {
cuerpo.extend_from_slice(&s15(a_xyz_d50[fila * 3 + col]));
}
}
entradas.push((firma, off as u32, (base + cuerpo.len() - off) as u32));
}
let mut icc = vec![0u8; 128];
icc[16..20].copy_from_slice(b"RGB ");
icc[20..24].copy_from_slice(b"XYZ ");
icc.extend_from_slice(&(tags.len() as u32).to_be_bytes());
for (firma, off, len) in &entradas {
icc.extend_from_slice(*firma);
icc.extend_from_slice(&off.to_be_bytes());
icc.extend_from_slice(&len.to_be_bytes());
}
icc.extend_from_slice(&cuerpo);
let n = icc.len() as u32;
icc[0..4].copy_from_slice(&n.to_be_bytes());
icc
}
#[test]
fn un_perfil_srgb_no_cambia_nada() {
// Éste es el test que **cruza las tres constantes publicadas**: la
// matriz de sRGB en D50 (la que trae adentro cualquier perfil sRGB),
// pasada por Bradford D50→D65 y por XYZ→sRGB, tiene que dar la
// identidad. Si cualquiera de las tres estuviera mal transcrita, no da.
let m = matriz_a_srgb(&SRGB_A_XYZ_D50);
assert!(
es_identidad(&m, 1e-3),
"sRGB → sRGB tendría que ser la identidad, dio {m:?}"
);
// Y de punta a punta: los píxeles quedan intactos y se dice que no se
// hizo nada, para que el caller no pague el recorrido.
let perfil = leer_icc(&icc_sintetico(&SRGB_A_XYZ_D50, 2.2)).expect("matrix-shaper");
let original = vec![10u8, 128, 240, 255, 0, 255, 7, 33];
let mut px = original.clone();
assert!(!convertir_a_srgb(&mut px, &perfil));
assert_eq!(px, original);
}
#[test]
fn el_lector_saca_los_numeros_que_se_le_pusieron() {
// El lector por separado de la colorimetría: que sepa recorrer la tabla
// de tags y desarmar un s15Fixed16.
let m = [
0.5, 0.25, 0.125, //
0.75, 0.5, 0.25, //
0.0625, 0.125, 0.875,
];
let p = leer_icc(&icc_sintetico(&m, 1.8)).expect("matrix-shaper");
for (leido, puesto) in p.a_xyz_d50.iter().zip(m.iter()) {
assert!((leido - puesto).abs() < 1e-4, "{:?} vs {m:?}", p.a_xyz_d50);
}
// La gamma de una sola muestra viaja en u8Fixed8, así que 1,8 no es
// representable: vuelve 1,796875. No es pérdida nuestra, es el formato.
let Curva::Gamma(g) = p.curva else { panic!("se esperaba una gamma, vino {:?}", p.curva) };
assert!((g - 1.8).abs() < 1.0 / 256.0, "gamma leída {g}");
}
#[test]
fn un_perfil_de_gamut_ancho_desatura_al_convertir() {
// Display P3 en D50, tal como lo trae el perfil de Apple. Lo que se
// afirma acá no es el valor exacto sino la **dirección**: el rojo puro
// de P3 es más rojo que el de sRGB, así que al llevarlo a sRGB satura
// el rojo y **tiene que meter verde y azul negativos** — que el recorte
// deja en 0. Y un gris se mantiene gris, porque la conversión no toca
// el punto blanco.
let p3_d50 = [
0.5151, 0.2920, 0.1571, //
0.2412, 0.6922, 0.0666, //
-0.0011, 0.0419, 0.7841,
];
let m = matriz_a_srgb(&p3_d50);
assert!(m[0] > 1.0, "el rojo P3 debería exceder el rojo sRGB: {m:?}");
assert!(m[1] < 0.0 && m[2] < 0.02, "y compensar con verde/azul: {m:?}");
let perfil = Perfil { a_xyz_d50: p3_d50, curva: Curva::Gamma(2.2) };
// Rojo puro, blanco, gris medio.
let mut px = vec![255u8, 0, 0, 255, 255, 255, 255, 255, 128, 128, 128, 255];
assert!(convertir_a_srgb(&mut px, &perfil));
assert_eq!(&px[0..4], &[255, 0, 0, 255], "rojo P3 → rojo sRGB saturado");
for (i, c) in px[4..7].iter().enumerate() {
assert!(*c >= 253, "el blanco tiene que seguir blanco (canal {i} = {c})");
}
let gris = &px[8..11];
assert!(
gris.iter().all(|c| (*c as i32 - 128).abs() <= 2),
"un gris no debería teñirse: {gris:?}"
);
}
#[test]
fn lo_que_no_se_entiende_se_deja_en_paz() {
// Un perfil CMYK de imprenta no se arregla con una matriz de 3×3, y un
// ICC truncado no se arregla con nada. En los dos casos la respuesta es
// no tocar la imagen.
let mut cmyk = icc_sintetico(&SRGB_A_XYZ_D50, 2.2);
cmyk[16..20].copy_from_slice(b"CMYK");
assert!(leer_icc(&cmyk).is_none());
assert!(leer_icc(&[]).is_none());
assert!(leer_icc(&[0u8; 200]).is_none());
// Y un tag con offset fuera del archivo no puede hacer pánico.
let mut roto = icc_sintetico(&SRGB_A_XYZ_D50, 2.2);
let n = roto.len();
roto[136..140].copy_from_slice(&(n as u32 + 9999).to_be_bytes());
assert!(leer_icc(&roto).is_none());
}
#[test]
fn la_curva_parametrica_de_srgb_es_la_curva_de_srgb() {
// Tipo 3 con los parámetros canónicos de sRGB. La prueba es contra la
// fórmula publicada, no contra mi implementación: si el tramo recto de
// abajo o el corte en `d` estuvieran mal, los valores chicos fallan.
let srgb = Curva::Parametrica {
tipo: 3,
p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0],
};
for v in [0.0f32, 0.01, 0.04, 0.05, 0.2, 0.5, 0.8, 1.0] {
let esperado = if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
};
let dio = srgb.a_lineal(v);
assert!((dio - esperado).abs() < 1e-5, "en {v}: dio {dio}, esperaba {esperado}");
}
// Y la razón por la que esto se implementó: aproximar por el exponente
// —lo que hacía antes— erra feo en el medio del rango. Con L* (tipo 3,
// g=3) un gris medio se iba unos 20 valores de 8 bits.
let l_estrella = Curva::Parametrica {
tipo: 3,
p: [3.0, 1.0 / 1.16, 0.16 / 1.16, 1.0 / 9.033, 0.08, 0.0, 0.0],
};
let bien = l_estrella.a_lineal(128.0 / 255.0);
let atajo = Curva::Gamma(3.0).a_lineal(128.0 / 255.0);
assert!(bien > atajo * 1.4, "el atajo por el exponente daba {atajo}, la curva da {bien}");
}
#[test]
fn la_curva_de_tabla_interpola_entre_muestras() {
let c = Curva::Tabla(vec![0, 16384, 65535]);
assert!((c.a_lineal(0.0) - 0.0).abs() < 1e-4);
assert!((c.a_lineal(0.5) - 0.25).abs() < 1e-3);
assert!((c.a_lineal(1.0) - 1.0).abs() < 1e-4);
// A mitad de camino entre la primera y la segunda muestra.
assert!((c.a_lineal(0.25) - 0.125).abs() < 1e-3);
// Una tabla que no describe nada no puede romper.
assert_eq!(Curva::Tabla(vec![]).a_lineal(0.7), 0.7);
}
// ── El camino de vuelta ──────────────────────────────────────────────
#[test]
fn cada_curva_deshace_lo_que_hizo() {
// `desde_lineal` es la inversa de `a_lineal`, y la única forma honesta
// de probarlo es componerlas: no hay ninguna constante que creerle.
// Van las cinco formas que el crate sabe leer, no una de muestra.
let curvas = [
Curva::Lineal,
Curva::Gamma(2.2),
Curva::Gamma(1.8),
// Una tabla no lineal, como la que trae casi cualquier perfil real.
Curva::Tabla((0..=64).map(|i| ((i as f32 / 64.0).powf(2.2) * 65535.0) as u16).collect()),
// sRGB (tipo 3) y L* (tipo 3 con g=3): las dos con tramo recto.
Curva::Parametrica {
tipo: 3,
p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0],
},
Curva::Parametrica {
tipo: 3,
p: [3.0, 1.0 / 1.16, 0.16 / 1.16, 1.0 / 9.033, 0.08, 0.0, 0.0],
},
Curva::Parametrica { tipo: 0, p: [2.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] },
Curva::Parametrica { tipo: 1, p: [2.4, 0.9, 0.1, 0.0, 0.0, 0.0, 0.0] },
Curva::Parametrica { tipo: 2, p: [2.4, 0.9, 0.05, 0.02, 0.0, 0.0, 0.0] },
Curva::Parametrica {
tipo: 4,
p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.01, 0.005],
},
];
for c in &curvas {
for i in 0..=32 {
let v = i as f32 / 32.0;
let ida = c.a_lineal(v);
// Un tramo fuera de 0..=1 (la tipo 2 con `c` desplaza el cero)
// no se puede exigir que vuelva: el clamp ya lo perdió.
if !(0.0..=1.0).contains(&ida) {
continue;
}
let vuelta = c.desde_lineal(ida);
assert!(
(vuelta - v).abs() < 0.01,
"{c:?}: {v} → {ida} → {vuelta}, no volvió"
);
}
}
}
#[test]
fn la_matriz_de_vuelta_deshace_la_de_ida() {
// P3 en D50. Componer la matriz de ida con la de vuelta tiene que dar
// la identidad — y ésta es la prueba de que `invertir` invierte, sin
// depender de ningún valor publicado de P3.
let p3_d50 = [
0.5151, 0.2920, 0.1571, //
0.2412, 0.6922, 0.0666, //
-0.0011, 0.0419, 0.7841,
];
let ida = matriz_a_srgb(&p3_d50);
let vuelta = matriz_desde_srgb(&p3_d50).expect("P3 no es singular");
assert!(es_identidad(&multiplicar(&ida, &vuelta), 1e-4));
assert!(es_identidad(&multiplicar(&vuelta, &ida), 1e-4));
// Una matriz con dos primarios iguales no tiene inversa, y eso se dice
// en vez de devolver infinitos.
let degenerada = [1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0];
assert!(invertir(&degenerada).is_none());
}
#[test]
fn una_imagen_srgb_va_a_p3_y_vuelve_igual() {
// El lazo que justifica exponer el perfil: tullpu edita en sRGB y
// re-exporta al espacio del archivo. sRGB **cabe entero** dentro de P3,
// así que esta ida y vuelta no recorta nada y lo único que queda es la
// cuantización a 8 bits.
let p3 = Perfil {
a_xyz_d50: [
0.5151, 0.2920, 0.1571, //
0.2412, 0.6922, 0.0666, //
-0.0011, 0.0419, 0.7841,
],
curva: Curva::Gamma(2.2),
};
let original: Vec<u8> = vec![
255, 0, 0, 255, // rojo sRGB puro
0, 255, 0, 255, // verde
0, 0, 255, 255, // azul
128, 128, 128, 255, // gris medio
200, 120, 40, 255, // un naranja cualquiera
12, 34, 56, 255, // y una sombra
];
let mut px = original.clone();
assert!(convertir_desde_srgb(&mut px, &p3), "sRGB → P3 tiene que hacer algo");
assert_ne!(px, original, "si no cambió nada, no se convirtió");
assert!(convertir_a_srgb(&mut px, &p3), "y P3 → sRGB también");
for (i, (dio, esperaba)) in px.iter().zip(original.iter()).enumerate() {
let d = (*dio as i32 - *esperaba as i32).abs();
assert!(d <= 3, "canal {i}: volvió {dio}, había salido de {esperaba}");
}
}
#[test]
fn volver_a_srgb_desde_srgb_no_recorre_la_imagen() {
// La misma guarda que en el otro sentido: multiplicar ocho millones de
// píxeles por la identidad es trabajo que no se paga.
let perfil = leer_icc(&icc_sintetico(&SRGB_A_XYZ_D50, 2.2)).expect("matrix-shaper");
let original = vec![10u8, 128, 240, 255, 0, 255, 7, 33];
let mut px = original.clone();
assert!(!convertir_desde_srgb(&mut px, &perfil));
assert_eq!(px, original);
}
}
+249
View File
@@ -0,0 +1,249 @@
//! El lector de ICC contra **perfiles reales**, no sintéticos.
//!
//! Los tests unitarios de `perfil.rs` arman sus propios ICC, así que prueban la
//! aritmética y el recorrido de tags pero no que un archivo escrito por otra
//! gente entre. Esto lo cubre con los perfiles que trae `colord`, que están en
//! casi cualquier Linux de escritorio.
//!
//! **Se saltea solo** si la máquina no los tiene: el test no puede exigir que
//! haya un paquete instalado, pero tampoco tiene sentido tirar la única
//! verificación contra archivos ajenos que hay.
//!
//! Certificado por texto (§8): son valores de píxel, no una pantalla.
use llimphi_image::perfil::{
convertir_a_srgb, convertir_desde_srgb, es_identidad, leer_icc, matriz_a_srgb,
};
const DIR: &str = "/usr/share/color/icc/colord";
fn cargar(nombre: &str) -> Option<Vec<u8>> {
std::fs::read(std::path::Path::new(DIR).join(nombre)).ok()
}
/// Convierte rojo puro, verde puro y un gris medio.
fn convertir_muestras(icc: &[u8]) -> Option<(bool, [u8; 3], [u8; 3], [u8; 3])> {
let p = leer_icc(icc)?;
let mut px = vec![255u8, 0, 0, 255, 0, 255, 0, 255, 128, 128, 128, 255];
let toco = convertir_a_srgb(&mut px, &p);
Some((
toco,
[px[0], px[1], px[2]],
[px[4], px[5], px[6]],
[px[8], px[9], px[10]],
))
}
#[test]
fn un_perfil_srgb_de_verdad_no_toca_la_imagen() {
let Some(icc) = cargar("sRGB.icc") else { return };
let p = leer_icc(&icc).expect("sRGB.icc es matrix-shaper");
assert!(
es_identidad(&matriz_a_srgb(&p.a_xyz_d50), 1.0 / 512.0),
"la matriz de un sRGB real tendría que dar la identidad"
);
let (toco, r, g, gris) = convertir_muestras(&icc).unwrap();
assert!(!toco, "no hay nada que convertir y no se debería recorrer la imagen");
assert_eq!((r, g, gris), ([255, 0, 0], [0, 255, 0], [128, 128, 128]));
// Rec.709 comparte primarios con sRGB: también identidad. Si diera otra
// cosa, el que estaría mal sería el lector.
if let Some(icc) = cargar("Rec709.icc") {
let p = leer_icc(&icc).expect("Rec709.icc es matrix-shaper");
assert!(es_identidad(&matriz_a_srgb(&p.a_xyz_d50), 1.0 / 512.0));
}
}
#[test]
fn el_perfil_que_intercambia_rojo_y_verde_intercambia_rojo_y_verde() {
// El mejor caso de prueba que se puede pedir: la respuesta correcta no
// depende de creerle a ninguna constante de colorimetría.
let Some(icc) = cargar("SwappedRedAndGreen.icc") else { return };
let (toco, r, g, gris) = convertir_muestras(&icc).unwrap();
assert!(toco);
assert!(r[1] > 250 && r[0] < 8, "el rojo tendría que salir verde, salió {r:?}");
assert!(g[0] > 250 && g[1] < 8, "el verde tendría que salir rojo, salió {g:?}");
assert_eq!(gris, [128, 128, 128], "un gris no se altera al permutar canales");
}
/// Mete un perfil ICC en un JPEG ya codificado, como marcador APP2.
///
/// JPEG guarda el ICC **sin comprimir** (a diferencia del `iCCP` de PNG, que
/// va en deflate), así que inyectarlo es concatenar bytes y no hace falta
/// ninguna dependencia nueva para armar el caso de prueba.
fn jpeg_con_icc(jpeg: &[u8], icc: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(jpeg.len() + icc.len() + 32);
out.extend_from_slice(&jpeg[..2]); // SOI
let largo = 2 + 12 + 2 + icc.len();
out.extend_from_slice(&[0xFF, 0xE2]);
out.extend_from_slice(&(largo as u16).to_be_bytes());
out.extend_from_slice(b"ICC_PROFILE\0");
out.extend_from_slice(&[1, 1]); // secuencia 1 de 1
out.extend_from_slice(icc);
out.extend_from_slice(&jpeg[2..]);
out
}
#[test]
fn decodificar_un_archivo_con_perfil_lo_lleva_a_srgb() {
// El lazo entero: bytes de archivo → `decode_bytes` → píxeles convertidos.
// Los otros tests prueban el lector y la aritmética por separado; éste es
// el único que verifica que el decodificador **le pregunta** el perfil al
// decoder en vez de tirarlo, que es lo que pasaba hasta 2026-08-05.
let Some(icc) = cargar("SwappedRedAndGreen.icc") else { return };
assert!(icc.len() < 65_000, "el perfil no entra en un solo APP2");
// Un JPEG chico de rojo puro. Se codifica con calidad máxima para que el
// subsampling de croma no se coma lo que se está midiendo.
let rojo = image::RgbImage::from_pixel(16, 16, image::Rgb([255, 0, 0]));
let mut jpeg = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100)
.encode_image(&image::DynamicImage::ImageRgb8(rojo))
.expect("encode");
let sin_perfil = llimphi_image::decode_bytes(&jpeg).expect("decode");
let con_perfil =
llimphi_image::decode_bytes(&jpeg_con_icc(&jpeg, &icc)).expect("decode con icc");
let centro = |img: &llimphi_image::Image| -> [u8; 3] {
let d: &[u8] = img.image.data.as_ref();
let o = ((8 * 16 + 8) * 4) as usize;
[d[o], d[o + 1], d[o + 2]]
};
let a = centro(&sin_perfil);
let b = centro(&con_perfil);
assert!(a[0] > 240 && a[1] < 20, "sin perfil el rojo queda rojo, dio {a:?}");
assert!(
b[1] > 240 && b[0] < 20,
"con el perfil que permuta canales el rojo tiene que salir verde, dio {b:?}"
);
}
#[test]
fn ningun_perfil_del_sistema_tiñe_un_gris() {
// La invariante que vale para **toda** conversión colorimétrica relativa
// entre dos espacios RGB: el blanco va al blanco, así que los neutros
// quedan neutros. El valor puede cambiar —un perfil de gamma 1,8 aclara el
// gris medio— pero los tres canales tienen que seguir siendo iguales.
//
// Es la prueba que agarró el atajo de las curvas paramétricas: aproximarlas
// por su exponente dejaba el gris de `SwappedRedAndGreen` en 121 y el de
// `ECI-RGBv2` en 100, cuando la curva bien evaluada da 128 y 119.
let Ok(dir) = std::fs::read_dir(DIR) else { return };
let (mut leidos, mut salteados) = (0, 0);
for e in dir.flatten() {
let ruta = e.path();
if ruta.extension().and_then(|s| s.to_str()) != Some("icc") {
continue;
}
let Ok(bytes) = std::fs::read(&ruta) else { continue };
let nombre = ruta.file_name().unwrap().to_string_lossy().into_owned();
match convertir_muestras(&bytes) {
Some((_, _, _, gris)) => {
leidos += 1;
assert!(
gris[0] == gris[1] && gris[1] == gris[2],
"{nombre} tiñó un gris: {gris:?}"
);
}
// Los de imprenta (FOGRA, SWOP, GRACoL…) son CMYK con LUT: no se
// arreglan con una matriz de 3×3 y se dejan pasar sin tocar.
None => salteados += 1,
}
}
// Guarda del arnés: si el directorio existiera vacío, el bucle no probaría
// nada y el test pasaría igual.
assert!(leidos >= 10, "sólo {leidos} perfiles leídos; el arnés no está probando");
assert!(salteados >= 1, "ninguno salteado: los CMYK deberían caer afuera");
}
// ── El camino de vuelta, contra los mismos archivos ajenos ───────────────
#[test]
fn abrir_y_volver_a_guardar_devuelve_los_bytes_que_habia() {
// El lazo completo de un editor: `tullpu` abre un archivo con perfil, los
// píxeles le llegan en sRGB, y al guardar los devuelve al espacio de origen
// para no degradar el archivo del usuario por el solo hecho de haberlo
// abierto.
//
// Con `SwappedRedAndGreen` el ida y vuelta es verificable a ojo cerrado y
// sin creerle a ninguna constante: el rojo del archivo se abre como verde y
// al guardar tiene que volver a ser rojo.
let Some(icc) = cargar("SwappedRedAndGreen.icc") else { return };
let rojo = image::RgbImage::from_pixel(8, 8, image::Rgb([230, 40, 60]));
let mut jpeg = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100)
.encode_image(&image::DynamicImage::ImageRgb8(rojo))
.expect("encode");
let d = llimphi_image::decode_bytes_con_origen(&jpeg_con_icc(&jpeg, &icc))
.expect("decode con icc");
assert!(d.origen.is_some(), "el perfil del archivo tiene que llegar al caller");
assert!(d.convertida, "y con este perfil hay conversión de verdad");
let mut px: Vec<u8> = d.imagen.image.data.as_ref().to_vec();
let abierto = [px[0], px[1], px[2]];
assert!(
abierto[1] > abierto[0],
"abierto tendría que venir con los canales permutados, dio {abierto:?}"
);
assert!(d.devolver_al_origen(&mut px), "hay a dónde volver");
let guardado = [px[0], px[1], px[2]];
// El JPEG a calidad 100 no es exacto: la tolerancia cubre eso más las dos
// cuantizaciones a 8 bits, no un error de la conversión.
for (i, (dio, habia)) in guardado.iter().zip([230u8, 40, 60].iter()).enumerate() {
let delta = (*dio as i32 - *habia as i32).abs();
assert!(delta <= 4, "canal {i}: se guardó {dio} donde el archivo tenía {habia}");
}
}
#[test]
fn ningun_perfil_del_sistema_corre_un_gris_al_ir_y_volver() {
// La invariante del párrafo anterior, pero **sobre los 38 archivos**, no
// sobre uno elegido. Se usa un gris porque es el único color que ningún
// espacio RGB puede recortar: el blanco va al blanco en toda conversión
// colorimétrica relativa, así que la ida y vuelta no pierde por gamut y lo
// que quede es error de la aritmética.
//
// Es la prueba que le pone dientes a `desde_lineal`: una inversa de curva
// mal despejada corre el gris decenas de valores, y ninguna cuenta de
// matrices lo taparía.
let Ok(dir) = std::fs::read_dir(DIR) else { return };
let mut probados = 0;
for e in dir.flatten() {
let ruta = e.path();
if ruta.extension().and_then(|s| s.to_str()) != Some("icc") {
continue;
}
let Ok(bytes) = std::fs::read(&ruta) else { continue };
let Some(p) = leer_icc(&bytes) else { continue };
let nombre = ruta.file_name().unwrap().to_string_lossy().into_owned();
// Los dos primeros son **sombras**, y están puestos a propósito: el
// tramo recto que las paramétricas de tipo 3 tienen abajo sólo se pisa
// ahí. Con grises de 32 para arriba, una inversa que ignorara ese tramo
// —el atajo obvio— pasaría el test sin que nadie se enterara.
let original: Vec<u8> = vec![
4, 4, 4, 255, //
9, 9, 9, 255, //
32, 32, 32, 255, //
128, 128, 128, 255, //
200, 200, 200, 255,
];
let mut px = original.clone();
// Un perfil que es sRGB no toca nada en ninguno de los dos sentidos, y
// entonces no hay ida y vuelta que probar.
if !convertir_desde_srgb(&mut px, &p) {
continue;
}
assert!(convertir_a_srgb(&mut px, &p), "{nombre}: fue pero no volvió");
probados += 1;
for (i, (dio, habia)) in px.iter().zip(original.iter()).enumerate() {
let delta = (*dio as i32 - *habia as i32).abs();
assert!(delta <= 3, "{nombre} corrió el canal {i}: {habia} → {dio}");
}
}
assert!(probados >= 5, "sólo {probados} perfiles con ida y vuelta; el arnés no prueba nada");
}
+927
View File
@@ -0,0 +1,927 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "atomic-polyfill"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
dependencies = [
"critical-section",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
dependencies = [
"bytemuck_derive",
]
[[package]]
name = "bytemuck_derive"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror",
]
[[package]]
name = "color"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf"
dependencies = [
"bytemuck",
"libm",
]
[[package]]
name = "core_maths"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
dependencies = [
"libm",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "displaydoc"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "euclid"
version = "0.22.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
dependencies = [
"num-traits",
]
[[package]]
name = "fearless_simd"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
dependencies = [
"libm",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5"
dependencies = [
"bytemuck",
]
[[package]]
name = "font-types"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7"
dependencies = [
"bytemuck",
]
[[package]]
name = "font-types"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100"
dependencies = [
"bytemuck",
]
[[package]]
name = "fontique"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff3336bc0b87fe42305047263fa60d2eabd650d29cbe62fdeb2a66c7a0a595f9"
dependencies = [
"bytemuck",
"core_maths",
"hashbrown 0.15.5",
"icu_locale_core",
"linebender_resource_handle",
"memmap2",
"read-fonts 0.35.0",
"smallvec",
]
[[package]]
name = "futures-core"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]]
name = "futures-task"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-util"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "glifo"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
dependencies = [
"bytemuck",
"core_maths",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
"skrifa 0.42.1",
"smallvec",
"vello_common",
]
[[package]]
name = "grid"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5"
[[package]]
name = "guillotiere"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab"
dependencies = [
"euclid",
]
[[package]]
name = "harfrust"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8"
dependencies = [
"bitflags",
"bytemuck",
"core_maths",
"read-fonts 0.35.0",
"smallvec",
]
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "heapless"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
dependencies = [
"atomic-polyfill",
"hash32",
"rustc_version",
"serde",
"spin",
"stable_deref_trait",
]
[[package]]
name = "icu_locale_core"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
"serde",
"tinystr",
"writeable",
]
[[package]]
name = "js-sys"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "kurbo"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
dependencies = [
"arrayvec",
"euclid",
"libm",
"polycool",
"smallvec",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "llimphi-compositor"
version = "0.1.0"
dependencies = [
"foldhash 0.2.0",
"hashbrown 0.17.1",
"libm",
"llimphi-layout",
"llimphi-text",
"web-time",
]
[[package]]
name = "llimphi-cpu"
version = "0.1.0"
dependencies = [
"fearless_simd",
"vello_cpu",
]
[[package]]
name = "llimphi-jaula"
version = "0.1.0"
dependencies = [
"llimphi-compositor",
"llimphi-layout",
"llimphi-text",
"llimphi-wire-escena",
"postcard",
]
[[package]]
name = "llimphi-layout"
version = "0.1.0"
dependencies = [
"hashbrown 0.17.1",
"taffy",
]
[[package]]
name = "llimphi-text"
version = "0.1.0"
dependencies = [
"hashbrown 0.17.1",
"llimphi-cpu",
"llimphi-wire-escena",
"parley",
"peniko",
]
[[package]]
name = "llimphi-wire-escena"
version = "0.1.0"
dependencies = [
"kurbo",
"peniko",
"serde",
]
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memmap2"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "parley"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26746861bb76dbc9bcd5ed1b0b55d2fedf291100961251702a031ab2abd2ce52"
dependencies = [
"core_maths",
"fontique",
"harfrust",
"hashbrown 0.15.5",
"linebender_resource_handle",
"skrifa 0.37.0",
"swash",
]
[[package]]
name = "peniko"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6"
dependencies = [
"bytemuck",
"color",
"kurbo",
"linebender_resource_handle",
"smallvec",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "polycool"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
dependencies = [
"arrayvec",
"libm",
]
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"heapless",
"serde",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "read-fonts"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358"
dependencies = [
"bytemuck",
"core_maths",
"font-types 0.10.1",
]
[[package]]
name = "read-fonts"
version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81"
dependencies = [
"bytemuck",
"core_maths",
"font-types 0.11.3",
]
[[package]]
name = "read-fonts"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
"core_maths",
"font-types 0.12.2",
"once_cell",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "skrifa"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841"
dependencies = [
"bytemuck",
"core_maths",
"read-fonts 0.35.0",
]
[[package]]
name = "skrifa"
version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564"
dependencies = [
"bytemuck",
"core_maths",
"read-fonts 0.39.2",
]
[[package]]
name = "skrifa"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
dependencies = [
"bytemuck",
"core_maths",
"read-fonts 0.41.0",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
"lock_api",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "swash"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e"
dependencies = [
"core_maths",
"skrifa 0.44.0",
"yazi",
"zeno",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "taffy"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
dependencies = [
"arrayvec",
"grid",
"serde",
"slotmap",
]
[[package]]
name = "thiserror"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"serde_core",
"zerovec",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "vello_common"
version = "0.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750"
dependencies = [
"bytemuck",
"fearless_simd",
"guillotiere",
"hashbrown 0.17.1",
"libm",
"log",
"peniko",
"smallvec",
"thiserror",
]
[[package]]
name = "vello_cpu"
version = "0.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23"
dependencies = [
"bytemuck",
"glifo",
"hashbrown 0.17.1",
"vello_common",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yazi"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
[[package]]
name = "zeno"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
dependencies = [
"libm",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
[[package]]
name = "zerovec"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"serde",
"zerofrom",
]
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "llimphi-jaula"
# Metadatos explícitos y no heredados: este crate está EXCLUIDO del workspace
# raíz, igual que `llimphi-wawa` y por el mismo motivo (ver el comentario en
# `/Cargo.toml`). Un miembro excluido no puede heredar de `workspace.package`.
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["Sergio <sergio@gioser.net>"]
publish = false
description = "llimphi-jaula — la cáscara del lado APP: corre el ciclo de Llimphi dentro de una jaula WASM y emite una `EscenaWire` en vez de rasterizar. Es el gemelo de `llimphi-wawa`, del otro lado de la frontera: aquélla recibe y ejecuta, ésta compone y manda. W4 de 03_ukupacha/wawa/PLAN-LLIMPHI.md."
[dependencies]
# El MISMO compositor que corre en Linux y en el kernel, en su tercer perfil: el
# que graba hacia el cable. `render.rs` no sabe cuál de los tres es.
llimphi-compositor = { path = "../llimphi-compositor", version = "0.1.0", default-features = false, features = ["wire"] }
llimphi-layout = { path = "../llimphi-layout", version = "0.1.0" }
llimphi-text = { path = "../llimphi-text", version = "0.1.0", default-features = false, features = ["wire"] }
llimphi-wire-escena = { path = "../llimphi-wire-escena", version = "0.1.0", default-features = false }
# El codec del cable. La petición de medida cruza serializada; la respuesta son
# doce bytes crudos y no lo necesita.
postcard = { version = "1", default-features = false, features = ["alloc"] }
[features]
# El perfil para el que este crate existe: dentro de una jaula, sin sistema.
default = ["libm"]
libm = ["llimphi-compositor/libm", "llimphi-text/libm", "llimphi-wire-escena/libm"]
# Correr la jaula en el anfitrión, para tests: mismo código, con stdlib.
std = ["llimphi-compositor/std", "llimphi-text/std", "llimphi-wire-escena/std"]
[dev-dependencies]
postcard = { version = "1", features = ["use-std"] }
llimphi-text = { path = "../llimphi-text", version = "0.1.0", default-features = false, features = ["wire", "std"] }
# Workspace propio, vacío. Sin esto cargo lo absorbería en el del repo —del que
# está excluido— y la unificación de features encendería `vello` junto a `wire`,
# que son backends excluyentes. Es exactamente la trampa que ya mordió con
# `llimphi-wawa`; acá está anotada para no volver a pisarla.
[workspace]
+105
View File
@@ -0,0 +1,105 @@
# llimphi-jaula
La cáscara del lado **app**: corre el ciclo de Llimphi dentro de una jaula WASM y
emite una [`EscenaWire`](../llimphi-wire-escena/LEEME.md) en vez de rasterizar.
Es el gemelo de [`llimphi-wawa`](../llimphi-wawa/LEEME.md), del otro lado de la
frontera: aquélla recibe y ejecuta, ésta compone y manda.
W4 de [`03_ukupacha/wawa/PLAN-LLIMPHI.md`](../../../03_ukupacha/wawa/PLAN-LLIMPHI.md).
```
View<Msg> → mount → layout → paint → EscenaWire → postcard → …
…………………………… frontera WASM ……………………………
… → shapear nativo → rasterizar
```
## Lo que hay que entender de acá
Las dos cáscaras corren el **mismo `llimphi-compositor`** — el mismo mount, el
mismo layout de taffy, el mismo `render.rs` de 4.000 líneas. Lo único que cambia
es el alias `Lienzo` y, con él, el `Typesetter`.
Eso es el resultado de W4 que vale decir en voz alta: **no hay un compositor para
apps y otro para el kernel.** Un widget escrito una vez sirve en Linux, adentro
del kernel, y adentro de una jaula.
## Las dos cosas que ésta hace y la del kernel no
### Pregunta las medidas
taffy no puede dimensionar una etiqueta sin saber cuánto mide, y medir es
shapear — justo lo que no debe pasar adentro de la jaula: el 30,3x del intérprete
se comería el 72% del fotograma (W4.1). Así que el medidor es un **argumento**:
```rust
let mut jaula = Jaula::new(320.0, 72.0, |p: &Peticion| {
// En wawa: un `sys_medir_texto` que cruza a parley del lado nativo.
// En un test: un `Typesetter` de parley de verdad.
medir_del_otro_lado(p)
});
let montado = jaula.fotograma(vista());
let bytes = postcard::to_allocvec(jaula.escena())?;
```
Sin medidor no se devuelve cero — un cero silencioso colapsaría el layout entero
y la pantalla saldría vacía «funcionando». Se estima grosero **y se cuenta**:
`jaula.medidas_estimadas()` distinto de cero significa que ese layout es
aproximado.
En wawa, ese medidor ya viene hecho — es [`medidor::wawa`](src/medidor.rs), que
envuelve el syscall `sys_medir_texto`:
```rust
let mut jaula = Jaula::new(ancho, alto, llimphi_jaula::medidor::wawa);
```
`sys_medir_texto` **no está gateado por permiso**: medir texto no es un
privilegio, es aritmética sobre una fuente, y toda app que dibuje la necesita. Lo
que sí lleva es cuota, porque el shaping lo paga el kernel fuera del `fuel` de la
app. Fuera de wasm el medidor devuelve `None` en vez de un número inventado.
### Se queda con el árbol
El `Mounted` no cruza la frontera, así que el hit-test se resuelve de este lado:
el kernel manda una coordenada y la app contesta qué se tocó.
```rust
if let Some(msg) = jaula.click(&montado, x, y) {
modelo = App::update(modelo, msg);
}
```
Es lo contrario de la cáscara del kernel, que **no puede** hacer hit-test
justamente porque el árbol se quedó acá.
## Por qué está fuera del workspace
Enciende `llimphi-text/wire`, que es un backend excluyente de `vello` (el que usa
todo el resto del repo por default). La unificación de features de cargo los
juntaría y dispararía el `compile_error!` que `llimphi-text` tiene puesto para
delatarlo — es la misma trampa que ya mordió con `llimphi-wawa`.
Que las dos estén afuera no es una molestia: es el diseño. Son dos procesos en
dos lados de una frontera.
```bash
cargo test --manifest-path 02_ruway/llimphi/llimphi-jaula/Cargo.toml \
--no-default-features --features std
cargo check --manifest-path 02_ruway/llimphi/llimphi-jaula/Cargo.toml \
--target wasm32-unknown-unknown --no-default-features --features libm
```
## El fixture
Como el encoder y el reproductor no pueden compilarse juntos, la costura del test
es la misma que la de producción: **bytes**.
```bash
cargo run --example emitir_fixture --no-default-features --features std
```
Compone una vista con el compositor de verdad y deja los bytes en
`../llimphi-wawa/pruebas/fixture_jaula.postcard`, donde los levantan el test
`la_escena_que_emitio_el_compositor_llega_a_pixeles` **y el arranque del kernel**.
Si alguien cambia el formato del cable de un solo lado, esos dos lo dicen.
+106
View File
@@ -0,0 +1,106 @@
# llimphi-jaula
The **app-side** shell: runs the Llimphi loop inside a WASM sandbox and emits an
[`EscenaWire`](../llimphi-wire-escena/README.md) instead of rasterizing.
It is the twin of [`llimphi-wawa`](../llimphi-wawa/README.md), on the other side
of the boundary: that one receives and executes, this one composes and ships.
Phase W4 of [`03_ukupacha/wawa/PLAN-LLIMPHI.md`](../../../03_ukupacha/wawa/PLAN-LLIMPHI.md).
```
View<Msg> → mount → layout → paint → EscenaWire → postcard → …
…………………………… WASM boundary ……………………………
… → native shaping → rasterize
```
## The thing to understand
Both shells run the **same `llimphi-compositor`** — same mount, same taffy
layout, same 4,000-line `render.rs`. The only difference is the `Lienzo` alias
and, with it, the `Typesetter`.
That is the W4 result worth saying out loud: **there is no app compositor and
kernel compositor.** A widget written once works on Linux, inside the kernel, and
inside a sandbox.
## Two things this one does that the kernel shell doesn't
### It asks for measurements
taffy cannot size a label without knowing how big it is, and measuring means
shaping — precisely what must not happen inside the sandbox: the interpreter's
30.3x would eat 72% of the frame (W4.1). So the measurer is an **argument**:
```rust
let mut jaula = Jaula::new(320.0, 72.0, |p: &Peticion| {
// On wawa: a `sys_medir_texto` crossing to parley on the native side.
// In a test: a real parley `Typesetter`.
medir_del_otro_lado(p)
});
let montado = jaula.fotograma(vista());
let bytes = postcard::to_allocvec(jaula.escena())?;
```
With no measurer it does not return zero — a silent zero would collapse the whole
layout and the screen would come out blank while "working". It estimates coarsely
**and counts**: a non-zero `jaula.medidas_estimadas()` means that layout is
approximate.
On wawa that measurer already exists — it is [`medidor::wawa`](src/medidor.rs),
wrapping the `sys_medir_texto` syscall:
```rust
let mut jaula = Jaula::new(ancho, alto, llimphi_jaula::medidor::wawa);
```
`sys_medir_texto` is **not permission-gated**: measuring text is not a privilege,
it is arithmetic over a font, and every app that draws needs it. What it does
carry is a quota, because the kernel pays for the shaping outside the app's
`fuel`. Off wasm the measurer returns `None` rather than an invented number.
### It keeps the tree
The `Mounted` never crosses the boundary, so hit-testing resolves here: the
kernel sends a coordinate and the app answers what was hit.
```rust
if let Some(msg) = jaula.click(&montado, x, y) {
modelo = App::update(modelo, msg);
}
```
The opposite of the kernel shell, which **cannot** hit-test precisely because the
tree stayed here.
## Why it lives outside the workspace
It enables `llimphi-text/wire`, a backend mutually exclusive with `vello` (what
the rest of the repo uses by default). Cargo's feature unification would turn on
both and trip the `compile_error!` `llimphi-text` carries to catch exactly that —
the same trap `llimphi-wawa` already hit.
Both being outside isn't friction: it's the design. They are two processes on two
sides of a boundary.
```bash
cargo test --manifest-path 02_ruway/llimphi/llimphi-jaula/Cargo.toml \
--no-default-features --features std
cargo check --manifest-path 02_ruway/llimphi/llimphi-jaula/Cargo.toml \
--target wasm32-unknown-unknown --no-default-features --features libm
```
## The fixture
Since encoder and replayer cannot be compiled together, the test seam is the same
as the production one: **bytes**.
```bash
cargo run --example emitir_fixture --no-default-features --features std
```
It composes a view with the real compositor and drops the bytes in
`../llimphi-wawa/pruebas/fixture_jaula.postcard`, where the
`la_escena_que_emitio_el_compositor_llega_a_pixeles` test **and the kernel's boot
path** pick them up. If anyone changes the wire format on one side only, those
two will say so.
+146
View File
@@ -0,0 +1,146 @@
// =============================================================================
// emitir_fixture :: la escena que el kernel va a reproducir
// -----------------------------------------------------------------------------
// El encoder (esta jaula) y el reproductor (`llimphi-wawa`) NO pueden vivir en
// el mismo binario: `wire` y `cpu` son backends excluyentes de `llimphi-text`,
// y encender los dos dispara un `compile_error!` puesto a propósito. Eso no es
// un obstáculo del diseño, es el diseño — son dos procesos en dos lados de una
// frontera, y forzarlos a convivir para testearlos sería probar otra cosa.
//
// Así que la costura del test es la misma que la de producción: **bytes**. Este
// ejemplo compone una vista con el compositor de verdad, la serializa, y deja
// el resultado donde el test del otro lado lo levanta con `include_bytes!`.
//
// cargo run --example emitir_fixture --no-default-features --features std
//
// Si alguna vez el fixture y el reproductor dejan de entenderse, el test de
// `llimphi-wawa` lo dice — que es exactamente lo que uno quiere que pase si
// alguien cambia el formato del cable de un solo lado.
// =============================================================================
use std::io::Write;
use llimphi_jaula::taffy::prelude::*;
use llimphi_jaula::{Color, Jaula, Style, View};
use llimphi_text::wire::{Medida, Peticion};
/// El destino, relativo a este crate.
const DESTINO: &str = "../llimphi-wawa/pruebas/fixture_jaula.postcard";
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Toque,
}
/// El medidor de la jaula.
///
/// **Acá está la única pieza de W4 que todavía es un muñón**, y conviene que se
/// vea: en wawa esto tiene que ser un `sys_medir_texto` que cruza a parley del
/// lado nativo. Mientras no exista, el fixture se layoutea con una fórmula. La
/// consecuencia práctica: el texto del fixture se pinta bien —el kernel lo
/// shapea de verdad— pero ubicado según una medida aproximada.
fn medidor(p: &Peticion<'_>) -> Medida {
let chars = p.cadena.chars().count() as f32;
let ancho = chars * p.estilo.tamano_px * 0.52;
Medida {
width: ancho,
full_width: ancho,
height: p.estilo.tamano_px * p.estilo.alto_linea,
}
}
fn vista() -> View<Msg> {
let raiz = Style {
size: Size {
width: length(320.0),
height: length(72.0),
},
flex_direction: FlexDirection::Column,
justify_content: Some(JustifyContent::Center),
padding: Rect {
left: length(14.0),
right: length(14.0),
top: length(10.0),
bottom: length(10.0),
},
..Default::default()
};
View::new(raiz)
.fill(Color::from_rgba8(22, 26, 38, 255))
.radius(8.0)
.children(vec![
View::new(Style::default()).text(
"escena emitida por la jaula",
15.0,
Color::from_rgba8(230, 232, 240, 255),
),
View::new(Style::default())
.text(
"shapeada del otro lado",
12.0,
Color::from_rgba8(140, 200, 175, 255),
)
.on_click(Msg::Toque),
])
}
fn main() {
let mut jaula = Jaula::new(320.0, 72.0, medidor);
let _montado = jaula.fotograma(vista());
let escena = jaula.escena();
let bytes = postcard::to_allocvec(escena).expect("serializar");
println!("órdenes : {}", escena.len());
println!("textos : {}", escena.textos());
println!("estilos : {}", escena.estilos().len());
println!("estimadas : {}", jaula.medidas_estimadas());
println!("bytes : {}", bytes.len());
let destino = std::path::Path::new(DESTINO);
if let Some(dir) = destino.parent() {
std::fs::create_dir_all(dir).expect("crear directorio");
}
let mut f = std::fs::File::create(destino).expect("crear fixture");
f.write_all(&bytes).expect("escribir fixture");
println!("escrito en : {}", destino.display());
emitir_peticion_de_prueba();
}
/// Emite además los bytes de una `PeticionMedida` lista para embeber en una app
/// `.wasm` sin asignador.
///
/// `hello_wasm` es `no_std` y **sin alloc**: no puede construir un `String` para
/// armar la petición en tiempo de ejecución. Pero tampoco lo necesita — lo único
/// que el syscall pide son bytes, y los bytes se pueden calcular acá y embeber
/// como constante. Así la app de prueba ejercita el cruce de verdad sin arrastrar
/// un asignador que no le hace falta para nada más.
fn emitir_peticion_de_prueba() {
use llimphi_jaula::wire::{EstiloTextoWire, PeticionMedida};
let peticion = PeticionMedida {
cadena: "wawa mide texto".into(),
estilo: EstiloTextoWire {
tamano_px: 16.0,
..Default::default()
},
ancho_max: None,
};
let bytes = postcard::to_allocvec(&peticion).expect("serializar peticion");
println!("\n// --- pegar en 03_ukupacha/wawa/apps/hello_wasm/src/lib.rs ---");
println!(
"/// `PeticionMedida {{ cadena: {:?}, tamano_px: {} }}` en postcard.",
peticion.cadena, peticion.estilo.tamano_px
);
print!("const PETICION_MEDIDA: [u8; {}] = [", bytes.len());
for (i, b) in bytes.iter().enumerate() {
if i % 12 == 0 {
print!("\n ");
}
print!("0x{b:02x}, ");
}
println!("\n];");
}
+57
View File
@@ -0,0 +1,57 @@
//! Emite los patrones compartidos como **escena de cable** (`EscenaWire`)
//! serializada, para que el reproductor de wawa los pinte y se compare contra
//! el camino directo.
//!
//! Es una de las tres mitades de una verificación que no cabe en un proceso:
//! los backends de `llimphi-text` (`vello`, `cpu`, `wire`) son excluyentes, y
//! por eso este crate y `llimphi-wawa` viven en workspaces aparte. Acá compone
//! la jaula (backend `wire`, que **no shapea**); allá reproduce el kernel.
//!
//! ```bash
//! cargo run --manifest-path 02_ruway/llimphi/llimphi-jaula/Cargo.toml \
//! --no-default-features --features std --example emitir_patron -- <dir>
//! ```
//!
//! **Sin el patrón de texto, y no es un olvido.** En el perfil `wire` medir una
//! cadena es una *pregunta* que cruza la frontera y contesta el otro lado —eso
//! es lo que W4 sacó de la jaula—. Un medidor sintético de este lado haría que
//! el layout reservara un ancho y el reproductor pintara los glifos con otro:
//! el test mediría esa discrepancia inventada y no la fidelidad del cable. Por
//! eso el medidor de acá **paniquea**: si alguien mete texto en el patrón, se
//! entera de por qué no puede estar en vez de leer un rojo confuso.
use llimphi_jaula::{Jaula, Medida, Peticion};
include!("../../escena_patron.rs");
fn medidor(p: &Peticion<'_>) -> Medida {
panic!(
"el patrón del cable no puede llevar texto: medir «{}» es una pregunta \
que contesta el otro lado de la frontera (ver el encabezado)",
p.cadena
);
}
fn main() {
let dir = std::env::args().nth(1).unwrap_or_else(|| {
eprintln!("uso: emitir_patron <dir-de-salida>");
std::process::exit(2);
});
let dir = std::path::PathBuf::from(dir);
std::fs::create_dir_all(&dir).expect("crear el directorio de salida");
let mut emitidos = 0;
for (nombre, vista) in patrones::<()>() {
if nombre.starts_with("texto") {
continue;
}
let mut jaula = Jaula::new(ANCHO as f32, ALTO as f32, medidor);
let _montado = jaula.fotograma(vista);
let bytes = postcard::to_allocvec(jaula.escena()).expect("serializar la escena");
let ruta = dir.join(format!("{nombre}.wire"));
std::fs::write(&ruta, &bytes).expect("escribir la escena");
println!("{nombre}: {} bytes de cable → {}", bytes.len(), ruta.display());
emitidos += 1;
}
println!("patrones emitidos: {emitidos}");
}
+150
View File
@@ -0,0 +1,150 @@
// =============================================================================
// llimphi-jaula :: la cáscara del lado APP (W4 de 03_ukupacha/wawa/PLAN-LLIMPHI.md)
// -----------------------------------------------------------------------------
// `llimphi-wawa` es la cáscara del kernel: recibe una escena y la ejecuta. Ésta
// es la del otro lado de la frontera: compone una escena y la manda.
//
// View<Msg> → mount → layout → paint → EscenaWire → postcard → …
// …………………… frontera WASM ……………………
// … → shapear nativo → rasterizar
//
// Las dos corren el MISMO `llimphi-compositor` — el mismo mount, el mismo
// layout de taffy, el mismo `render.rs` de 4.000 líneas. Lo único que cambia es
// el alias `Lienzo` y, con él, el `Typesetter`. Ése es el resultado de W4 que
// vale la pena decir en voz alta: **no hay un compositor para apps y otro para
// el kernel**.
//
// ## Las dos cosas que esta cáscara hace y la del kernel no
//
// **Pregunta las medidas.** taffy no puede dimensionar una etiqueta sin saber
// cuánto mide, y medir es shapear — justo lo que no debe pasar acá adentro (el
// 30,3x del intérprete se comería el 72% del fotograma, W4.1). Así que el
// medidor es un argumento: en wawa envuelve un `sys_`, en un test envuelve un
// `Typesetter` de parley de verdad. Ver `llimphi_text::wire`.
//
// **Se queda con el árbol.** El `Mounted` no cruza la frontera, así que el
// hit-test se resuelve de este lado: el kernel manda una coordenada y la app
// contesta qué se tocó. Es lo contrario de la cáscara del kernel, que no puede
// hacer hit-test justamente porque el árbol se quedó acá.
// =============================================================================
#![cfg_attr(not(any(feature = "std", test)), no_std)]
extern crate alloc;
use llimphi_compositor::Mounted;
use llimphi_layout::{ComputedLayout, LayoutTree};
/// Cómo preguntarle las medidas al kernel — el otro lado de W4.
pub mod medidor;
pub use llimphi_compositor::View;
pub use llimphi_layout::taffy;
pub use llimphi_layout::Style;
pub use llimphi_text;
pub use llimphi_text::peniko;
pub use llimphi_text::peniko::Color;
pub use llimphi_text::wire::{Medida, Peticion, Typesetter};
pub use llimphi_wire_escena as wire;
pub use llimphi_wire_escena::EscenaWire;
/// La cáscara de la app: el estado que sobrevive entre fotogramas.
pub struct Jaula {
typesetter: Typesetter,
escena: EscenaWire,
computado: Option<ComputedLayout>,
ancho: f32,
alto: f32,
}
impl Jaula {
/// Una jaula que mide preguntando del otro lado de la frontera.
///
/// El medidor NO es opcional en la práctica: sin él el layout es una
/// estimación grosera. Se pide por constructor para que olvidarlo sea
/// imposible en vez de ser un default silencioso.
pub fn new<F>(ancho: f32, alto: f32, medidor: F) -> Self
where
F: FnMut(&Peticion<'_>) -> Medida + 'static,
{
Self {
typesetter: Typesetter::con_medidor(medidor),
escena: EscenaWire::new(),
computado: None,
ancho,
alto,
}
}
/// Cuántas medidas salieron de la estimación de emergencia en vez del
/// medidor. Distinto de cero = el layout de esas etiquetas es aproximado.
pub fn medidas_estimadas(&self) -> usize {
self.typesetter.medidas_estimadas()
}
/// Redimensiona el viewport.
pub fn redimensionar(&mut self, ancho: f32, alto: f32) {
self.ancho = ancho;
self.alto = alto;
}
/// Corre un fotograma: monta, layoutea preguntando las medidas, y graba la
/// escena para mandar.
///
/// Devuelve el `Mounted` por la misma razón que [`llimphi_wawa::Cascara`]:
/// es genérico en `Msg` y la cáscara no lo es. Acá además hace falta
/// conservarlo, porque el hit-test se resuelve de este lado.
pub fn fotograma<Msg: Clone>(&mut self, vista: View<Msg>) -> Mounted<Msg> {
let mut arbol = LayoutTree::new();
let montado = llimphi_compositor::mount(&mut arbol, vista);
let ts = &mut self.typesetter;
let tmap = &montado.text_measures;
let computado = arbol
.compute_with_measure(montado.root, (self.ancho, self.alto), |nid, k, a| {
match tmap.get(&nid) {
Some(tm) => llimphi_compositor::measure_text_node(ts, tm, k, a),
None => taffy::Size {
width: 0.0,
height: 0.0,
},
}
})
.unwrap_or_default();
self.escena.reset();
llimphi_compositor::paint(
&mut self.escena,
&montado,
&computado,
&mut self.typesetter,
None,
None,
);
self.computado = Some(computado);
montado
}
/// La escena del último fotograma, lista para serializar y mandar.
pub fn escena(&self) -> &EscenaWire {
&self.escena
}
/// El layout del último fotograma, contra el que probar los clicks.
pub fn computado(&self) -> Option<&ComputedLayout> {
self.computado.as_ref()
}
/// El `Msg` del nodo clickeable bajo `(x, y)`.
///
/// El hit-test vive de ESTE lado a propósito: el árbol nunca cruzó la
/// frontera. El kernel manda una coordenada; qué hay debajo lo sabe la app.
pub fn click<Msg: Clone>(&self, montado: &Mounted<Msg>, x: f32, y: f32) -> Option<Msg> {
let computado = self.computado.as_ref()?;
let idx = llimphi_compositor::hit_test_click(montado, computado, x, y)?;
montado.nodes.get(idx).and_then(|n| n.on_click.clone())
}
}
#[cfg(test)]
mod pruebas;
+125
View File
@@ -0,0 +1,125 @@
// =============================================================================
// medidor :: preguntarle las medidas al kernel (W4, el lado app)
// -----------------------------------------------------------------------------
// El `Typesetter` de la jaula no shapea: pregunta. Este módulo es la pregunta
// concreta cuando el que contesta es wawa.
//
// El porqué está en W4.1 y conviene tenerlo a mano, porque la tentación de
// «medir acá nomás, total es una cuenta» es real: shapear adentro de la jaula
// paga el 30,3x del intérprete, y el fotograma de una pantalla densa con texto
// vivo pasa de 71 fps a 21,8. Medir ES shapear — parley resuelve ligaduras,
// bidi y selección de cara para saber cuánto ocupa una cadena—, así que medir
// adentro sería tirar la decisión entera de W4 por una comodidad.
//
// La forma del contrato:
//
// app kernel
// ─── ──────
// PeticionMedida ─postcard─►
// parley, con la caché del sistema
// ◄─12 bytes─ MedidaTexto (3 f32 LE)
//
// La respuesta es de tamaño fijo a propósito: el kernel la escribe sin alocar.
// =============================================================================
use alloc::vec;
use llimphi_text::wire::{Medida, Peticion};
use llimphi_wire_escena::{MedidaTexto, PeticionMedida};
// La capacidad que el kernel inyecta. Ausente en cualquier otro anfitrión.
//
// `wasm_import_module = "wawa"` es lo que la ata a la matriz de capacidades: si
// el kernel no registró esta función, el módulo NO instancia. La frontera es
// física, no una comprobación en tiempo de ejecución — no hay tabla que
// escalar porque no hay tabla.
#[cfg(target_arch = "wasm32")]
#[link(wasm_import_module = "wawa")]
extern "C" {
fn sys_medir_texto(peticion_ptr: u32, peticion_len: u32, salida_ptr: u32) -> i32;
}
/// Tope del búfer de petición. Espeja el que el kernel impone; tenerlo de los
/// dos lados evita hacer un viaje que ya se sabe que va a fallar.
const MAX_BYTES_PETICION: usize = 8 * 1024;
/// El medidor de wawa: el que se le pasa a [`crate::Jaula::new`] corriendo
/// dentro del kernel.
///
/// ```ignore
/// let mut jaula = Jaula::new(ancho, alto, llimphi_jaula::medidor::wawa);
/// ```
///
/// Devuelve una medida en cero **sólo** si el kernel rechazó la petición (cuota
/// agotada, payload inválido). El contador de
/// [`crate::Jaula::medidas_estimadas`] no cubre este caso porque acá sí hay
/// medidor; quien necesite distinguirlo tiene [`medir`], que devuelve el código.
pub fn wawa(p: &Peticion<'_>) -> Medida {
medir(p).unwrap_or(Medida {
width: 0.0,
full_width: 0.0,
height: 0.0,
})
}
/// Como [`wawa`] pero devolviendo `None` cuando el kernel rechazó la petición.
///
/// La distinción importa: una medida en cero y una medida que no se pudo obtener
/// producen el mismo layout colapsado, pero sólo una de las dos es un bug de la
/// app. Quien quiera reaccionar —reintentar el fotograma siguiente, recortar la
/// cadena— necesita saber cuál fue.
pub fn medir(p: &Peticion<'_>) -> Option<Medida> {
let peticion = PeticionMedida {
cadena: alloc::string::String::from(p.cadena),
estilo: p.estilo.clone(),
ancho_max: p.ancho_max,
};
let bytes = postcard::to_allocvec(&peticion).ok()?;
if bytes.is_empty() || bytes.len() > MAX_BYTES_PETICION {
return None;
}
let mut salida = vec![0u8; MedidaTexto::BYTES];
let codigo = invocar(&bytes, &mut salida);
if codigo != 0 {
return None;
}
let mut fijo = [0u8; MedidaTexto::BYTES];
fijo.copy_from_slice(&salida);
let m = MedidaTexto::de_bytes(&fijo);
Some(Medida {
width: m.width,
full_width: m.full_width,
height: m.height,
})
}
/// El cruce en sí. Aislado en una función para que el resto del módulo sea
/// código común y sólo esto dependa del anfitrión.
#[cfg(target_arch = "wasm32")]
fn invocar(peticion: &[u8], salida: &mut [u8]) -> i32 {
// SEGURIDAD: los dos punteros describen NUESTRA memoria lineal y el kernel
// valida los rangos contra sus límites reales antes de leer o escribir un
// solo byte — no confía en que el runtime lo haya hecho. Un rango que se
// desborde aborta este módulo, que es lo correcto: la culpa sería nuestra.
unsafe {
sys_medir_texto(
peticion.as_ptr() as u32,
peticion.len() as u32,
salida.as_mut_ptr() as u32,
)
}
}
/// Fuera de wasm no hay kernel al que preguntarle.
///
/// Existe para que el crate compile y se pueda testear en el anfitrión sin
/// `#[cfg]` regados por todos lados. Devuelve `Ausente` (-1), o sea que
/// [`medir`] da `None` y [`wawa`] da cero — nunca un número inventado que
/// parezca una medida buena.
#[cfg(not(target_arch = "wasm32"))]
fn invocar(_peticion: &[u8], _salida: &mut [u8]) -> i32 {
-1
}
+211
View File
@@ -0,0 +1,211 @@
// =============================================================================
// pruebas_wire :: que el encoder produzca una escena que sirva (W4)
// -----------------------------------------------------------------------------
// Que esto COMPILE ya prueba lo principal —`render.rs` no cambió una línea y sin
// embargo emite hacia el IR de escena— pero compilar no es suficiente, y en este
// crate menos que en ningún otro: con un medidor que devuelva ceros el layout se
// colapsa entero y la escena sale «bien» con todas las cajas en el origen.
//
// Así que se certifica lo que puede salir mal en silencio:
//
// 1. Que el texto viaje SIN SHAPEAR (es W4 entero).
// 2. Que se haya usado el medidor de verdad y no la estimación de emergencia.
// 3. Que la posición que emite el paint sea la que el layout calculó.
// 4. Que un subárbol rotado conserve su matriz en vez de aplanarse.
// =============================================================================
use alloc::vec;
use llimphi_layout::taffy::prelude::*;
use llimphi_layout::Style;
use llimphi_text::peniko::Color;
use llimphi_text::wire::{Medida, Peticion};
use llimphi_wire_escena::{EscenaWire, OrdenWire, UbicacionWire};
use crate::{Jaula, View};
/// Ancho por carácter del medidor de prueba. Un número redondo y feo a
/// propósito: si algún cálculo lo perdiera, la diferencia sería visible.
const ANCHO_GLIFO: f32 = 10.0;
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Nada,
}
/// Un medidor determinista que ocupa el lugar del `sys_` de wawa.
///
/// En producción esto pregunta del otro lado de la frontera y allá contesta
/// parley. Acá contesta una fórmula, porque lo que este test certifica no es la
/// fidelidad tipográfica —eso lo prueba el reproductor, que shapea de verdad—
/// sino que el número que devuelve el medidor sea el que termina gobernando el
/// layout y la posición emitida.
fn medidor(p: &Peticion<'_>) -> Medida {
let chars = p.cadena.chars().count() as f32;
let ancho = chars * ANCHO_GLIFO;
Medida {
width: ancho,
full_width: ancho,
height: p.estilo.tamano_px * p.estilo.alto_linea,
}
}
fn pantalla(w: f32, h: f32) -> Style {
Style {
size: Size {
width: length(w),
height: length(h),
},
..Default::default()
}
}
/// Corre un fotograma entero y devuelve la escena lista para el cable.
fn componer(vista: View<Msg>, viewport: (f32, f32)) -> (EscenaWire, usize) {
let mut jaula = Jaula::new(viewport.0, viewport.1, medidor);
let _montado = jaula.fotograma(vista);
(jaula.escena().clone(), jaula.medidas_estimadas())
}
#[test]
fn el_paint_emite_el_texto_sin_shapear() {
let vista: View<Msg> = View::new(pantalla(200.0, 60.0))
.fill(Color::from_rgba8(10, 10, 20, 255))
.children(vec![View::new(Style::default()).text(
"hola",
14.0,
Color::from_rgba8(255, 255, 255, 255),
)]);
let (escena, estimadas) = componer(vista, (200.0, 60.0));
// Que NO se haya estimado es tan importante como que haya texto: una
// estimación silenciosa querría decir que el medidor no se enchufó y que
// todo el layout de este test es ficción.
assert_eq!(
estimadas, 0,
"se midió por estimación — el medidor no se usó"
);
assert_eq!(escena.textos(), 1, "tiene que haber exactamente un texto");
let texto = escena
.ordenes()
.iter()
.find_map(|o| match o {
OrdenWire::Texto(t) => Some(t),
_ => None,
})
.expect("la orden de texto");
assert_eq!(texto.cadena, "hola");
assert_eq!(texto.color, [255, 255, 255, 255]);
// El estilo se internó y se puede resolver.
let estilo = escena.estilo(texto.estilo).expect("estilo en tabla");
assert_eq!(estilo.tamano_px, 14.0);
// Y el fondo se emitió como relleno, o sea que el resto del paint también
// pasó por el cable y no sólo el texto.
assert!(
escena
.ordenes()
.iter()
.any(|o| matches!(o, OrdenWire::Rellenar { .. })),
"falta el relleno del fondo"
);
}
#[test]
fn la_medida_del_medidor_gobierna_el_layout_y_la_posicion() {
// Dos etiquetas en columna: la segunda tiene que caer al alto de la primera,
// que es el número que devolvió el medidor. Si el layout ignorara la medida
// —o si el paint emitiera coordenadas crudas— las dos se pisarían en y=0.
let alto_linea = 14.0 * 1.2;
let vista: View<Msg> = View::new(Style {
size: Size {
width: length(300.0),
height: length(100.0),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.children(vec![
View::new(Style::default()).text("arriba", 14.0, Color::BLACK),
View::new(Style::default()).text("abajo", 14.0, Color::BLACK),
]);
let (escena, estimadas) = componer(vista, (300.0, 100.0));
assert_eq!(estimadas, 0);
let ys: alloc::vec::Vec<f32> = escena
.ordenes()
.iter()
.filter_map(|o| match o {
OrdenWire::Texto(t) => Some(t.xf.traslacion().1),
_ => None,
})
.collect();
assert_eq!(ys.len(), 2, "dos textos");
// Las comparaciones van con holgura de un píxel, y la razón no es pereza:
// taffy REDONDEA el rect final a píxeles enteros, así que una línea de 16,8
// px queda en una caja de 17, y el centrado vertical del paint la baja 0,1.
// Esos 0,1 son comportamiento correcto —el mismo que produce el backend de
// vello— y exigir el cero exacto sería certificar un bug.
assert!(ys[0] < 1.0, "el primero arriba de todo, cayó a {}", ys[0]);
assert!(
(ys[1] - alto_linea).abs() <= 1.0,
"el segundo tenía que caer a ~{alto_linea}, cayó a {}",
ys[1]
);
// Lo que de verdad importa: el segundo NO se pisa con el primero. Si el
// layout hubiera ignorado la medida, los dos estarían en cero.
assert!(
ys[1] - ys[0] > alto_linea * 0.9,
"los dos textos se pisaron: {} y {}",
ys[0],
ys[1]
);
}
#[test]
fn un_subarbol_rotado_conserva_su_matriz() {
// El caso que motivó que la ubicación del texto sea `UbicacionWire` y no un
// punto. Con un punto, esto se pintaría derecho y nada avisaría.
let vista: View<Msg> = View::new(pantalla(200.0, 200.0)).children(vec![View::new(
Style::default(),
)
.transform(llimphi_text::peniko::kurbo::Affine::rotate(0.4))
.children(vec![View::new(Style::default()).text(
"girado",
14.0,
Color::BLACK,
)])]);
let (escena, _) = componer(vista, (200.0, 200.0));
let texto = escena
.ordenes()
.iter()
.find_map(|o| match o {
OrdenWire::Texto(t) => Some(t),
_ => None,
})
.expect("la orden de texto");
assert!(
matches!(texto.xf, UbicacionWire::Afin(_)),
"un texto bajo una rotación tiene que viajar con la matriz entera, vino {:?}",
texto.xf
);
}
#[test]
fn sin_medidor_se_estima_y_se_delata() {
// La red de seguridad: si alguien olvida enchufar el medidor, el layout es
// aproximado — pero el contador lo dice en vez de que la pantalla salga rara
// sin explicación.
let mut ts = llimphi_text::wire::Typesetter::new();
let ancho = llimphi_text::text_width_with(&mut ts, "hola", 14.0);
assert!(ancho > 0.0, "la estimación no puede devolver cero");
assert_eq!(ts.medidas_estimadas(), 1, "y tiene que quedar contada");
}
+2 -1
View File
@@ -514,7 +514,8 @@ impl<A: App> Runner<A> {
));
// SAFETY: los handles apuntan a objetos Wayland que `self` mantiene vivos.
let make_target = || wgpu::SurfaceTargetUnsafe::RawHandle {
raw_display_handle: display_handle,
// wgpu 29 volvió opcional el display del target.
raw_display_handle: Some(display_handle),
raw_window_handle: window_handle,
};
+28 -1
View File
@@ -9,7 +9,34 @@ publish.workspace = true
description = "Layout engine for llimphi (Flexbox + CSS Grid via taffy)."
repository.workspace = true
[dependencies]
taffy = { workspace = true }
# taffy declarado DIRECTO y no `{ workspace = true }`: apagar `default-features`
# desde un miembro no tiene efecto si el workspace no lo declaró así (cargo lo
# avisa y lo ignora). Y apagarlo es justamente el punto — el `default` de taffy
# arrastra `std`, que es lo único que ataba este crate a un sistema operativo.
#
# Se conservan las demás features del default (grid, block_layout, calc,
# content_size): la meta es soltar `std`, no recortar capacidades de layout por
# la ventana.
#
# La excepción es `detailed_layout_info`, y no es una elección de diseño sino un
# BUG DE TAFFY 0.9.2: con esa feature + `grid` y sin `std`, su propio código usa
# `Box` sin importar `alloc::boxed::Box` y no compila
# (`tree/layout.rs:354`, `tree/taffy_tree.rs:511`). Nadie en el repo consume
# `DetailedLayoutInfo` —se verificó por grep sobre todos los cuadrantes—, así
# que apagarla no cuesta nada. Si algún día hace falta, hay que arreglar taffy
# aguas arriba, no reactivarla acá.
taffy = { version = "0.12", default-features = false, features = [
"alloc",
"taffy_tree",
"flexbox",
"grid",
"block_layout",
"calc",
"content_size",
] }
# `HashMap` sin sistema operativo. Es el mismo mapa que usa la stdlib por
# dentro — de hecho `std::collections::HashMap` ES hashbrown con otra cara.
hashbrown = { version = "0.17", default-features = false, features = ["default-hasher", "inline-more"] }
[dev-dependencies]
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
+72 -9
View File
@@ -5,7 +5,16 @@
//! árbol a `compute(root, viewport)` y obtiene un [`ComputedLayout`] con
//! un rect absoluto por nodo, listo para `llimphi-raster`.
use std::collections::HashMap;
// `no_std` salvo bajo test. El layout es aritmética sobre un árbol: nunca tuvo
// motivo para exigir sistema operativo, sólo la inercia de `std::collections`.
// Es lo que permite que el mismo cálculo de cajas corra en Linux y en el kernel
// de wawa (W1 de PLAN-LLIMPHI.md).
#![cfg_attr(not(test), no_std)]
extern crate alloc;
use alloc::string::{String, ToString};
use hashbrown::HashMap;
pub use taffy;
pub use taffy::prelude::*;
@@ -16,15 +25,18 @@ pub enum LayoutError {
Taffy(String),
}
impl std::fmt::Display for LayoutError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl core::fmt::Display for LayoutError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Taffy(s) => write!(f, "taffy: {s}"),
}
}
}
impl std::error::Error for LayoutError {}
// `core::error::Error` es estable desde Rust 1.81 y es LA MISMA trait que
// `std::error::Error` (std la reexporta), así que ningún consumidor nota el
// cambio: quien haga `Box<dyn std::error::Error>` con esto sigue compilando.
impl core::error::Error for LayoutError {}
/// Caja absoluta de un nodo (origen en la esquina superior izquierda del viewport).
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -45,6 +57,29 @@ impl ComputedLayout {
pub fn get(&self, node: NodeId) -> Option<Rect> {
self.rects.get(&node).copied()
}
/// Vacía el mapa **conservando la capacidad**, para escribir el layout del
/// próximo frame encima del bloque que ya está tomado.
///
/// Es la contraparte de lo que el Bloque 21 hizo con el `Mounted`: sin
/// esto, cada frame construye un `HashMap` de N entradas desde cero, y un
/// `HashMap` que crece se rehashea **a saltos** (duplica la tabla, re-inserta
/// todo). A 200k nodos eso medía 14 MB por frame — y era la mitad del «hilo
/// abierto» del plan, donde un frame suelto aparecía con 1.807 asignaciones
/// y 25 MB en la etapa de layout mientras el resto del lote iba en 465.
///
/// Contrapeso, igual que en el mount: si el árbol se desploma (una tabla de
/// 200k filas → una pantalla vacía), conservar la capacidad vieja sería
/// quedarse con esos megas tomados para siempre. Se encoge sólo cuando sobra
/// más de 4×, dejando 2× de margen, para que una UI que oscila alrededor de
/// un tamaño no rebote entre encoger y volver a crecer.
pub fn limpiar(&mut self) {
let n = self.rects.len().max(1);
if self.rects.capacity() > 4 * n {
self.rects.shrink_to(2 * n);
}
self.rects.clear();
}
}
/// Árbol de layout. Encapsula la `TaffyTree` y la lógica de absolutización.
@@ -119,10 +154,31 @@ impl LayoutTree {
/// (p. ej. texto a shapear con parley) sin acoplar este crate a la capa
/// de tipografía.
pub fn compute_with_measure<F>(
&mut self,
root: NodeId,
viewport: (f32, f32),
measure: F,
) -> Result<ComputedLayout, LayoutError>
where
F: FnMut(NodeId, taffy::Size<Option<f32>>, taffy::Size<AvailableSpace>) -> taffy::Size<f32>,
{
self.compute_with_measure_reciclando(root, viewport, measure, None)
}
/// Igual que [`LayoutTree::compute_with_measure`], pero escribiendo el
/// resultado **encima del `ComputedLayout` del frame anterior** en vez de
/// pedir uno nuevo.
///
/// El caller le entrega el mapa que ya no va a leer; acá se limpia
/// conservando capacidad (ver [`ComputedLayout::limpiar`]) y se rellena. La
/// fachada de arriba le pasa `None` — comportamiento histórico exacto, cero
/// regresión para el resto de los callers.
pub fn compute_with_measure_reciclando<F>(
&mut self,
root: NodeId,
viewport: (f32, f32),
mut measure: F,
reciclado: Option<ComputedLayout>,
) -> Result<ComputedLayout, LayoutError>
where
F: FnMut(NodeId, taffy::Size<Option<f32>>, taffy::Size<AvailableSpace>) -> taffy::Size<f32>,
@@ -139,7 +195,8 @@ impl LayoutTree {
},
)
.map_err(|e| LayoutError::Taffy(e.to_string()))?;
let mut out = ComputedLayout::default();
let mut out = reciclado.unwrap_or_default();
out.limpiar();
flatten(&self.inner, root, 0.0, 0.0, &mut out.rects)?;
Ok(out)
}
@@ -237,10 +294,16 @@ fn flatten(
h: layout.size.height,
},
);
let children = tree
.children(node)
.map_err(|e| LayoutError::Taffy(e.to_string()))?;
for child in children {
// `tree.children(node)` hace `self.children[node].clone()`: **una asignación
// de heap por cada nodo con hijos, en cada frame**, sólo para recorrerlos y
// tirarla. A 200k nodos en 448 columnas eso medía 448 de las 465
// asignaciones de la etapa de layout. Se recorre por índice, que lee el
// mismo `Vec` de taffy sin copiarlo.
let n = tree.child_count(node);
for i in 0..n {
let child = tree
.child_at_index(node, i)
.map_err(|e| LayoutError::Taffy(e.to_string()))?;
flatten(tree, child, x, y, out)?;
}
Ok(())
@@ -0,0 +1,65 @@
# taffy#1000 — `distribute_space_up_to_limits` olvida lo que ya repartió
#
# CAUSA RAÍZ, encontrada instrumentando taffy 0.12.2 (2026-07-28). La función
# reparte espacio libre entre pistas «hasta su límite», en varias iteraciones,
# acumulando en `track.item_incurred_increase`. Las DOS cuentas que deciden
# cuánto le cabe a cada pista miran `track_affected_property(track)` PELADO, sin
# sumarle lo ya acumulado:
#
# · el margen restante (`min_increase_limit`), que queda inflado en todo lo
# que la pista ya creció en iteraciones anteriores; y
# · el guarda que aplica el incremento, que por lo mismo deja pasar pistas ya
# CONGELADAS por el filtro de elegibilidad de arriba.
#
# Traza del caso real (infobox «Rust Foundation» de Wikipedia, contenedor 352,
# bases [87, 140], límites [147, 197], libre 125):
# iter 1: cabe min(60, 57) = 57 → ambas +57 → [144, 197], sobran 11.
# iter 2: elegible sólo la 1ª (la 2ª topó), pero el margen se calcula
# 147 87 = 60 (debería ser 3) y el guarda compara 87 + 11 ≤ 147
# (debería ser 144 + 11) → **las DOS** se llevan +11.
# salida [155, 208] = 363 en un contenedor de 352, con límites que sumaban
# 344 y entraban de sobra.
#
# Explica las dos caras que veníamos midiendo (ver
# `tests/grid_maximise_overshoot.rs`): la pista que supera su propio
# max-content, y el reparto que empeora al SACAR filas.
#
# VERIFICADO contra el repo de taffy en el tag v0.12.2: la suite entera pasa —
# 4421 tests generados (los de web-platform), 105 unitarios, 43 hand-written,
# 5 de doc; cero fallas. Y arregla los dos casos: el original de taffy#1000
# (contenedor 928, max-contents [400, 674]) pasa de [528, 528] = 1056 a
# [400, 528] = 928 EXACTO, y el infobox de 2 filas de 363 a 344.
#
# Sigue VIVO en master (0972b84, posterior a 0.12.2).
#
# Para aplicarlo hace falta un taffy vendorizado + `[patch.crates-io]`; mientras
# eso no se decida, esto es la evidencia y la receta, no un parche activo.
#
diff --git a/src/compute/grid/track_sizing.rs b/src/compute/grid/track_sizing.rs
index 10e3ea2..9d8ad07 100644
--- a/src/compute/grid/track_sizing.rs
+++ b/src/compute/grid/track_sizing.rs
@@ -1399,7 +1399,10 @@ fn distribute_space_up_to_limits(
.iter()
.filter(|track| track_affected_property(track) + track.item_incurred_increase < track_limit(track))
.filter(|track| track_is_affected(track))
- .map(|track| (track_limit(track) - track_affected_property(track)) / track_distribution_proportion(track))
+ .map(|track| {
+ (track_limit(track) - track_affected_property(track) - track.item_incurred_increase)
+ / track_distribution_proportion(track)
+ })
.min_by(|a, b| a.total_cmp(b))
.unwrap(); // We will never pass an empty track list to this function
let iteration_item_incurred_increase =
@@ -1407,7 +1410,10 @@ fn distribute_space_up_to_limits(
for track in tracks.iter_mut().filter(|track| track_is_affected(track)) {
let increase = iteration_item_incurred_increase * track_distribution_proportion(track);
- if increase > 0.0 && track_affected_property(track) + increase <= track_limit(track) + THRESHOLD {
+ if increase > 0.0
+ && track_affected_property(track) + track.item_incurred_increase + increase
+ <= track_limit(track) + THRESHOLD
+ {
track.item_incurred_increase += increase;
space_to_distribute -= increase;
}
@@ -0,0 +1,92 @@
//! Un `margin-right` NEGATIVO saca la caja de borde del contenedor a
//! propósito: la que tiene que entrar es la caja de MARGEN.
//!
//! Es el botón de idiomas de Wikipedia («97 languages»), que lleva
//! `margin-right: -12px` para que su padding interno no meta sangría en el
//! borde derecho del encabezado. Medido en el árbol real: fila de 880, botón de
//! 147 en x=745 → el borde llega a 892 y parece un desborde de 12px, pero la
//! caja de margen mide 135 y cierra EXACTO en 880. Taffy reparte bien; el que
//! medía mal era el harness `salud_web`, que comparaba bordes.
//!
//! Este test fija las dos mitades del contrato, porque de la primera sola se
//! saca la conclusión equivocada: (a) sin el margen negativo el item entra
//! justo, y (b) con él el borde asoma exactamente lo que se pidió, ni más.
use llimphi_layout::taffy::{self, prelude::*, AvailableSpace};
/// Fila de 880 con una columna elástica y un item inflexible de 147 (una hoja
/// medida, como el texto real) que lleva `margin-right: margen`. Devuelve
/// `(x, ancho)` del item.
fn fila_con_item(margen: f32) -> (f32, f32) {
let mut t: TaffyTree<f32> = TaffyTree::new();
let item = t
.new_leaf_with_context(
Style {
flex_grow: 0.0,
flex_shrink: 0.0,
margin: Rect {
left: zero(),
right: length(margen),
top: zero(),
bottom: zero(),
},
..Default::default()
},
147.0,
)
.unwrap();
let columna = t
.new_leaf(Style {
flex_grow: 1.0,
flex_basis: length(0.0_f32),
min_size: Size { width: length(0.0_f32), height: auto() },
..Default::default()
})
.unwrap();
let fila = t
.new_with_children(
Style {
display: Display::Flex,
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
align_items: Some(AlignItems::START),
size: Size { width: length(880.0_f32), height: auto() },
..Default::default()
},
&[columna, item],
)
.unwrap();
t.compute_layout_with_measure(
fila,
taffy::Size { width: AvailableSpace::Definite(880.0), height: AvailableSpace::MaxContent },
|known, avail, _n, ctx, _st| {
let Some(&mut mx) = ctx else { return taffy::Size::ZERO };
let w = known.width.unwrap_or(match avail.width {
AvailableSpace::Definite(w) => w.min(mx),
AvailableSpace::MaxContent => mx,
AvailableSpace::MinContent => mx,
});
taffy::Size { width: w, height: 18.0 }
},
)
.unwrap();
let l = t.layout(item).unwrap();
(l.location.x, l.size.width)
}
#[test]
fn sin_margen_negativo_el_item_cierra_justo_en_el_contenedor() {
let (x, w) = fila_con_item(0.0);
assert_eq!(w, 147.0, "el item mide su max-content");
assert!((x + w - 880.0).abs() < 0.5, "cierra en el borde: {x}+{w}");
}
#[test]
fn con_margen_negativo_el_borde_asoma_justo_lo_pedido() {
let (x, w) = fila_con_item(-12.0);
// La caja de MARGEN cierra exacto en 880…
assert!((x + w - 12.0 - 880.0).abs() < 0.5, "la caja de margen no cierra: {x}+{w}-12");
// …y por eso la de BORDE asoma 12, ni uno más. Un harness que mida bordes
// ve acá un «desborde» de 12px que ningún browser reporta.
assert!((x + w - 892.0).abs() < 0.5, "el borde asoma otra cosa: {x}+{w}");
}
@@ -0,0 +1,235 @@
//! Aislamiento del desborde de la tabla «CSS priority scheme» del artículo CSS
//! de Wikipedia (salud_web +148px) y del grid del footer de wordpress.org: las
//! pistas `auto` de un grid CRECEN MÁS ALLÁ de su max-content y se pasan de un
//! contenedor DEFINIDO que ya estaba sobre-suscripto.
//!
//! Es el mismo bug upstream que motivó el fork revertido en 789595479 y que
//! quedó anotado como «sin repro sintético» — este archivo ES el repro.
//! Reportado como **DioxusLabs/taffy#1000** (con repro autónomo, sólo depende
//! de `taffy = "0.12.2"`). Lo que faltaba para reproducirlo son DOS
//! ingredientes juntos:
//! 1. una **función de medida** (no hojas de tamaño fijo), y
//! 2. max-contents **asimétricos**, de modo que una pista tope su límite y
//! sobre espacio para repartir entre las otras.
//! Con pistas simétricas (`[400, 400]` en 500) taffy reparte bien, y con una
//! asimetría que no llega a topar (`[100, 674]` en 500) también — por eso los
//! intentos anteriores daban verde.
//!
//! Consecuencia para el wire, MEDIDA (no hipótesis): capar la tabla con
//! `max-width: 100%` en `box_style` **empeora** el corpus. La caja queda en 928
//! pero las pistas siguen en ~1074, así que el desborde pasa de 1 (la tabla) a
//! 11 (cada celda). Por eso `tabla_shrink` NO lleva cap: hasta que taffy
//! reparta bien, un desborde de la tabla entera es más fiel que once de celdas.
//!
//! CSS Grid §12.6-12.8: al distribuir espacio libre, una pista `auto` se
//! congela al llegar a su growth limit (su max-content) y el sobrante se
//! reparte entre las que aún pueden crecer. Aquí la pista 0 debería congelarse
//! en 400 y la 1 quedarse con el resto (528): 400 + 528 = 928, exacto.
//!
//! ## CAUSA RAÍZ ENCONTRADA (2026-07-28) — y arreglada en 8 líneas
//!
//! Instrumentando `taffy 0.12.2` cayó: `distribute_space_up_to_limits`
//! (`compute/grid/track_sizing.rs`) reparte en VARIAS iteraciones y acumula lo
//! entregado en `track.item_incurred_increase`, pero las dos cuentas que
//! deciden cuánto le cabe a cada pista miran `track_affected_property(track)`
//! PELADO, sin sumarle lo ya acumulado: el margen restante
//! (`min_increase_limit`) queda inflado, y el guarda que aplica el incremento
//! deja pasar pistas que el filtro de elegibilidad ya había congelado.
//!
//! Traza del infobox (contenedor 352, bases [87, 140], límites [147, 197],
//! libre 125): la 1ª iteración da +57 a cada una → [144, 197] y sobran 11; en
//! la 2ª sólo la pista 0 es elegible, pero su margen se calcula 14787 = 60
//! (son 3) y el guarda compara 87+11 ≤ 147 (debería ser 144+11) → **las dos**
//! se llevan +11. Salida [155, 208] = 363 con límites que sumaban 344.
//!
//! El parche vive en `../taffy-1000-distribute-space.patch` (sumar
//! `item_incurred_increase` en las dos cuentas). **Verificado contra el repo
//! de taffy en el tag v0.12.2: pasa la suite ENTERA — 4421 tests generados de
//! web-platform, 105 unitarios, 43 hand-written, 5 de doc, cero fallas** — y
//! arregla los dos casos de este archivo. Sigue vivo en master (0972b84).
//! Aplicarlo acá pide vendorizar taffy + `[patch.crates-io]`: decisión de
//! dependencia, no de layout, así que los tests siguen `#[ignore]`.
use llimphi_layout::taffy::{self, prelude::*, AvailableSpace};
/// Arma un grid de `maxc.len()` pistas `auto` con ancho DEFINIDO `ancho`, una
/// celda de texto por pista (min-content 60, max-content `maxc[i]`), y
/// devuelve el ancho resuelto de cada pista.
fn pistas(ancho: f32, maxc: &[f32]) -> Vec<f32> {
const MIN_CONTENT: f32 = 60.0;
let mut t: TaffyTree<f32> = TaffyTree::new();
let celdas: Vec<NodeId> = maxc
.iter()
.map(|mx| t.new_leaf_with_context(Style::default(), *mx).unwrap())
.collect();
let g = t
.new_with_children(
Style {
display: Display::Grid,
grid_template_columns: vec![auto(); maxc.len()],
size: Size { width: length(ancho), height: auto() },
..Default::default()
},
&celdas,
)
.unwrap();
t.compute_layout_with_measure(
g,
taffy::Size {
width: AvailableSpace::Definite(ancho),
height: AvailableSpace::MaxContent,
},
|known, avail, _n, ctx, _st| {
let Some(&mut mx) = ctx else { return taffy::Size::ZERO };
// Medidor honesto: nunca reporta más que su max-content ni menos
// que su min-content. (Verificado con instrumentación: taffy pide
// MinContent→60 y MaxContent→`mx`, y aun así asigna de más.)
let w = known.width.unwrap_or(match avail.width {
AvailableSpace::Definite(w) => w.clamp(MIN_CONTENT, mx),
AvailableSpace::MaxContent => mx,
AvailableSpace::MinContent => MIN_CONTENT,
});
taffy::Size { width: w, height: 19.0 }
},
)
.unwrap();
celdas
.iter()
.map(|c| t.layout(*c).unwrap().size.width)
.collect()
}
/// Los casos que taffy SÍ reparte bien — fijan lo que no hay que romper al
/// arreglar el de arriba, y explican por qué el bug se escondió tanto.
#[test]
fn pistas_auto_que_taffy_reparte_bien() {
// Simétricas: las dos topan a la vez, sin sobrante que redistribuir.
let p = pistas(500.0, &[400.0, 400.0]);
assert!((p.iter().sum::<f32>() - 500.0).abs() < 1.0, "simétricas caben: {p:?}");
// Asimétricas donde la angosta topa: el sobrante va entera a la otra.
let p = pistas(500.0, &[100.0, 674.0]);
assert!((p[0] - 100.0).abs() < 1.0, "la pista angosta se congela en su max-content: {p:?}");
assert!((p.iter().sum::<f32>() - 500.0).abs() < 1.0, "y el total cabe: {p:?}");
// El contenedor da de sobra: cada pista llega a su max-content (y el
// remanente estira, que es lo que pide Grid §12.8).
let p = pistas(1200.0, &[400.0, 674.0]);
assert!(p[0] >= 400.0 - 1.0, "con lugar, la pista llega a su max-content: {p:?}");
}
/// EL BUG, hoy ARREGLADO por el fork (ver el encabezado): con taffy 0.12.2 de
/// crates.io esto falla; con el `[patch.crates-io]` del Cargo.toml raíz pasa.
/// Es la regresión que avisa si alguien retira el patch antes de tiempo.
#[test]
fn pistas_auto_no_deben_pasarse_del_contenedor_definido() {
// El caso real de Wikipedia, a escala: contenedor 928, dos columnas cuyos
// max-content suman 1074. Reparto correcto: [400, 528] = 928 exacto.
let p = pistas(928.0, &[400.0, 674.0]);
assert!(
p[0] <= 400.0 + 1.0,
"la pista 0 no puede superar su max-content (400), midió {:.0}",
p[0]
);
assert!(
p.iter().sum::<f32>() <= 928.0 + 1.0,
"las pistas no pueden pasarse del contenedor definido (928), suman {:.0}: {p:?}",
p.iter().sum::<f32>()
);
}
/// Arma un grid de 2 pistas `auto` con ancho DEFINIDO `ancho` y VARIAS filas:
/// `col[i] = (min_content, max_content)` de la celda de la fila `i`. Devuelve
/// el ancho resuelto de las dos pistas.
fn pistas_multifila(ancho: f32, col1: &[(f32, f32)], col2: &[(f32, f32)]) -> (f32, f32) {
let mut t: TaffyTree<(f32, f32)> = TaffyTree::new();
let mut celdas = Vec::new();
for i in 0..col1.len() {
for c in [col1[i], col2[i]] {
celdas.push(t.new_leaf_with_context(Style::default(), c).unwrap());
}
}
let g = t
.new_with_children(
Style {
display: Display::Grid,
grid_template_columns: vec![auto(), auto()],
size: Size { width: length(ancho), height: auto() },
justify_content: Some(JustifyContent::START),
..Default::default()
},
&celdas,
)
.unwrap();
t.compute_layout_with_measure(
g,
taffy::Size { width: AvailableSpace::Definite(ancho), height: AvailableSpace::MaxContent },
|known, avail, _n, ctx, _st| {
let Some(&mut (mn, mx)) = ctx else { return taffy::Size::ZERO };
let w = known.width.unwrap_or(match avail.width {
AvailableSpace::Definite(w) => w.clamp(mn.min(mx), mx),
AvailableSpace::MaxContent => mx,
AvailableSpace::MinContent => mn,
});
taffy::Size { width: w, height: 20.0 }
},
)
.unwrap();
(
t.layout(celdas[0]).unwrap().size.width,
t.layout(celdas[1]).unwrap().size.width,
)
}
/// SEGUNDA CARA del mismo bug upstream, con un disparador más simple: DOS
/// ítems en cada pista alcanzan, sin ninguna celda que abarque columnas y con
/// los max-content de las pistas CABIENDO de sobra en el contenedor.
///
/// Es el residuo del infobox «Rust Foundation» del artículo de Rust en
/// Wikipedia (`salud_web`: 9 celdas a +14px, medido 2026-07-28). Los números
/// son los reales, tomados con `dump_layout --release <url>` + `PURIY_MINC`:
/// la tabla declara 352px, la columna de etiquetas tiene max-content 147
/// («Executive Director») y la de datos 197 («February 8, 2021; 5 years ago»).
/// 147 + 197 = 344 ≤ 352: las dos pistas ENTRAN. taffy igual devuelve 155 y
/// 208 = 363, once píxeles fuera de una tabla que sobraba lugar.
///
/// Lo que delata que es un bug de reparto y no una medida nuestra: el
/// resultado depende de CUÁNTAS filas hay, con los mismos límites de pista.
/// Con 5 filas taffy da [147, 197] —exacto— y con 2, 3 ó 4 da [155, 208].
/// Un algoritmo correcto no puede empeorar al agregarle filas angostas.
///
/// Ninguna función de pista lo esquiva: `auto`, `minmax(auto, max-content)`,
/// `fit-content(100%)` y `minmax(min-content, auto)` dan los cuatro 155+208 —
/// por eso la tabla aplanada sigue con `auto` (`flatten_table`) y el arreglo
/// tenía que ser adentro de taffy.
#[test]
fn dos_items_por_pista_no_deben_pasarse_del_contenedor() {
let col1 = [(87.0_f32, 87.0_f32), (84.0, 147.0)];
let col2 = [(140.0_f32, 197.0_f32), (60.0, 180.0)];
let (a, b) = pistas_multifila(352.0, &col1, &col2);
assert!(
a <= 147.0 + 1.0,
"la pista de etiquetas no puede superar su max-content (147), midió {a:.0}"
);
assert!(
a + b <= 352.0 + 1.0,
"las dos pistas no pueden pasarse del contenedor definido (352), suman {:.0} ({a:.0} + {b:.0})",
a + b
);
}
/// El contra-ejemplo que prueba que los límites de pista NO son el problema:
/// las MISMAS dos columnas, con tres filas angostas de más, se reparten bien.
/// Fija el comportamiento correcto para que el día que taffy arregle lo de
/// arriba se note si en el camino rompió esto.
#[test]
fn con_cinco_filas_las_mismas_pistas_se_reparten_bien() {
let col1 = [(87.0_f32, 87.0_f32), (84.0, 147.0), (72.0, 72.0), (80.0, 80.0), (90.0, 90.0)];
let col2 = [(140.0_f32, 197.0_f32), (60.0, 180.0), (100.0, 190.0), (90.0, 150.0), (80.0, 120.0)];
let (a, b) = pistas_multifila(352.0, &col1, &col2);
assert!(
a + b <= 352.0 + 1.0,
"con cinco filas sí entran: {a:.0} + {b:.0} = {:.0}",
a + b
);
}
+244
View File
@@ -0,0 +1,244 @@
//! Aislamiento del desborde de svelte.dev (salud_web +366px): un ítem de grid
//! con `width: percent(1.0)` ENVENENA el track sizing de taffy 0.9.2 — el
//! porcentaje se resuelve contra el CONTENEDOR (el área del ítem todavía no
//! existe) y el ítem aporta el ancho entero del grid como contribución, así
//! que su track se come todo el espacio y las demás columnas colapsan a 0.
//! En CSS, un porcentaje en pleno sizing intrínseco se comporta como `auto`.
//!
//! El wire lo esquiva en `puriy-render::box_style`: ítem de grid con
//! `width:100%` de autor → `auto` (equivale al stretch default del ítem).
//!
//! Los tests SIN porcentaje demuestran que el resto (placement auto, áreas
//! nombradas, spans, fr tracks, medición min/max-content) anda bien — no
//! tocar esos caminos para "arreglar" esto.
use llimphi_layout::taffy::{self, prelude::*, AvailableSpace};
/// Medidor de "texto": min-content = palabra más ancha, max-content = línea
/// entera; el alto crece al encoger el ancho (aprox).
fn measure_texto(
min_w: f32,
max_w: f32,
known: taffy::Size<Option<f32>>,
available: taffy::Size<AvailableSpace>,
) -> taffy::Size<f32> {
let alto = 19.0;
let w = known.width.unwrap_or(match available.width {
AvailableSpace::Definite(w) => w.clamp(min_w, max_w),
AvailableSpace::MaxContent => max_w,
AvailableSpace::MinContent => min_w,
});
let lineas = (max_w / w.max(1.0)).ceil();
taffy::Size { width: w, height: alto * lineas }
}
fn medir(tree: &mut TaffyTree<(f32, f32)>, raiz: NodeId, ancho: AvailableSpace) {
tree.compute_layout_with_measure(
raiz,
taffy::Size { width: ancho, height: AvailableSpace::MaxContent },
|known, avail, _n, ctx, _st| match ctx {
Some(&mut (mn, mx)) => measure_texto(mn, mx, known, avail),
None => taffy::Size::ZERO,
},
)
.unwrap();
}
fn areas_svelte() -> Vec<taffy::GridTemplateArea<String>> {
// "definition definition" / "blurb screenshots" — líneas 1-based, fin
// EXCLUSIVO (la línea después de la última celda), como el wire.
vec![
taffy::GridTemplateArea { name: "definition".to_string(), row_start: 1, row_end: 2, column_start: 1, column_end: 3 },
taffy::GridTemplateArea { name: "blurb".to_string(), row_start: 2, row_end: 3, column_start: 1, column_end: 2 },
taffy::GridTemplateArea { name: "screenshots".to_string(), row_start: 2, row_end: 3, column_start: 2, column_end: 3 },
]
}
fn estilo_grid() -> Style {
Style {
display: Display::Grid,
grid_template_columns: vec![fr(1.0), fr(1.0)],
gap: taffy::Size { width: length(64.0), height: length(0.0) },
size: taffy::Size { width: length(960.0), height: auto() },
..Default::default()
}
}
#[test]
fn grid_fr_min_content_no_es_max_content() {
// Grid `1fr 1fr` medido bajo MinContent: el ancho es la suma de los
// min-content de los ítems (+gap), no la de los max-content.
let mut tree: TaffyTree<(f32, f32)> = TaffyTree::new();
let a = tree.new_leaf_with_context(Style::default(), (212.0, 380.0)).unwrap();
let b = tree.new_leaf_with_context(Style::default(), (55.0, 635.0)).unwrap();
let grid = tree
.new_with_children(
Style {
display: Display::Grid,
grid_template_columns: vec![fr(1.0), fr(1.0)],
gap: taffy::Size { width: length(64.0), height: length(0.0) },
..Default::default()
},
&[a, b],
)
.unwrap();
medir(&mut tree, grid, AvailableSpace::MinContent);
let w = tree.layout(grid).unwrap().size.width;
// 212 + 55 + 64 = 331.
assert!(w < 400.0, "min-content del grid debe dar ~331, dio {w}");
}
#[test]
fn grid_fr_definite_item_que_abarca_no_desborda() {
// Ítem que abarca ambas columnas (líneas 1..3) con max-content ENORME:
// con available definido, ni el grid ni el ítem exceden el contenedor.
let mut tree: TaffyTree<(f32, f32)> = TaffyTree::new();
let abarca = tree
.new_leaf_with_context(
Style {
grid_column: taffy::geometry::Line { start: line(1), end: line(3) },
..Default::default()
},
(212.0, 1249.0),
)
.unwrap();
let b = tree.new_leaf_with_context(Style::default(), (55.0, 635.0)).unwrap();
let c = tree.new_leaf_with_context(Style::default(), (10.0, 10.0)).unwrap();
let grid = tree.new_with_children(estilo_grid(), &[abarca, b, c]).unwrap();
medir(&mut tree, grid, AvailableSpace::Definite(960.0));
let w_grid = tree.layout(grid).unwrap().size.width;
let w_abarca = tree.layout(abarca).unwrap().size.width;
assert!(w_grid <= 961.0, "grid {w_grid} > 960");
assert!(w_abarca <= w_grid + 1.0, "el ítem que abarca ({w_abarca}) desborda el grid ({w_grid})");
}
#[test]
fn grid_areas_nombradas_sin_percent_posicionan_bien() {
// Placement por NamedLine contra áreas: posiciones y anchos correctos
// mientras ningún ítem tenga width porcentual.
let nombrado = |n: &str| taffy::geometry::Line {
start: GridPlacement::<String>::NamedLine(n.to_string(), 1),
end: GridPlacement::<String>::NamedLine(n.to_string(), 1),
};
let mut tree: TaffyTree<(f32, f32)> = TaffyTree::new();
let def = tree
.new_leaf_with_context(
Style { grid_row: nombrado("definition"), grid_column: nombrado("definition"), ..Default::default() },
(212.0, 1249.0),
)
.unwrap();
let blurb = tree
.new_leaf_with_context(
Style { grid_row: nombrado("blurb"), grid_column: nombrado("blurb"), ..Default::default() },
(55.0, 635.0),
)
.unwrap();
let shots = tree
.new_leaf_with_context(
Style { grid_row: nombrado("screenshots"), grid_column: nombrado("screenshots"), ..Default::default() },
(40.0, 40.0),
)
.unwrap();
let mut estilo = estilo_grid();
estilo.grid_template_areas = areas_svelte();
let grid = tree.new_with_children(estilo, &[def, blurb, shots]).unwrap();
medir(&mut tree, grid, AvailableSpace::Definite(960.0));
let ld = tree.layout(def).unwrap();
let lb = tree.layout(blurb).unwrap();
let ls = tree.layout(shots).unwrap();
assert!(ld.size.width <= 961.0, "definition mide {} > 960", ld.size.width);
assert!(lb.location.x < 1.0, "blurb debe arrancar en col 1, x={}", lb.location.x);
assert!(ls.location.x + ls.size.width <= 961.0, "screenshots se sale: x={} w={}", ls.location.x, ls.size.width);
assert!(lb.location.y > 1.0 && ls.location.y > 1.0, "blurb/screenshots van en la fila 2");
}
/// EL BUG UPSTREAM (taffy 0.9.2): mismo grid pero el primer ítem lleva
/// `width: percent(1.0)` — el % se resuelve contra el CONTENEDOR en pleno
/// track sizing, el track 1 se come los 960 y el 2º ítem cae en x=1024,
/// fuera del grid. Es el desborde de svelte.dev. El wire lo esquiva mapeando
/// `width:100%` de un ítem de grid a `auto` (`box_style`). Si taffy lo
/// arregla aguas arriba, des-ignorar y convertir en regresión.
/// Con taffy 0.12 (bump 2026-07-27) este bug quedó ARREGLADO upstream y el
/// test corre como REGRESIÓN: si vuelve a fallar, el track sizing volvió a
/// resolver el % del ítem contra el contenedor (era el desborde de svelte).
#[test]
fn grid_item_percent_envenena_track_sizing() {
let mut tree: TaffyTree<(f32, f32)> = TaffyTree::new();
let a = tree
.new_leaf_with_context(
Style { size: taffy::Size { width: percent(1.0), height: auto() }, ..Default::default() },
(212.0, 380.0),
)
.unwrap();
let b = tree.new_leaf_with_context(Style::default(), (55.0, 635.0)).unwrap();
let c = tree.new_leaf_with_context(Style::default(), (0.0, 0.0)).unwrap();
let grid = tree.new_with_children(estilo_grid(), &[a, b, c]).unwrap();
medir(&mut tree, grid, AvailableSpace::Definite(960.0));
let lb = tree.layout(b).unwrap();
assert!(
lb.location.x + lb.size.width <= 961.0,
"b se sale del grid: x={} w={} (track 1 se comió el contenedor)",
lb.location.x,
lb.size.width
);
}
/// Flexbox §9.3: un contenedor COLUMN + Wrap con altura INDEFINIDA tiene la
/// main size infinita → los ítems van todos en UNA línea (columna). taffy
/// 0.9.2 envuelve contra la altura resuelta por contenido y manda el 2º ítem
/// a una columna fantasma a la derecha (go.dev: `flex-flow:column wrap` del
/// CSS mobile-first tiraba media sección a x=952, fuera del contenedor). El
/// wire lo esquiva (`box_style`: column+wrap sin altura → NoWrap).
#[test]
#[ignore = "bug UPSTREAM taffy: column+wrap con altura auto envuelve contra la altura resuelta — vivo también en 0.12.2, reportado como DioxusLabs/taffy#999 (repro mínimo ahí: necesita anidamiento + flex-basis:100% + measure fn)"]
fn column_wrap_altura_auto_no_envuelve() {
let mut tree: TaffyTree<(f32, f32)> = TaffyTree::new();
let a = tree
.new_leaf_with_context(
Style { flex_basis: percent(1.0), flex_shrink: 0.0, ..Default::default() },
(200.0, 200.0),
)
.unwrap();
let b = tree.new_leaf_with_context(Style::default(), (200.0, 200.0)).unwrap();
let cont = tree
.new_with_children(
Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
flex_wrap: FlexWrap::Wrap,
size: taffy::Size { width: length(976.0), height: auto() },
..Default::default()
},
&[a, b],
)
.unwrap();
// El contenedor va DENTRO de otra columna (como en la página real): el
// pase de medición resuelve su altura por contenido y el arrange corre
// con esa altura ya conocida — ahí es donde taffy envuelve.
let padre = tree
.new_with_children(
Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
size: taffy::Size { width: length(976.0), height: auto() },
..Default::default()
},
&[cont],
)
.unwrap();
tree.compute_layout_with_measure(
padre,
taffy::Size { width: AvailableSpace::Definite(976.0), height: AvailableSpace::MaxContent },
|known, avail, _n, ctx, _st| match ctx {
Some(&mut (mn, mx)) => measure_texto(mn, mx, known, avail),
None => taffy::Size::ZERO,
},
)
.unwrap();
let lb = tree.layout(b).unwrap();
assert!(
lb.location.x < 1.0,
"el 2º ítem debe APILARSE (columna única), no irse a x={}",
lb.location.x
);
}
@@ -120,8 +120,8 @@ impl App for Demo {
height: length(280.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.radius(16.0)
@@ -151,8 +151,8 @@ impl App for Demo {
width: percent(1.0_f32),
height: length(26.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.text(estado, 17.0, Color::from_rgba8(180, 200, 230, 255));
@@ -162,8 +162,8 @@ impl App for Demo {
width: percent(1.0_f32),
height: length(22.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.text(
@@ -182,8 +182,8 @@ impl App for Demo {
width: length(0.0_f32),
height: length(16.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
padding: Rect {
left: length(24.0_f32),
right: length(24.0_f32),
@@ -138,8 +138,8 @@ impl App for Demo {
height: length(280.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.children(vec![state_machine_view::<Msg>(
@@ -157,8 +157,8 @@ impl App for Demo {
width: percent(1.0_f32),
height: length(28.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.text(estado, 18.0, Color::from_rgba8(180, 200, 230, 255));
@@ -173,8 +173,8 @@ impl App for Demo {
width: length(280.0_f32),
height: length(52.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.fill(if model.moving {
@@ -196,8 +196,8 @@ impl App for Demo {
width: length(0.0_f32),
height: length(20.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
padding: Rect {
left: length(24.0_f32),
right: length(24.0_f32),
+2 -2
View File
@@ -187,8 +187,8 @@ impl App for Demo {
width: percent(1.0_f32),
height: length(30.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.text(
+2 -2
View File
@@ -221,8 +221,8 @@ impl App for Demo {
};
let status = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(30.0_f32) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::CENTER),
justify_content: Some(JustifyContent::CENTER),
..Default::default()
})
.text(label, 14.0, Color::from_rgba8(150, 165, 190, 255));

Some files were not shown because too many files have changed in this diff Show More