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:
Generated
+346
-517
File diff suppressed because it is too large
Load Diff
+96
-7
@@ -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"
|
||||
|
||||
@@ -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).
|
||||

|
||||
|
||||
`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.
|
||||
|
||||
@@ -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. **Sí** 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
@@ -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
|
||||
|
||||
@@ -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.6–5× 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `|p−a| < 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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+842
-233
File diff suppressed because it is too large
Load Diff
+755
-277
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+184
-205
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");
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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};
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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(°enerada).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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Generated
+927
@@ -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",
|
||||
]
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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];");
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 147−87 = 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
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user