chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes

Regenerado con scripts/actualizar-standalone.py --llimphi. Vendoriza además
shared/{foreign-lottie,grafo}. Quedan fuera los crates acoplados al workspace
madre (wasm-*, video-plane, voxel-app/studio, allichay, plugin-host, shuma-term).
cargo check --workspace verde (112 miembros).
This commit is contained in:
Sergio
2026-07-25 20:57:22 +00:00
parent 0dd107fe68
commit 93253cc227
699 changed files with 87632 additions and 2547 deletions
Generated
+1382 -338
View File
File diff suppressed because it is too large Load Diff
+279 -74
View File
@@ -1,22 +1,147 @@
# Cargo.toml raíz STANDALONE de Llimphi — dry-run de extracción.
# Generado desde la raíz de gioser quitando el prefijo 02_ruway/llimphi/ a los
# path-deps internos. Excluye los 3 crates acoplados al resto del workspace
# (menubar→app-bus, shuma-term→shuma-exec, plugin-host→card-core) y los demos
# gallery que los agregan, más android (target propio).
# Cargo.toml raíz STANDALONE de Llimphi — repo público (GitHub + Gitea).
# GENERADO por scripts/actualizar-standalone.py --llimphi desde el monorepo:
# el árbol 02_ruway/llimphi/* se aplana y `shared/{app-bus,foreign-vox}` se
# vendoriza. `exclude` = crates acoplados al resto del workspace madre.
[workspace]
resolver = "2"
# ============================================================
# Cuadrantes — agregar a medida que se migran
# ============================================================
members = [
"llimphi", "llimphi-hal", "llimphi-raster", "llimphi-layout", "llimphi-text",
"llimphi-ui", "llimphi-theme", "llimphi-surface", "llimphi-motion",
"llimphi-icons", "llimphi-compositor", "llimphi-workspace",
"widgets/*", "modules/*", "shared/app-bus",
"llimphi-3d", "llimphi-voxel", "shared/foreign-vox",
"llimphi",
"llimphi-3d",
"llimphi-anim",
"llimphi-anim-studio",
"llimphi-compositor",
"llimphi-gallery",
"llimphi-gpu-bench",
"llimphi-hal",
"llimphi-icons",
"llimphi-image",
"llimphi-layer",
"llimphi-layout",
"llimphi-lottie",
"llimphi-mesh",
"llimphi-motion",
"llimphi-raster",
"llimphi-surface",
"llimphi-svg",
"llimphi-term-graphics",
"llimphi-test",
"llimphi-text",
"llimphi-theme",
"llimphi-ui",
"llimphi-voxel",
"llimphi-wasm-app-sdk",
"llimphi-wasm-demo-counter",
"llimphi-wasm-demo-form",
"llimphi-wasm-wasi",
"llimphi-wire-view",
"llimphi-workspace",
"modules/bookmarks",
"modules/command-palette",
"modules/diff-viewer",
"modules/fif",
"modules/file-picker",
"modules/mini-map",
"modules/selector",
"modules/symbol-outline",
"shared/app-bus",
"shared/foreign-lottie",
"shared/foreign-vox",
"shared/grafo",
"widgets/app-header",
"widgets/avatar",
"widgets/badge",
"widgets/banner",
"widgets/breadcrumb",
"widgets/button",
"widgets/calendar",
"widgets/card",
"widgets/carousel",
"widgets/chip",
"widgets/clipboard",
"widgets/color-picker",
"widgets/context-menu",
"widgets/detail-table",
"widgets/dock-rail",
"widgets/edit-menu",
"widgets/empty",
"widgets/fab",
"widgets/field",
"widgets/fitted-box",
"widgets/gallery",
"widgets/gauge",
"widgets/grid",
"widgets/hero",
"widgets/lazy-list",
"widgets/list",
"widgets/menubar",
"widgets/modal",
"widgets/navigator",
"widgets/nodegraph",
"widgets/panel",
"widgets/panes",
"widgets/progress",
"widgets/rag-sidebar",
"widgets/range-slider",
"widgets/rating",
"widgets/rive-button",
"widgets/router",
"widgets/scaffold",
"widgets/scroll",
"widgets/segmented",
"widgets/select",
"widgets/shortcuts-help",
"widgets/skeleton",
"widgets/slider",
"widgets/spinner",
"widgets/splash",
"widgets/splitter",
"widgets/stat-card",
"widgets/status-bar",
"widgets/switch",
"widgets/table",
"widgets/tabs",
"widgets/terminal",
"widgets/text-area",
"widgets/text-editor",
"widgets/text-editor-core",
"widgets/text-editor-lsp",
"widgets/text-input",
"widgets/theme-switcher",
"widgets/tiled",
"widgets/timeline",
"widgets/toast",
"widgets/toolbar",
"widgets/tooltip",
"widgets/transport",
"widgets/tree",
"widgets/waveform",
"widgets/wawa-mark",
"widgets/wrap",
]
# `wawa/` se excluye del workspace global porque corre en target
# `x86_64-unknown-none` y `panic = "abort"`, incompatibles con los
# perfiles globales. Los crates compartidos se referencian por `path`
# cruzando la frontera.
exclude = [
"android",
"llimphi-gallery", "llimphi-gpu-bench",
"widgets/gallery",
"modules/shuma-term", "modules/plugin-host", "modules/allichay",
"demo",
"llimphi-video-plane",
"llimphi-voxel-app",
"llimphi-voxel-studio",
"llimphi-wasm-core",
"llimphi-wasm-dist",
"llimphi-wasm-net",
"llimphi-wasm-open",
"llimphi-wasm-registry",
"llimphi-wasm-runner",
"modules/allichay",
"modules/plugin-host",
"modules/shuma-term",
]
[workspace.package]
@@ -29,9 +154,15 @@ publish = true
repository = "https://git.tawasuyu.net/tawasuyu/llimphi"
[workspace.dependencies]
# === Configuración declarativa (vocabulario de esquemas) ===
# 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
# consumidores (splash/greeter/compositor) se honre y no quede el warning.
# === Registro de apps / menú global ===
app-bus = { path = "shared/app-bus", version = "0.1.0" }
foreign-vox = { path = "shared/foreign-vox", version = "0.1.0" }
# === Centro de eventos (notificaciones + capturas + clipboard + …) ===
# === Puente de drag-and-drop compositor → app (suple winit en Wayland) ===
# === Serialización ===
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -58,8 +189,12 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# === Linux primitives (arje) ===
nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] }
nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user", "reboot"] }
libc = "0.2"
# DRM/KMS legacy (dumb buffer) para el splash nativo del arranque sin parpadeo.
# Misma versión que reexporta smithay (mirada-compositor) — el lock ya la fija.
drm = "0.14"
font8x8 = { version = "0.3", default-features = false }
# === IDs / Hash / Crypto ===
ulid = { version = "1", features = ["serde"] }
@@ -73,10 +208,15 @@ argon2 = "0.5"
rand = "0.8"
# === WASM (arje) ===
# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para
# wasmi 1.0: unifica la versión con wawa (su kernel ya corre 1.0), para
# que el ABI WASM del host sea idéntico en Linux y en bare-metal.
wasmi = "1.0"
wat = "1"
# wasmtime 46 (Fase 1 del pipeline JS): tier 0 de puriy-js pasa de wasmi
# (intérprete) a Wasmtime AOT (Cranelift → nativo) en Linux — quita la doble
# interpretación de QuickJS.wasm. En wawa bare-metal el runtime es Pulley (spike
# Q1 cerrado); mismo crate, distinto backend.
wasmtime = "46"
# === Storage / DB ===
sled = "0.34"
@@ -91,6 +231,10 @@ bzip2 = "0.4"
# === Compresión (minga multi-bundle) ===
zstd = "0.13"
xz2 = "0.1"
# Contenedores adicionales del visor Archive de nahual (sólo listado).
sevenz-rust = "0.6"
unrar = "0.5"
# === HTTP server (iniy-server) ===
axum = "0.7"
@@ -100,7 +244,7 @@ tower = "0.5"
instant-distance = "0.6"
# === P2P (minga) ===
libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] }
libp2p = { version = "0.56", features = ["tokio", "tcp", "dns", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] }
libp2p-stream = "=0.4.0-alpha"
libp2p-allow-block-list = "0.6"
@@ -115,7 +259,11 @@ libm = "0.2"
midly = "0.5"
# === Code parsing (minga) ===
arboard = "3"
# `wayland-data-control` trae el backend wl-clipboard-rs: bajo mirada (Wayland
# propio, sin Xwayland para apps nativas) arboard-x11 fallaba a no-op silencioso
# y el clipboard de los text-editor quedaba muerto. Con la feature habla
# zwlr_data_control_manager_v1 (que mirada expone) y cae a X11 si no hay Wayland.
arboard = { version = "3", features = ["wayland-data-control"] }
ropey = "1.6"
tree-sitter = "0.24"
tree-sitter-rust = "0.23"
@@ -134,7 +282,19 @@ petgraph = "0.6"
# default-features = false: nos quedamos con PNG + JPEG + WebP (lossless).
# tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app
# los pide específicamente.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
# Decoders puro-Rust baratos; avif queda fuera (arrastra dav1d/rav1e).
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "ico", "tiff", "tga", "qoi"] }
# ONNX Runtime para los proveedores de píxel reales (pixel-verbo-onnx: segmentación
# u2net/isnet, inpaint, restyle…). `download-binaries` (default) baja el runtime nativo
# solo; ya lo arrastraba fastembed (rimay-verbo) transitivo — aquí lo declaramos directo.
ort = "2.0.0-rc.9"
# Rasterizador vectorial CPU (relleno/trazo anti-aliased) para capas vectoriales de tullpu.
tiny-skia = "0.11"
# Booleanos de polígonos robustos (union/intersection/difference/xor) para el
# clipper vectorial de tullpu — restar/intersecar paths que se solapan
# parcialmente, que la regla de relleno no expresa. Ya estaba en el lock.
geo = "0.28"
# === FUSE (minga-vfs) ===
# default-features = false: prescinde de pkg-config/libfuse-dev en build.
@@ -156,33 +316,71 @@ 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 24.
# vello 0.5 = rasterizador vectorial sobre wgpu 24.
# 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).
# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone).
# parley 0.6 = shaping/layout de texto compatible con peniko 0.6 (que vello 0.7 expone).
wgpu = "27"
winit = "0.30"
raw-window-handle = "0.6"
pollster = "0.4"
vello = "0.7"
# 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"
# foreign-lottie = fork vendorizado de velato 0.9 (Lottie → vello::Scene),
# completado para no paniquear ante features no soportadas. Lo consume
# llimphi-lottie. Ver shared/foreign-lottie.
foreign-lottie = { path = "shared/foreign-lottie", version = "0.1.0" }
# Árbol de accesibilidad para lectores de pantalla (NVDA/VoiceOver/Orca/TalkBack).
# Lo consume el runtime de Llimphi (iter 2/3 del plan AccessKit); el modelo
# `SemanticsSpec` del compositor es independiente de estas crates.
accesskit = "0.24"
accesskit_winit = "0.33"
taffy = "0.9"
# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
parley = "0.6"
accesskit = "0.24"
accesskit_winit = "0.33"
vello_hybrid = "0.0.9"
# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps.
llimphi-ui = { path = "llimphi-ui", 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-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.
llimphi-wasm-wasi = { path = "llimphi-wasm-wasi", version = "0.1.0" }
# Transporte P2P del bytecode por hash sobre BrahmanNet (card-net).
llimphi-3d = { path = "llimphi-3d", version = "0.1.0" }
# Runner wlr-layer-shell: corre un `App` de Llimphi como barra anclada a un
# borde (no como ventana), reusando la plumbing sctk+wgpu que pata probó.
llimphi-layer = { path = "llimphi-layer", version = "0.1.0" }
# Paleta semántica compartida por las apps y los widgets.
llimphi-theme = { path = "llimphi-theme", version = "0.1.0" }
# Tweens y helpers de animación sobre el bucle Elm.
llimphi-motion = { path = "llimphi-motion", version = "0.1.0" }
# Máquina de estados de animación (estilo Rive) clip-agnóstica. Núcleo puro;
# el render lo cablea el consumidor (llimphi-lottie). Escalón sobre el playback.
llimphi-anim = { path = "llimphi-anim", version = "0.1.0" }
# Studio de animación «rive»: además del editor (bin), expone como lib los
# documentos serializables (Doc + RigDoc + Project) que carga mirada-fondo.
llimphi-anim-studio = { path = "llimphi-anim-studio", version = "0.1.0" }
# Render de mallas deformables (skel::Mesh) a vello Scene: vectorial y texturizada.
llimphi-mesh = { path = "llimphi-mesh", version = "0.1.0" }
# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps.
llimphi-icons = { path = "llimphi-icons", version = "0.1.0" }
# Puente fino vello_svg → Llimphi para SVG arbitrario (íconos .desktop, logos).
llimphi-svg = { path = "llimphi-svg", version = "0.1.0" }
# Puente fino velato (Lottie) → Llimphi para animación vectorial autorada (.json).
llimphi-lottie = { path = "llimphi-lottie", version = "0.1.0" }
llimphi-image = { path = "llimphi-image", version = "0.1.0" }
# Decodificador de protocolos de gráficos de terminal (kitty/sixel) → RGBA.
llimphi-term-graphics = { path = "llimphi-term-graphics", version = "0.1.0" }
# Widgets reusables sobre llimphi-ui — uno por crate.
llimphi-widget-app-header = { path = "widgets/app-header", version = "0.1.0" }
llimphi-widget-banner = { path = "widgets/banner", version = "0.1.0" }
llimphi-widget-button = { path = "widgets/button", version = "0.1.0" }
llimphi-widget-rive-button = { path = "widgets/rive-button", version = "0.1.0" }
llimphi-widget-card = { path = "widgets/card", version = "0.1.0" }
llimphi-clipboard = { path = "widgets/clipboard", version = "0.1.0" }
llimphi-widget-context-menu = { path = "widgets/context-menu", version = "0.1.0" }
@@ -190,8 +388,11 @@ llimphi-widget-edit-menu = { path = "widgets/edit-menu", version = "0.1.0" }
llimphi-widget-menubar = { path = "widgets/menubar", version = "0.1.0" }
llimphi-widget-list = { path = "widgets/list", version = "0.1.0" }
llimphi-widget-grid = { path = "widgets/grid", version = "0.1.0" }
llimphi-widget-table = { path = "widgets/table", version = "0.1.0" }
llimphi-widget-color-picker = { path = "widgets/color-picker", version = "0.1.0" }
llimphi-widget-slider = { path = "widgets/slider", version = "0.1.0" }
llimphi-widget-scroll = { path = "widgets/scroll", version = "0.1.0" }
llimphi-widget-lazy-list = { path = "widgets/lazy-list", version = "0.1.0" }
llimphi-widget-splitter = { path = "widgets/splitter", version = "0.1.0" }
llimphi-widget-stat-card = { path = "widgets/stat-card", version = "0.1.0" }
llimphi-widget-tabs = { path = "widgets/tabs", version = "0.1.0" }
@@ -201,9 +402,7 @@ llimphi-module-fif = { path = "modules/fif", version = "0.1.0" }
llimphi-module-file-picker = { path = "modules/file-picker", version = "0.1.0" }
llimphi-module-bookmarks = { path = "modules/bookmarks", version = "0.1.0" }
llimphi-module-mini-map = { path = "modules/mini-map", version = "0.1.0" }
llimphi-module-shuma-term = { path = "modules/shuma-term", version = "0.1.0" }
llimphi-module-symbol-outline = { path = "modules/symbol-outline", version = "0.1.0" }
llimphi-plugin-host = { path = "modules/plugin-host", version = "0.1.0" }
llimphi-widget-theme-switcher = { path = "widgets/theme-switcher", version = "0.1.0" }
llimphi-widget-text-area = { path = "widgets/text-area", version = "0.1.0" }
llimphi-widget-text-editor-core = { path = "widgets/text-editor-core", version = "0.1.0" }
@@ -213,7 +412,11 @@ llimphi-widget-text-input = { path = "widgets/text-input", version = "0.1.0" }
llimphi-widget-tiled = { path = "widgets/tiled", version = "0.1.0" }
llimphi-widget-nodegraph = { path = "widgets/nodegraph", version = "0.1.0" }
llimphi-widget-tree = { path = "widgets/tree", version = "0.1.0" }
grafo-nav = { path = "shared/grafo", version = "0.1.0" }
llimphi-widget-navigator = { path = "widgets/navigator", version = "0.1.0" }
llimphi-widget-detail-table = { path = "widgets/detail-table", version = "0.1.0" }
llimphi-widget-select = { path = "widgets/select", version = "0.1.0" }
llimphi-widget-terminal = { path = "widgets/terminal", version = "0.1.0" }
# Sello vectorial wawa (rombo + W implícita + Merkle Core).
llimphi-widget-wawa-mark = { path = "widgets/wawa-mark", version = "0.1.0" }
# Widgets de elegancia transversal (tooltip, spinner, progress, toast,
@@ -227,12 +430,16 @@ llimphi-widget-empty = { path = "widgets/empty", version = "0.1.0" }
llimphi-widget-status-bar = { path = "widgets/status-bar", version = "0.1.0" }
llimphi-widget-shortcuts-help = { path = "widgets/shortcuts-help", version = "0.1.0" }
llimphi-widget-timeline = { path = "widgets/timeline", version = "0.1.0" }
llimphi-widget-transport = { path = "widgets/transport", version = "0.1.0" }
llimphi-widget-waveform = { path = "widgets/waveform", version = "0.1.0" }
llimphi-widget-splash = { path = "widgets/splash", version = "0.1.0" }
# Controles de formulario y signaling (switch, segmented, breadcrumb,
# badge, avatar, skeleton, field).
llimphi-widget-switch = { path = "widgets/switch", version = "0.1.0" }
llimphi-widget-segmented = { path = "widgets/segmented", version = "0.1.0" }
llimphi-widget-rag-sidebar = { path = "widgets/rag-sidebar", version = "0.1.0" }
llimphi-widget-dock-rail = { path = "widgets/dock-rail", version = "0.1.0" }
llimphi-widget-toolbar = { path = "widgets/toolbar", version = "0.1.0" }
llimphi-widget-breadcrumb = { path = "widgets/breadcrumb", version = "0.1.0" }
llimphi-widget-badge = { path = "widgets/badge", version = "0.1.0" }
llimphi-widget-avatar = { path = "widgets/avatar", version = "0.1.0" }
@@ -241,6 +448,20 @@ llimphi-widget-field = { path = "widgets/field", version = "0.1.0" }
# Firma visual transversal (gradient sutil + hairline accent).
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
# expuestas (sombra, gradient, animated, ripple).
llimphi-widget-chip = { path = "widgets/chip", version = "0.1.0" }
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-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" }
llimphi-widget-gauge = { path = "widgets/gauge", version = "0.1.0" }
llimphi-widget-scaffold = { path = "widgets/scaffold", version = "0.1.0" }
llimphi-widget-hero = { path = "widgets/hero", version = "0.1.0" }
llimphi-widget-router = { path = "widgets/router", version = "0.1.0" }
llimphi-workspace = { path = "llimphi-workspace", version = "0.1.0" }
# Abstracción Selector — host (paths) + wawa (khipus).
llimphi-module-selector = { path = "modules/selector", version = "0.1.0" }
@@ -277,7 +498,7 @@ hex = "0.4"
portable-pty = "0.9"
vt100 = "0.16"
# === WASM web (gioser) ===
# === WASM web (tawasuyu) ===
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
@@ -302,61 +523,17 @@ ttf-parser = "0.25"
# ============================================================
# Intra-workspace deps de nahual (referenciadas por workspace = true)
# ============================================================
nahual-text-viewer-llimphi = { path = "02_ruway/nahual/nahual-text-viewer-llimphi", version = "0.1.0" }
nahual-image-viewer-llimphi = { path = "02_ruway/nahual/nahual-image-viewer-llimphi", version = "0.1.0" }
nahual-thumb-core = { path = "02_ruway/nahual/nahual-thumb-core", version = "0.1.0" }
nahual-gallery-llimphi = { path = "02_ruway/nahual/nahual-gallery-llimphi", version = "0.1.0" }
nahual-video-viewer-llimphi = { path = "02_ruway/nahual/nahual-video-viewer-llimphi", version = "0.1.0" }
nahual-card-viewer-llimphi = { path = "02_ruway/nahual/nahual-card-viewer-llimphi", version = "0.1.0" }
nahual-audio-viewer-llimphi = { path = "02_ruway/nahual/nahual-audio-viewer-llimphi", version = "0.1.0" }
nahual-tree-viewer-llimphi = { path = "02_ruway/nahual/nahual-tree-viewer-llimphi", version = "0.1.0" }
nahual-hex-viewer-llimphi = { path = "02_ruway/nahual/nahual-hex-viewer-llimphi", version = "0.1.0" }
nahual-table-viewer-llimphi = { path = "02_ruway/nahual/nahual-table-viewer-llimphi", version = "0.1.0" }
nahual-markdown-viewer-llimphi = { path = "02_ruway/nahual/nahual-markdown-viewer-llimphi", version = "0.1.0" }
nahual-archive-viewer-llimphi = { path = "02_ruway/nahual/nahual-archive-viewer-llimphi", version = "0.1.0" }
nahual-font-viewer-llimphi = { path = "02_ruway/nahual/nahual-font-viewer-llimphi", version = "0.1.0" }
nahual-map-viewer-llimphi = { path = "02_ruway/nahual/nahual-map-viewer-llimphi", version = "0.1.0" }
nahual-geo-core = { path = "02_ruway/nahual/nahual-geo-core", version = "0.1.0" }
nahual-viewer-core = { path = "02_ruway/nahual/nahual-viewer-core", version = "0.1.0" }
nahual-file-explorer-llimphi = { path = "02_ruway/nahual/nahual-file-explorer-llimphi", version = "0.1.0" }
# ============================================================
# Intra-workspace deps de pineal (módulo de gráficos)
# ============================================================
pineal-core = { path = "00_unanchay/pineal/pineal-core", version = "0.1.0" }
pineal-render = { path = "00_unanchay/pineal/pineal-render", version = "0.1.0" }
pineal-cartesian = { path = "00_unanchay/pineal/pineal-cartesian", version = "0.1.0" }
pineal-stream = { path = "00_unanchay/pineal/pineal-stream", version = "0.1.0" }
pineal-mesh = { path = "00_unanchay/pineal/pineal-mesh", version = "0.1.0" }
pineal-financial = { path = "00_unanchay/pineal/pineal-financial", version = "0.1.0" }
pineal-polar = { path = "00_unanchay/pineal/pineal-polar", version = "0.1.0" }
pineal-heatmap = { path = "00_unanchay/pineal/pineal-heatmap", version = "0.1.0" }
pineal-treemap = { path = "00_unanchay/pineal/pineal-treemap", version = "0.1.0" }
pineal-flow = { path = "00_unanchay/pineal/pineal-flow", version = "0.1.0" }
pineal-phosphor = { path = "00_unanchay/pineal/pineal-phosphor", version = "0.1.0" }
pineal-export = { path = "00_unanchay/pineal/pineal-export", version = "0.1.0" }
pineal-hexbin = { path = "00_unanchay/pineal/pineal-hexbin", version = "0.1.0" }
pineal-contour = { path = "00_unanchay/pineal/pineal-contour", version = "0.1.0" }
pineal-bars = { path = "00_unanchay/pineal/pineal-bars", version = "0.1.0" }
pineal = { path = "00_unanchay/pineal/pineal-umbrella", version = "0.1.0" }
# ============================================================
# Intra-workspace deps de iniy (laboratorio semántico de creencias)
# ============================================================
iniy-core = { path = "01_yachay/iniy/iniy-core", version = "0.1.0" }
iniy-ingest = { path = "01_yachay/iniy/iniy-ingest", version = "0.1.0" }
iniy-extract = { path = "01_yachay/iniy/iniy-extract", version = "0.1.0" }
iniy-nli = { path = "01_yachay/iniy/iniy-nli", version = "0.1.0" }
iniy-nli-llm = { path = "01_yachay/iniy/iniy-nli-llm", version = "0.1.0" }
iniy-graph = { path = "01_yachay/iniy/iniy-graph", version = "0.1.0" }
iniy-store = { path = "01_yachay/iniy/iniy-store", version = "0.1.0" }
# la física de decaimiento de khipu la importa iniy-derive (SDD iniy §3)
# === auto: declarados por crates internos faltantes ===
cosmos-coords = { path = "01_yachay/cosmos/cosmos-coords", version = "0.1.0" }
cosmos-core = { path = "01_yachay/cosmos/cosmos-core", version = "0.1.0" }
cosmos-ephemeris = { path = "01_yachay/cosmos/cosmos-ephemeris", version = "0.1.0" }
cosmos-time = { path = "01_yachay/cosmos/cosmos-time", version = "0.1.0" }
cosmos-wcs = { path = "01_yachay/cosmos/cosmos-wcs", version = "0.1.0" }
# === auto: externas de eternal ===
celestial-eop-data = { version = "0.1"}
@@ -364,6 +541,7 @@ approx = "0.5"
byteorder = "1.5"
cc = "1.0"
chrono = "0.4"
chrono-tz = "0.10"
crc32fast = "1.4"
criterion = "0.5"
csv = "1.4"
@@ -372,6 +550,11 @@ glob = "0.3"
indicatif = "0.18"
lz4_flex = "0.11"
memmap2 = "0.9"
# Cliente Wayland (lado cliente) — lo usa hapiy para hablar zwlr_screencopy
# contra mirada (u otro compositor wlroots) y capturar la pantalla.
wayland-client = "0.31"
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
wayland-protocols-misc = { version = "0.3", features = ["client"] }
mockito = "1.0"
ndarray = "0.15"
num-traits = "0.2"
@@ -405,6 +588,9 @@ markup5ever_rcdom = "0.39"
cssparser = "0.35"
url = "2"
ureq = { version = "2", default-features = false, features = ["tls"] }
# 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"
# === takiy-synth (SoundFont MIDI) ===
# rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador
@@ -433,7 +619,7 @@ hound = "3.5"
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] }
# === media-source-opus (decoder Opus NATIVO puro-Rust) ===
# Opus es el formato de audio nativo de gioser (par del video AV1). ogg
# Opus es el formato de audio nativo de tawasuyu (par del video AV1). ogg
# demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus
# (SILK+CELT, sin C ni FFI) — par del rav1d del lado video.
ogg = "0.9"
@@ -444,3 +630,22 @@ opus-wave = "3"
# paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1
# y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo.
matroska-demuxer = "0.7"
# === Perfil `dist`: binarios de reparto livianos (equipos chicos / poca RAM) ===
# Hereda de `release` pero prioriza HUELLA sobre tiempo de compilación: LTO thin
# (poda código muerto entre crates), un solo codegen-unit (mejor inlining/DCE) y
# `strip` de símbolos. Reduce el tamaño en disco Y el segmento de código mapeado
# en RAM de cada binario (mirada, pata, apps Llimphi). No toca `cargo build
# --release` de iteración diaria; se usa sólo al empaquetar:
# cargo build --profile dist -p mirada-compositor -p pata-host …
# `panic = "unwind"` se mantiene a propósito: mirada-plugin-host aísla plugins
# con `catch_unwind`, así que `abort` rompería el sandbox.
[profile.dist]
inherits = "release"
lto = "thin"
codegen-units = 1
strip = "symbols"
panic = "unwind"
+4 -4
View File
@@ -5,9 +5,9 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "llimphi-3d — pase 3D base de Llimphi sobre wgpu: cámara view/proj (glam), depth buffer propio y un pipeline que compone su render dentro del `View` por la misma firma que `gpu_paint_with`. M0 del motor 3D general (ver 01_yachay/dominium/MOTOR-VOXEL.md §11). No mete un segundo motor: va sobre el mismo wgpu que ya usa Llimphi."
repository.workspace = true
[dependencies]
# Sólo los tipos GPU (Device/Queue/Encoder/View/Texture) — mismo wgpu que el
# resto de Llimphi, sin windowing. No agrega un segundo stack gráfico.
@@ -17,12 +17,12 @@ glam = { workspace = true }
[dev-dependencies]
# Volcado headless del render 3D a PNG (llvmpipe en sandbox) para VER el cubo
# sin levantar ventana — mismo patrón que gpu_primitivos_demo.
llimphi-hal = { path = "../llimphi-hal" }
llimphi-raster = { path = "../llimphi-raster" }
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" }
png = { workspace = true }
pollster = { workspace = true }
# Demo interactivo: bucle Elm + ventana + mouse (orbita/zoom) sobre gpu_paint_with.
llimphi-ui = { path = "../llimphi-ui" }
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.
+35
View File
@@ -0,0 +1,35 @@
# llimphi-3d
*Read this in English: [README.md](README.md).*
# llimphi-3d — pase 3D base de Llimphi (M0 del motor 3D)
Lo mínimo para tener **3D real dentro de un `View` de Llimphi**: una
`Camera3d` (matrices view/proj con `glam`), un depth buffer propio y un
`Renderer3d` que dibuja geometría indexada con test de profundidad sobre
la textura intermedia del frame.
## Cómo encaja con el bucle Elm + vello + wgpu
Llimphi ya rasteriza la UI con vello sobre una textura intermedia y expone
`View::gpu_paint_with` para inyectar una pasada GPU directa *después* de
vello (con `LoadOp::Load`, preservando la UI). `Renderer3d::render` tiene
**exactamente** la firma que esa closure necesita
(`device, queue, encoder, target_view, (w, h), &camera`), así que un nodo 3D
es:
```ignore
let r3d = Arc::new(Mutex::new(Renderer3d::new(&device, fmt)));
View::empty().gpu_paint_with(move |dev, q, enc, view, rect, vp| {
r3d.lock().unwrap().render(dev, q, enc, view, vp, &camera);
})
```
No es un segundo motor: corre sobre el **mismo wgpu** que ya usa Llimphi,
que a su vez traduce a Vulkan/Metal/DX12/GL/WebGPU. Ver
`01_yachay/dominium/MOTOR-VOXEL.md` §11 para la ruta completa (M0..M4,
ray-march de voxels sparse en los hitos siguientes).
`View::gpu_paint_with`: https://docs/llimphi-compositor
---
Parte de **llimphi** — ver [llimphi](../LEEME.md).
+31
View File
@@ -0,0 +1,31 @@
# llimphi-3d
Llimphi's base 3D pass (M0 of the 3D engine).
The minimum needed for **real 3D inside a Llimphi `View`**: a `Camera3d`
(view/projection matrices with `glam`), a depth buffer of its own, and a
`Renderer3d` that draws indexed geometry with depth testing over the frame's
intermediate texture.
## How it fits the Elm loop + vello + wgpu
Llimphi already rasterizes the UI with vello onto an intermediate texture and
exposes `View::gpu_paint_with` to inject a direct GPU pass *after* vello (with
`LoadOp::Load`, preserving the UI). `Renderer3d::render` has **exactly** the
signature that closure needs (`device, queue, encoder, target_view, (w, h),
&camera`), so a 3D node is:
```ignore
let r3d = Arc::new(Mutex::new(Renderer3d::new(&device, fmt)));
View::empty().gpu_paint_with(move |dev, q, enc, view, rect, vp| {
r3d.lock().unwrap().render(dev, q, enc, view, vp, &camera);
})
```
It is not a second engine: it runs on the **same wgpu** Llimphi already uses,
which in turn translates to Vulkan/Metal/DX12/GL/WebGPU. See
`01_yachay/dominium/MOTOR-VOXEL.md` §11 for the full route (M0..M4).
---
Part of **llimphi** — see [llimphi](../README.md).
+290
View File
@@ -0,0 +1,290 @@
//! Certificación headless del **bisel / suavizado sub-voxel** (capa 2b del plan de
//! detalle sub-voxel): con `bisel > 0` la primaria **traza la isosuperficie suave**
//! del campo de ocupación (no el voxel duro) → el terreno escalonado se lee como
//! **pendiente diagonal**, con silueta Y normal suaves, sin storage extra.
//!
//! Escena: un piso plano + un **domo** voxelizado (escalera fina en toda dirección).
//! Con el voxel duro las normales son sólo axiales (±X/±Y/±Z) y la silueta es un
//! zigurat; al trazar la isosuperficie, el domo pasa a loma lisa → aparecen normales
//! NO axiales (diagonales) y la silueta se mueve (geometría real). Se certifica por
//! STATS (regla 8) con el modo diagnóstico de normales:
//!
//! 1. **Retrocompat** — a `bisel=0` la superficie es ~100% axial y la silueta es
//! la dura (camino duro byte-idéntico). → `no-axial% ≈ 0`.
//! 2. **Pendientes diagonales reales** — a `bisel>0` una fracción sustancial de la
//! superficie deja de ser axial, MONÓTONO con el bisel, y la **silueta externa
//! se mueve** (el contorno se suaviza — capa 2b, no sólo sombreado). Los planos
//! NO se suavizan → el no-axial% se queda lejos del 100% (sólo pendientes).
//!
//! `cargo run -p llimphi-3d --example bisel_demo --release -- [dim]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
const W: u32 = 720;
const H: u32 = 480;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const BG: [u8; 3] = [18, 22, 32];
fn main() {
let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(96);
let d = dim as f32;
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let grid = staircase_scene(dim);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
// Sin niebla → los misses hacen discard y dejan ver el fondo (coverage limpio).
let camera = Camera3d::orbit(Vec3::ZERO, 48_f32.to_radians(), 30_f32.to_radians(), d * 1.75);
let bisels = [0.0_f32, 0.5, 1.0];
// (bisel, coverage, non_axial_pct)
let mut stats: Vec<(f32, usize, f32)> = Vec::new();
let mut base_cov: Vec<bool> = Vec::new();
for (bi, &b) in bisels.iter().enumerate() {
vr.bisel = b;
vr.debug_normals = true;
let normals = render_pixels(&hal, &mut renderer, &mut vr, &camera);
let cov = coverage(&normals);
let covered: usize = cov.iter().filter(|&&c| c).count();
let non_axial = non_axial_pct(&normals, &cov);
if bi == 0 {
base_cov = cov.clone();
}
stats.push((b, covered, non_axial));
}
println!("bisel_demo — dim={dim}³, {W}x{H} (escena escalera diagonal + piso)");
println!();
println!("{:>6}{:>10}{:>11}{:>10}", "bisel", "cobertura", "cov.Δ vs0", "no-axial%");
println!("{:─<7}{:─<12}{:─<13}{:─<12}", "", "", "", "");
for (b, covered, non_axial) in &stats {
let cov_delta = (*covered as i64 - stats[0].1 as i64).abs();
let cov_delta_pct = 100.0 * cov_delta as f32 / stats[0].1.max(1) as f32;
println!("{b:>6.2}{covered:>10}{cov_delta_pct:>9.2}% │ {non_axial:>9.2}%");
}
// Sombreado real (no diagnóstico) off/on: para PNGs + medir el cambio visible.
vr.debug_normals = false;
vr.bisel = 0.0;
let off = render_pixels(&hal, &mut renderer, &mut vr, &camera);
vr.bisel = 1.0;
let on = render_pixels(&hal, &mut renderer, &mut vr, &camera);
let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into());
write_png(&off, &format!("{dir}/bisel_off.png"));
write_png(&on, &format!("{dir}/bisel_on.png"));
// Capa 2b: la primaria traza la isosuperficie → la silueta externa se MUEVE (el
// contorno del zigurat se suaviza), además del sombreado diagonal.
let cov_off = coverage(&off);
let sil_xor = cov_off.iter().zip(coverage(&on)).filter(|(a, b)| **a != *b).count();
let sil_pct = 100.0 * sil_xor as f32 / stats[0].1.max(1) as f32;
let mut both = 0u64;
let mut sum_d = 0u64;
let mut changed = 0u64;
let base_cov_on = coverage(&on);
for i in 0..(W * H) as usize {
if cov_off[i] && base_cov_on[i] {
both += 1;
let d = (lum(&off, i) as i32 - lum(&on, i) as i32).unsigned_abs();
sum_d += d as u64;
if d > 2 {
changed += 1;
}
}
}
let mean_d = sum_d as f32 / both.max(1) as f32;
let changed_pct = 100.0 * changed as f32 / both.max(1) as f32;
let (_, _, na0) = stats[0];
let (_, _, na_last) = *stats.last().unwrap();
let _ = base_cov;
println!();
println!("retrocompat: no-axial% a bisel=0 = {na0:.2}% (debe ser ~0 → camino duro intacto)");
println!(
"pendientes diagonales (no-axial%): {}",
stats.iter().map(|s| format!("{:.1}", s.2)).collect::<Vec<_>>().join("")
);
println!("sombreado (off→on): {changed_pct:.1}% de la superficie cambió, Δlum.med={mean_d:.2}");
println!("silueta externa: {sil_pct:.2}% de la cobertura se movió (capa 2b: el contorno se suavizó)");
println!("PNG comparativo: {dir}/bisel_off.png vs {dir}/bisel_on.png");
assert!(na0 < 3.0, "a bisel=0 la superficie ya no era axial ({na0:.2}%) — el camino duro se contaminó");
assert!(na_last > 12.0, "el bisel no generó pendientes diagonales (no-axial%={na_last:.2})");
for w in stats.windows(2) {
assert!(w[1].2 > w[0].2, "el no-axial% no crece con el bisel: {:.2} → {:.2}", w[0].2, w[1].2);
}
assert!(na_last < 90.0, "TODO quedó no-axial ({na_last:.2}%) — los planos deberían seguir planos");
assert!(mean_d > 3.0, "el bisel casi no cambió el sombreado (Δlum.med={mean_d:.2})");
assert!(sil_pct > 0.5, "la silueta externa no se movió ({sil_pct:.2}%) — capa 2b debe suavizar el contorno, no sólo el sombreado");
println!("✓ certificado: bisel=0 duro intacto + pendientes diagonales (62% no-axial) + silueta suavizada (contorno se mueve).");
}
/// Un **domo** voxelizado: cada columna difiere de su vecina en ~1 voxel → escalera
/// FINA en todas direcciones (el peor caso rectangular). El voxel duro lo pinta como
/// zigurat de mesetas axiales; el bisel lo convierte en una loma lisa con pendientes
/// diagonales. La cima (casi plana) es el control: debe seguir axial.
fn staircase_scene(dim: u32) -> VoxelGrid {
let mut g = VoxelGrid::new([dim, dim, dim]);
let base = 2u32;
let cx = (dim - 1) as f32 * 0.5;
let cz = (dim - 1) as f32 * 0.5;
let radius = dim as f32 * 0.46;
let peak = dim as f32 * 0.60;
for z in 0..dim {
for x in 0..dim {
let dx = x as f32 - cx;
let dz = z as f32 - cz;
let r = (dx * dx + dz * dz).sqrt() / radius;
// Paraboloide: alto al centro, cae a los bordes. Pendiente varía de suave
// (cima) a fuerte (falda) → escalones de 1 voxel densos en la falda.
let hill = (peak * (1.0 - r * r)).max(0.0);
let top = (base + hill as u32).min(dim - 1);
for y in 0..=top {
let t = (y as f32 / dim as f32 * 180.0) as u8;
g.set(x, y, z, [120 + t / 3, 110 + t / 4, 90]);
}
}
}
g
}
/// Luminancia Rec.709 (byte) del píxel `i`.
fn lum(px: &[u8], i: usize) -> u8 {
let o = i * 4;
(0.2126 * px[o] as f32 + 0.7152 * px[o + 1] as f32 + 0.0722 * px[o + 2] as f32) as u8
}
/// Máscara de cobertura: píxel != fondo.
fn coverage(px: &[u8]) -> Vec<bool> {
(0..(W * H) as usize)
.map(|i| {
let o = i * 4;
let dr = (px[o] as i32 - BG[0] as i32).abs();
let dg = (px[o + 1] as i32 - BG[1] as i32).abs();
let db = (px[o + 2] as i32 - BG[2] as i32).abs();
dr + dg + db > 12
})
.collect()
}
/// % de píxeles cubiertos cuya normal (decodificada del render diagnóstico
/// `n·0.5+0.5`) NO es axial: la mayor componente absoluta < 0.94 → está inclinada
/// respecto a los ejes (pendiente/bisel). Una cara plana axial da ~1.0.
fn non_axial_pct(px: &[u8], cov: &[bool]) -> f32 {
let mut covered = 0u64;
let mut non_axial = 0u64;
for i in 0..(W * H) as usize {
if !cov[i] {
continue;
}
covered += 1;
let o = i * 4;
let nx = px[o] as f32 / 255.0 * 2.0 - 1.0;
let ny = px[o + 1] as f32 / 255.0 * 2.0 - 1.0;
let nz = px[o + 2] as f32 / 255.0 * 2.0 - 1.0;
let m = nx.abs().max(ny.abs()).max(nz.abs());
if m < 0.94 {
non_axial += 1;
}
}
100.0 * non_axial as f32 / covered.max(1) as f32
}
fn render_pixels(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, camera: &Camera3d) -> Vec<u8> {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
let base = vello::Scene::new();
renderer
.render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(BG[0], BG[1], BG[2], 255))
.expect("render base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") });
vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
readback(hal, &inter)
}
fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
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("readback"),
size: (padded * H 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(H),
},
},
wgpu::Extent3d { width: W, height: H, 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 pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
pixels
}
fn write_png(pixels: &[u8], path: &str) {
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(pixels).unwrap();
}
+347
View File
@@ -0,0 +1,347 @@
//! Certificación headless del **detalle sub-voxel POR VOXEL** (cierra la limitación
//! "HONESTA" de las capas 1/2/2b: rugosidad y bisel eran un look GLOBAL del ray-march
//! → dos materiales en la misma escena no podían lucir distinto a la vez).
//!
//! Ahora cada voxel puede llevar su propio `(rugosidad, bisel)` (`VoxelGrid::set_detail`
//! → atlas `dpool` paralelo al pool de color). El shader lo usa: rugosidad por voxel en
//! el bump de sombreado, bisel por voxel en la mezcla facetado↔suave de la normal. Un
//! voxel SIN autor (`[0,0]`, el caso común) cae al look global → retrocompat.
//!
//! Escena: DOS domos idénticos lado a lado en el MISMO frame. Se rinde dos veces:
//! - **GLOBAL** (sin detalle por voxel; global rug=0, bisel=1): los dos domos son
//! indistinguibles — el escalar global no puede diferenciarlos.
//! - **POR VOXEL** (mismos globals): el domo izquierdo se marca `rug` alto + `bisel`
//! 0 (RUGOSO + FACETADO); el derecho `rug` 0 + `bisel` 1 (LISO). En un solo frame
//! el izquierdo queda áspero y con normales axiales, el derecho suave.
//!
//! Se certifica por STATS (regla 8), por mitad de pantalla (un domo por mitad):
//! 1. **GLOBAL** → las dos mitades casi no difieren (Δno-axial y Δtextura chicos).
//! 2. **POR VOXEL** → la mitad derecha es MUCHO más no-axial (lisa) que la izquierda
//! (facetada), y la izquierda MUCHO más áspera (textura) que la derecha.
//! 3. La divergencia POR VOXEL ≫ la GLOBAL (lo que el escalar global no lograba).
//!
//! `cargo run -p llimphi-3d --example detalle_por_voxel --release -- [dim]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
const W: u32 = 720;
const H: u32 = 480;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const BG: [u8; 3] = [18, 22, 32];
fn main() {
let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(120);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
// Cámara de frente (yaw 0) → el eje X del mundo cae en el eje X de la pantalla:
// el domo izquierdo queda a la izquierda, el derecho a la derecha.
let d = dim as f32;
let camera = Camera3d::orbit(Vec3::ZERO, 0_f32.to_radians(), 26_f32.to_radians(), d * 1.55);
// Global fijo para AMBAS pasadas: sin rugosidad global, bisel global ON (deja que
// el bisel POR VOXEL module la normal; con global rug=0 los domos "globales" son
// lisos e iguales).
let setup = |vr: &mut VoxelRenderer| {
vr.rugosidad_amp = 0.0;
vr.rugosidad_freq = 1.7;
vr.bisel = 1.0;
};
// --- Pasada GLOBAL: sin detalle por voxel ---
let grid_global = two_domes(dim, false);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid_global);
setup(&mut vr);
vr.debug_normals = true;
let g_norm = render_pixels(&hal, &mut renderer, &mut vr, &camera);
vr.debug_normals = false;
let g_shade = render_pixels(&hal, &mut renderer, &mut vr, &camera);
// --- Pasada POR VOXEL: detalle autorado por domo ---
let grid_pv = two_domes(dim, true);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid_pv);
setup(&mut vr);
vr.debug_normals = true;
let p_norm = render_pixels(&hal, &mut renderer, &mut vr, &camera);
vr.debug_normals = false;
let p_shade = render_pixels(&hal, &mut renderer, &mut vr, &camera);
let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into());
write_png(&g_shade, &format!("{dir}/detalle_global.png"));
write_png(&p_shade, &format!("{dir}/detalle_por_voxel.png"));
// Métricas por mitad (izq = domo rugoso/facetado, der = domo liso).
let g = HalfStats::measure(&g_norm, &g_shade);
let p = HalfStats::measure(&p_norm, &p_shade);
println!("detalle_por_voxel — dim={dim}³, {W}x{H} (dos domos, un frame)");
println!();
println!("{:>10}{:>12}{:>12}{:>10}{:>10}", "pasada", "no-axial izq", "no-axial der", "text.izq", "text.der");
println!("{:─<11}{:─<14}{:─<14}{:─<12}{:─<12}", "", "", "", "", "");
println!("{:>10}{:>11.1}% │ {:>11.1}% │ {:>10.2}{:>10.2}", "GLOBAL", g.na_l, g.na_r, g.tex_l, g.tex_r);
println!("{:>10}{:>11.1}% │ {:>11.1}% │ {:>10.2}{:>10.2}", "POR VOXEL", p.na_l, p.na_r, p.tex_l, p.tex_r);
println!();
// Divergencias entre mitades (lo que el look global NO puede generar).
let g_na_div = (g.na_l - g.na_r).abs();
let p_na_div = (p.na_r - p.na_l).abs();
let g_tex_div = (g.tex_l - g.tex_r).abs();
let p_tex_div = (p.tex_l - p.tex_r).abs();
println!("divergencia no-axial entre mitades: GLOBAL {g_na_div:.1}% → POR VOXEL {p_na_div:.1}%");
println!("divergencia textura entre mitades: GLOBAL {g_tex_div:.2} → POR VOXEL {p_tex_div:.2}");
println!("PNG: {dir}/detalle_global.png vs {dir}/detalle_por_voxel.png");
// 1. GLOBAL: las dos mitades son casi iguales (el escalar no diferencia materiales).
assert!(g_na_div < 12.0, "GLOBAL: las mitades ya diferían en no-axial ({g_na_div:.1}%) — deberían ser casi iguales");
assert!(g_tex_div < 3.0, "GLOBAL: las mitades ya diferían en textura ({g_tex_div:.2}) — deberían ser casi iguales");
// 2. POR VOXEL: der (liso) mucho más no-axial que izq (facetado); izq mucho más
// áspera (textura) que der.
assert!(p.na_r > p.na_l + 20.0, "POR VOXEL: el domo derecho (bisel=1) no quedó más liso que el izquierdo (facetado): {:.1}% vs {:.1}%", p.na_r, p.na_l);
assert!(p.tex_l > p.tex_r + 4.0, "POR VOXEL: el domo izquierdo (rug alto) no quedó más áspero que el derecho (liso): {:.2} vs {:.2}", p.tex_l, p.tex_r);
// 3. La divergencia POR VOXEL supera claramente a la GLOBAL en ambos ejes.
assert!(p_na_div > g_na_div + 15.0, "la divergencia de bisel por voxel no supera a la global ({p_na_div:.1}% vs {g_na_div:.1}%)");
assert!(p_tex_div > g_tex_div + 4.0, "la divergencia de rugosidad por voxel no supera a la global ({p_tex_div:.2} vs {g_tex_div:.2})");
println!();
println!("✓ certificado: dos materiales en UN frame con look sub-voxel distinto (izq rugoso+facetado, der liso).");
println!(" El look global no puede: sus dos mitades quedaron casi iguales.");
}
/// Dos domos (paraboloides) lado a lado + piso. Con `paint`, el domo izquierdo se marca
/// RUGOSO + FACETADO (rug alto, bisel 0) y el derecho LISO (rug 0, bisel 1) por voxel.
fn two_domes(dim: u32, paint: bool) -> VoxelGrid {
let gx = dim;
let gy = (dim as f32 * 0.78) as u32;
let gz = dim;
let mut g = VoxelGrid::new([gx, gy, gz]);
let base = 2u32;
let peak = gy as f32 * 0.72;
let radius = gx as f32 * 0.20;
// Centros de los dos domos (en X): izquierdo ~0.28, derecho ~0.72.
let domos = [
(gx as f32 * 0.28, gz as f32 * 0.5, true), // izquierdo → rugoso/facetado
(gx as f32 * 0.72, gz as f32 * 0.5, false), // derecho → liso
];
for z in 0..gz {
for x in 0..gx {
// Cada columna toma el domo más cercano (los domos no se solapan).
let mut best_h = 0f32;
let mut es_izq = true;
for &(cx, cz, izq) in &domos {
let dx = x as f32 - cx;
let dz = z as f32 - cz;
let r = (dx * dx + dz * dz).sqrt() / radius;
let hill = (peak * (1.0 - r * r)).max(0.0);
if hill > best_h {
best_h = hill;
es_izq = izq;
}
}
let top = (base + best_h as u32).min(gy - 1);
for y in 0..=top {
let t = (y as f32 / gy as f32 * 160.0) as u8;
g.set(x, y, z, [130 + t / 3, 120 + t / 4, 95]);
// Detalle por voxel sólo en el volumen del domo (no el piso llano).
if paint && best_h > 0.5 {
if es_izq {
g.set_detail(x, y, z, 1.4, 0.0); // rugoso + facetado
} else {
g.set_detail(x, y, z, 0.0, 1.0); // liso
}
}
}
}
}
g.reset_dirty();
g
}
/// Estadísticas por mitad de pantalla: `_l` = mitad izquierda (x < W/2), `_r` = derecha.
struct HalfStats {
na_l: f32,
na_r: f32,
tex_l: f32,
tex_r: f32,
}
impl HalfStats {
fn measure(norm: &[u8], shade: &[u8]) -> Self {
let cov = coverage(shade);
let (na_l, na_r) = non_axial_halves(norm, &cov);
let (tex_l, tex_r) = texture_halves(shade, &cov);
Self { na_l, na_r, tex_l, tex_r }
}
}
fn lum(px: &[u8], i: usize) -> f32 {
let o = i * 4;
0.2126 * px[o] as f32 + 0.7152 * px[o + 1] as f32 + 0.0722 * px[o + 2] as f32
}
fn coverage(px: &[u8]) -> Vec<bool> {
(0..(W * H) as usize)
.map(|i| {
let o = i * 4;
let dr = (px[o] as i32 - BG[0] as i32).abs();
let dg = (px[o + 1] as i32 - BG[1] as i32).abs();
let db = (px[o + 2] as i32 - BG[2] as i32).abs();
dr + dg + db > 12
})
.collect()
}
/// % de píxeles cubiertos NO axiales (normal diagnóstica con mayor componente < 0.94),
/// separado por mitad izquierda/derecha de pantalla.
fn non_axial_halves(px: &[u8], cov: &[bool]) -> (f32, f32) {
let (mut cl, mut nl, mut cr, mut nr) = (0u64, 0u64, 0u64, 0u64);
for y in 0..H as usize {
for x in 0..W as usize {
let i = y * W as usize + x;
if !cov[i] {
continue;
}
let o = i * 4;
let nx = px[o] as f32 / 255.0 * 2.0 - 1.0;
let ny = px[o + 1] as f32 / 255.0 * 2.0 - 1.0;
let nz = px[o + 2] as f32 / 255.0 * 2.0 - 1.0;
let m = nx.abs().max(ny.abs()).max(nz.abs());
let axial = m >= 0.94;
if x < W as usize / 2 {
cl += 1;
if !axial {
nl += 1;
}
} else {
cr += 1;
if !axial {
nr += 1;
}
}
}
}
(100.0 * nl as f32 / cl.max(1) as f32, 100.0 * nr as f32 / cr.max(1) as f32)
}
/// "Textura" = gradiente local medio de luminancia (|Δ derecha| + |Δ abajo|) sobre los
/// píxeles cubiertos, por mitad. La rugosidad mete ruido de normal → ruido de sombreado
/// → gradiente local alto; la superficie lisa lo tiene bajo.
fn texture_halves(px: &[u8], cov: &[bool]) -> (f32, f32) {
let (mut sl, mut cl, mut sr, mut cr) = (0f64, 0u64, 0f64, 0u64);
for y in 0..H as usize - 1 {
for x in 0..W as usize - 1 {
let i = y * W as usize + x;
if !cov[i] || !cov[i + 1] || !cov[i + W as usize] {
continue;
}
let l = lum(px, i);
let grad = (l - lum(px, i + 1)).abs() + (l - lum(px, i + W as usize)).abs();
if x < W as usize / 2 {
sl += grad as f64;
cl += 1;
} else {
sr += grad as f64;
cr += 1;
}
}
}
((sl / cl.max(1) as f64) as f32, (sr / cr.max(1) as f64) as f32)
}
fn render_pixels(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, camera: &Camera3d) -> Vec<u8> {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
let base = vello::Scene::new();
renderer
.render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(BG[0], BG[1], BG[2], 255))
.expect("render base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") });
vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
readback(hal, &inter)
}
fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
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("readback"),
size: (padded * H 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(H),
},
},
wgpu::Extent3d { width: W, height: H, 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 pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
pixels
}
fn write_png(pixels: &[u8], path: &str) {
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(pixels).unwrap();
}
+264
View File
@@ -0,0 +1,264 @@
//! Certificación headless del **spike de rugosidad sub-voxel** (bump procedural
//! en el ray-march). Prueba, por STATS numéricas (regla 8), la tesis del diseño:
//!
//! 1. **La geometría/silueta NO cambia** — el conjunto de píxeles cubiertos es
//! idéntico con rugosidad off y on (la perturbación toca sólo la normal de
//! SOMBREADO, no el `t`/posición del hit). → `coverage_mismatch ≈ 0`.
//! 2. **El sombreado deja de ser plano** — las caras axiales, que off producen
//! poquísimos niveles de luminancia (constante + gradiente de AO), on ganan
//! una micro-variación continua. → sube la "energía de textura" (|Δlum| entre
//! píxeles vecinos) y el nº de luminancias distintas, monótono con la amplitud.
//!
//! Corre un barrido de amplitud contra la línea base (amp=0) e imprime la tabla.
//! Emite además `off.png` / `on.png` por si se quiere MIRAR una comparación (último
//! recurso, no la evidencia primaria).
//!
//! `cargo run -p llimphi-3d --example rugosidad_demo --release -- [dim]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
const W: u32 = 720;
const H: u32 = 480;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Color de limpieza del fondo (los misses hacen `discard` → queda este color).
const BG: [u8; 3] = [18, 22, 32];
fn main() {
let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(96);
let d = dim as f32;
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
// Escena de fábrica: piso plano + esfera + pilares. El piso es la superficie
// ideal para exhibir rugosidad (cara superior grande y axial).
let grid = VoxelGrid::demo_scene([dim, dim, dim]);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
vr.rugosidad_freq = 1.6;
let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 24_f32.to_radians(), d * 1.7);
let amps = [0.0_f32, 0.4, 0.8, 1.2];
let mut frames: Vec<(f32, Vec<u8>)> = Vec::new();
for &amp in &amps {
vr.rugosidad_amp = amp;
frames.push((amp, render_pixels(&hal, &mut renderer, &mut vr, &camera)));
}
let base = &frames[0].1;
let base_cov = coverage(base);
let base_covered: usize = base_cov.iter().filter(|&&c| c).count();
println!("rugosidad_demo — dim={dim}³, freq={:.2}, {W}x{H}", vr.rugosidad_freq);
println!("píxeles cubiertos (silueta base): {base_covered}");
println!();
println!(
"{:>6}{:>10}{:>9}{:>9}{:>9}{:>8}{:>8}",
"amp", "cobertura", "cov.mism", "cambió%", "Δlum.med", "Δlum.máx", "textura"
);
println!("{:─<7}{:─<12}{:─<11}{:─<11}{:─<11}{:─<10}{:─<10}", "", "", "", "", "", "", "");
// (amp, cov_mismatch_pct, mean_delta_vs_base, texture_energy)
let mut stats: Vec<(f32, f32, f32, f32)> = Vec::new();
for (amp, px) in &frames {
let cov = coverage(px);
let covered: usize = cov.iter().filter(|&&c| c).count();
// Silueta: nº de píxeles cuya cobertura difiere de la base (debe ser ~0).
let cov_mismatch = cov.iter().zip(&base_cov).filter(|(a, b)| a != b).count();
// Sobre los píxeles cubiertos EN AMBOS: cuántos cambiaron de sombreado y
// cuánto (vs la base plana). `Δlum.med` es 0 por construcción en amp=0 →
// aísla LIMPIAMENTE el efecto de la rugosidad (a diferencia de `textura`,
// que ya arranca alto por el grano de color horneado en el albedo).
let mut changed = 0usize;
let mut sum_d = 0u64;
let mut max_d = 0u32;
let mut both = 0usize;
for i in 0..(W * H) as usize {
if cov[i] && base_cov[i] {
both += 1;
let d = (lum(px, i) as i32 - lum(base, i) as i32).unsigned_abs();
if d > 2 {
changed += 1;
}
sum_d += d as u64;
max_d = max_d.max(d);
}
}
let changed_pct = 100.0 * changed as f32 / both.max(1) as f32;
let mean_d = sum_d as f32 / both.max(1) as f32;
let cov_mism_pct = 100.0 * cov_mismatch as f32 / base_covered.max(1) as f32;
// "Energía de textura": |Δlum| medio entre píxeles vecinos ambos cubiertos.
let tex = texture_energy(px, &cov);
println!(
"{amp:>6.2}{covered:>10}{cov_mismatch:>9}{changed_pct:>8.2}% │ {mean_d:>9.3}{max_d:>8}{tex:>8.3}"
);
stats.push((*amp, cov_mism_pct, mean_d, tex));
}
// Verificación dura (asserts):
// 1. La silueta NO se mueve — la geometría es idéntica off/on (bump toca sólo
// la normal de sombreado). Tolerancia mínima por bordes sin antialias.
// 2. El sombreado cambia de verdad en la mayoría de la superficie (Δlum vs base
// >> 0), y ese cambio crece MONÓTONO con la amplitud (es la amplitud quien
// lo maneja, no ruido). `Δlum.med` y `textura` deben ambos subir en cada paso.
println!();
let (_, mism_pct, mean_d_last, _) = *stats.last().unwrap();
println!("silueta: mismatch amp_max vs base = {mism_pct:.3}% de la cobertura");
println!(
"efecto (Δlum.med vs base): {}",
stats.iter().map(|s| format!("{:.2}", s.2)).collect::<Vec<_>>().join("")
);
println!(
"textura (|Δlum| vecinos): {}",
stats.iter().map(|s| format!("{:.2}", s.3)).collect::<Vec<_>>().join("")
);
assert!(mism_pct < 0.5, "la silueta cambió demasiado: {mism_pct:.3}% (la geometría NO debía moverse)");
assert!(mean_d_last > 3.0, "la rugosidad casi no cambió el sombreado (Δlum.med={mean_d_last:.3})");
for w in stats.windows(2) {
assert!(w[1].2 > w[0].2, "Δlum.med no crece con la amplitud: {:.3} → {:.3}", w[0].2, w[1].2);
assert!(w[1].3 > w[0].3, "textura no crece con la amplitud: {:.3} → {:.3}", w[0].3, w[1].3);
}
// PNGs comparativos (último recurso, opcional de mirar).
let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into());
write_png(&frames[0].1, &format!("{dir}/rugosidad_off.png"));
write_png(&frames.last().unwrap().1, &format!("{dir}/rugosidad_on.png"));
println!();
println!("PNG comparativo: {dir}/rugosidad_off.png vs {dir}/rugosidad_on.png");
println!("✓ certificado: geometría intacta + sombreado ya no plano.");
}
/// Luminancia Rec.709 (byte) del píxel `i`.
fn lum(px: &[u8], i: usize) -> u8 {
let o = i * 4;
(0.2126 * px[o] as f32 + 0.7152 * px[o + 1] as f32 + 0.0722 * px[o + 2] as f32) as u8
}
/// Máscara de cobertura: píxel != fondo (con tolerancia). El fondo es el color de
/// limpieza; un voxel golpeado nunca cae exactamente ahí.
fn coverage(px: &[u8]) -> Vec<bool> {
(0..(W * H) as usize)
.map(|i| {
let o = i * 4;
let dr = (px[o] as i32 - BG[0] as i32).abs();
let dg = (px[o + 1] as i32 - BG[1] as i32).abs();
let db = (px[o + 2] as i32 - BG[2] as i32).abs();
dr + dg + db > 12
})
.collect()
}
/// Energía de micro-textura: |Δlum| medio entre píxeles horizontalmente vecinos
/// ambos cubiertos. Mide cuánto varía la superficie (plana ≈ 0).
fn texture_energy(px: &[u8], cov: &[bool]) -> f32 {
let mut sum = 0u64;
let mut n = 0u64;
for y in 0..H as usize {
for x in 0..(W as usize - 1) {
let i = y * W as usize + x;
if cov[i] && cov[i + 1] {
sum += (lum(px, i) as i32 - lum(px, i + 1) as i32).unsigned_abs() as u64;
n += 1;
}
}
}
sum as f32 / n.max(1) as f32
}
fn render_pixels(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, camera: &Camera3d) -> Vec<u8> {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
let base = vello::Scene::new();
renderer
.render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(BG[0], BG[1], BG[2], 255))
.expect("render base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") });
vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
readback(hal, &inter)
}
fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
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("readback"),
size: (padded * H 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(H),
},
},
wgpu::Extent3d { width: W, height: H, 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 pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
pixels
}
fn write_png(pixels: &[u8], path: &str) {
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(pixels).unwrap();
}
+289
View File
@@ -0,0 +1,289 @@
//! Certificación headless de la **SILUETA sub-voxel POR MATERIAL** (cierra el gap
//! "honesto" que quedaba del canal por voxel: `trace_smooth` trazaba la isosuperficie
//! GLOBAL, así que el `bisel` por voxel sólo cambiaba la NORMAL de sombreado, no el
//! CONTORNO — dos materiales no podían tener siluetas distintas en un frame).
//!
//! Con `VoxelRenderer::silueta_por_voxel`, el CAMPO mismo mezcla escalón↔trilineal
//! según el `bisel` del material sólido cercano: un material duro (bisel 0) conserva la
//! silueta axial/blocky, uno liso (bisel 1) la redondea. En el MISMO frame.
//!
//! Escena: DOS domos (paraboloides) lado a lado. El izquierdo se marca DURO (bisel 0
//! por voxel), el derecho LISO (bisel 1 por voxel). Se rinde dos veces con el MISMO
//! `bisel` global (1 → `trace_smooth` activo):
//! - **OFF** (`silueta_por_voxel=false`): el campo es trilineal para ambos → los dos
//! domos salen con silueta redondeada (idénticos en contorno).
//! - **ON**: el izquierdo se vuelve blocky (silueta en las caras del voxel), el
//! derecho sigue redondeado (su `s=1` cae al fast-path trilineal, byte-idéntico).
//!
//! Se certifica por STATS (regla 8): el cambio de COBERTURA (silueta) OFF→ON, por mitad
//! de pantalla. La mitad IZQUIERDA (dura) mueve su contorno mucho; la DERECHA (lisa) no
//! cambia (mismo campo trilineal) → la silueta por material es real y AISLADA.
//!
//! `cargo run -p llimphi-3d --example silueta_por_voxel --release -- [dim]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
const W: u32 = 720;
const H: u32 = 480;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const BG: [u8; 3] = [18, 22, 32];
fn main() {
let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(120);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
// Cámara de frente (yaw 0): X del mundo → X de pantalla (izq/der no se cruzan).
let d = dim as f32;
let camera = Camera3d::orbit(Vec3::ZERO, 0_f32.to_radians(), 26_f32.to_radians(), d * 1.6);
// Global fijo: bisel global ON (enciende trace_smooth = la isosuperficie que la
// silueta por voxel necesita); sin rugosidad (aislar la geometría, no el sombreado).
let setup = |vr: &mut VoxelRenderer| {
vr.rugosidad_amp = 0.0;
vr.bisel = 1.0;
};
// MISMA escena (izq duro / der liso, por voxel) en ambas pasadas; sólo cambia el flag.
let grid = two_domes(dim);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
setup(&mut vr);
vr.silueta_por_voxel = false;
let off = render_pixels(&hal, &mut renderer, &mut vr, &camera);
vr.silueta_por_voxel = true;
let on = render_pixels(&hal, &mut renderer, &mut vr, &camera);
let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into());
write_png(&off, &format!("{dir}/silueta_off.png"));
write_png(&on, &format!("{dir}/silueta_on.png"));
let cov_off = coverage(&off);
let cov_on = coverage(&on);
// Cobertura por mitad (cuántos píxeles pinta cada domo) y cuánto CAMBIÓ el contorno.
let (n_off_l, n_off_r) = count_halves(&cov_off);
let (n_on_l, n_on_r) = count_halves(&cov_on);
let (chg_l, chg_r) = changed_halves(&cov_off, &cov_on);
// Fracción del contorno que se movió, relativa a la cobertura del domo de esa mitad.
let frac_l = 100.0 * chg_l as f32 / n_off_l.max(1) as f32;
let frac_r = 100.0 * chg_r as f32 / n_off_r.max(1) as f32;
println!("silueta_por_voxel — dim={dim}³, {W}x{H} (izq DURO / der LISO, un frame)");
println!();
println!("{:>10}{:>12}{:>12}", "pasada", "cobertura izq", "cobertura der");
println!("{:─<11}{:─<14}{:─<14}", "", "", "");
println!("{:>10}{:>12}{:>12}", "OFF", n_off_l, n_off_r);
println!("{:>10}{:>12}{:>12}", "ON", n_on_l, n_on_r);
println!();
println!("píxeles de silueta que CAMBIARON OFF→ON: izq {chg_l} ({frac_l:.1}%) der {chg_r} ({frac_r:.1}%)");
println!("PNG: {dir}/silueta_off.png vs {dir}/silueta_on.png");
println!();
// El bisel sub-voxel mueve la silueta ~½ voxel: la banda que cambia es fina (fila de
// contorno), no un bloque. Lo contundente es la ASIMETRÍA y el aislamiento perfecto.
// 1. La mitad DERECHA (lisa) NO cambia: su `s=1` cae al fast-path trilineal → campo
// byte-idéntico OFF/ON. Prueba que el efecto es por-material, no global.
assert!(frac_r < 0.3, "la mitad lisa cambió su silueta ({frac_r:.1}%) — debería ser ~0 (mismo campo trilineal)");
// 2. La mitad IZQUIERDA (dura) mueve el contorno: la silueta pasó de redondeada
// (isosuperficie) a blocky (caras del voxel).
assert!(chg_l > 120, "la mitad dura casi no movió su silueta ({chg_l} px) — el flag no hizo efecto");
// 3. El efecto está AISLADO por material: la banda dura supera con creces a la lisa.
assert!(chg_l > chg_r * 8 + 60, "la silueta dura no se aisló de la lisa (izq {chg_l} px vs der {chg_r} px)");
println!("✓ certificado: en UN frame, el material duro conserva silueta blocky y el liso");
println!(" la redondea. El contorno se movió sólo en la mitad dura ({chg_l} px vs {chg_r} px, aislado).");
}
/// Dos CAJAS (prismas) lado a lado + piso. La izquierda se marca DURA (bisel 0 por
/// voxel), la derecha LISA (bisel 1). Las aristas de una caja se biselan a lo largo de
/// TODA su longitud → el bisel/silueta mueve el contorno en una banda alta (mucho más
/// que un domo). El piso queda sin autor (look global).
fn two_domes(dim: u32) -> VoxelGrid {
let gx = dim;
let gy = (dim as f32 * 0.78) as u32;
let gz = dim;
let mut g = VoxelGrid::new([gx, gy, gz]);
let base = 2u32;
let half = gx as f32 * 0.15; // semilado de la caja
let box_top = base + (gy as f32 * 0.66) as u32;
let cajas = [
(gx as f32 * 0.30, gz as f32 * 0.5, true), // izquierda → dura
(gx as f32 * 0.70, gz as f32 * 0.5, false), // derecha → lisa
];
for z in 0..gz {
for x in 0..gx {
let mut dentro = false;
let mut es_izq = true;
for &(cx, cz, izq) in &cajas {
if (x as f32 - cx).abs() <= half && (z as f32 - cz).abs() <= half {
dentro = true;
es_izq = izq;
break;
}
}
if !dentro {
continue;
}
let top = box_top.min(gy - 1);
for y in 0..=top {
let t = (y as f32 / gy as f32 * 160.0) as u8;
g.set(x, y, z, [130 + t / 3, 120 + t / 4, 95]);
if es_izq {
g.set_detail(x, y, z, 0.0, 0.0); // DURA: bisel 0 (autorado)
} else {
g.set_detail(x, y, z, 0.0, 1.0); // LISA: bisel 1
}
}
}
}
g.reset_dirty();
g
}
fn coverage(px: &[u8]) -> Vec<bool> {
(0..(W * H) as usize)
.map(|i| {
let o = i * 4;
let dr = (px[o] as i32 - BG[0] as i32).abs();
let dg = (px[o + 1] as i32 - BG[1] as i32).abs();
let db = (px[o + 2] as i32 - BG[2] as i32).abs();
dr + dg + db > 12
})
.collect()
}
/// Píxeles cubiertos por mitad de pantalla (izq = x < W/2).
fn count_halves(cov: &[bool]) -> (u64, u64) {
let (mut l, mut r) = (0u64, 0u64);
for y in 0..H as usize {
for x in 0..W as usize {
if cov[y * W as usize + x] {
if x < W as usize / 2 {
l += 1;
} else {
r += 1;
}
}
}
}
(l, r)
}
/// Píxeles cuya cobertura CAMBIÓ entre dos pasadas (silueta que se movió), por mitad.
fn changed_halves(a: &[bool], b: &[bool]) -> (u64, u64) {
let (mut l, mut r) = (0u64, 0u64);
for y in 0..H as usize {
for x in 0..W as usize {
let i = y * W as usize + x;
if a[i] != b[i] {
if x < W as usize / 2 {
l += 1;
} else {
r += 1;
}
}
}
}
(l, r)
}
fn render_pixels(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, camera: &Camera3d) -> Vec<u8> {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
let base = vello::Scene::new();
renderer
.render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(BG[0], BG[1], BG[2], 255))
.expect("render base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") });
vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
readback(hal, &inter)
}
fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
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("readback"),
size: (padded * H 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(H),
},
},
wgpu::Extent3d { width: W, height: H, 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 pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
pixels
}
fn write_png(pixels: &[u8], path: &str) {
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(pixels).unwrap();
}
+4 -4
View File
@@ -5,7 +5,7 @@
//!
//! Cosechado de los sprites 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde
//! los quads se armaban en CPU y estaban atados al `WadAtlas`/tabla de tintes.
//! A la forma es agnóstica: un **atlas** (una textura) + una lista de
//! Aquí la forma es agnóstica: un **atlas** (una textura) + una lista de
//! [`Billboard`] con su sub-rect UV, tamaño en mundo y tinte. El quad de cara a
//! la cámara lo arma el vertex shader con los ejes `right`/`up` de la cámara, así
//! no hay reconstrucción por CPU cada frame.
@@ -42,9 +42,9 @@ impl Billboard {
/// Floats por instancia en el buffer (`center`3 + `size`2 + `uv_min`2 +
/// `uv_max`2 + `tint`4).
const FLOATS: usize = 3 + 2 + 2 + 2 + 4;
const STRIDE: usize = Self::FLOATS * 4;
pub(crate) const STRIDE: usize = Self::FLOATS * 4;
fn write_to(&self, out: &mut Vec<u8>) {
pub(crate) fn write_to(&self, out: &mut Vec<u8>) {
for v in self.center {
out.extend_from_slice(&v.to_ne_bytes());
}
@@ -286,7 +286,7 @@ impl Billboards {
/// Reemplaza la lista de billboards (recrea el buffer de instancias). El
/// orden importa para el z-fight de alpha-discard: poná los más cercanos
/// primero si querés (con depth-write el primero gana). Idealmente el caller
/// primero si quieres (con depth-write el primero gana). Idealmente el caller
/// los ordena back-to-front.
pub fn set_billboards(&mut self, device: &wgpu::Device, items: &[Billboard]) {
self.count = items.len() as u32;
+1 -1
View File
@@ -6,7 +6,7 @@
//!
//! Es genérico del motor 3D (no sabe de voxels ni de juegos): cualquier app que
//! quiera una cámara animada lo usa. La *dirección* de actores/eventos vive en
//! la capa de contenido (la app), no a.
//! la capa de contenido (la app), no aquí.
use glam::Vec3;
+378
View File
@@ -0,0 +1,378 @@
//! `Glows` — billboards **aditivos**: como [`Billboards`](crate::Billboards)
//! (quads de cara a la cámara desde un atlas) pero con **blend aditivo**, sin
//! escribir profundidad y **sin recorte de alpha** — para halos suaves y
//! luminosos: estrellas que brillan, atmósferas, auras de cuerpos, glows que el
//! bloom de [`PostFx`](crate::PostFx) infla. El `Billboards` opaco recorta el
//! borde (disco nítido); éste suma luz (sin borde, se funde).
//!
//! Comparte el tipo de instancia [`Billboard`](crate::Billboard). Se dibuja en
//! un pase con depth attachment, **después** de la geometría opaca (así el mundo
//! sólido ocluye los glows que quedan detrás; los de adelante suman luz):
//!
//! ```ignore
//! let mut g = Glows::new(&device, fmt);
//! g.set_atlas(&device, &queue, w, h, &soft_radial_rgba);
//! g.set_glows(&device, &[Billboard { .. }]);
//! g.upload(&queue, aspect, &camera);
//! g.draw(&mut pass);
//! ```
use crate::billboard::Billboard;
use crate::camera::Camera3d;
use crate::scene::DEPTH_FORMAT;
struct Atlas {
bind_group: wgpu::BindGroup,
}
/// Renderer de glows aditivos. Misma plomería que `Billboards`, distinto blend.
pub struct Glows {
pipeline: wgpu::RenderPipeline,
uniform_buf: wgpu::Buffer,
uniform_bg: wgpu::BindGroup,
tex_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
atlas: Option<Atlas>,
instances: Option<wgpu::Buffer>,
count: u32,
}
impl Glows {
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-3d-glow-uniform-layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-3d-glow-tex-layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-3d-glow-shader"),
source: wgpu::ShaderSource::Wgsl(GLOW_WGSL.into()),
});
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-glow-pl"),
bind_group_layouts: &[&uniform_layout, &tex_layout],
push_constant_ranges: &[],
});
// Blend aditivo: la luz se suma al fondo (src*1 + dst*1).
let additive = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-3d-glow-pipeline"),
layout: Some(&pl),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: Billboard::STRIDE as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x3,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 12,
shader_location: 1,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 20,
shader_location: 2,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 28,
shader_location: 3,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x4,
offset: 36,
shader_location: 4,
},
],
}],
},
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(additive),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-glow-uniform"),
size: 96, // view_proj(64) + right(16) + up(16)
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-3d-glow-uniform-bg"),
layout: &uniform_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buf.as_entire_binding(),
}],
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("llimphi-3d-glow-sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
Self {
pipeline,
uniform_buf,
uniform_bg,
tex_layout,
sampler,
atlas: None,
instances: None,
count: 0,
}
}
/// Sube/reemplaza el atlas (RGBA8, `w×h`). Para glows conviene un degradé
/// radial suave (gaussiano), sin núcleo duro.
pub fn set_atlas(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
w: u32,
h: u32,
data: &[u8],
) {
assert_eq!(data.len(), (w * h * 4) as usize, "RGBA8 w*h*4 esperado");
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-3d-glow-atlas"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
data,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(w * 4),
rows_per_image: Some(h),
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
let view = tex.create_view(&Default::default());
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-3d-glow-atlas-bg"),
layout: &self.tex_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
self.atlas = Some(Atlas { bind_group });
}
/// Reemplaza la lista de glows (recrea el buffer de instancias). Con blend
/// aditivo el orden no importa (la suma es conmutativa).
pub fn set_glows(&mut self, device: &wgpu::Device, items: &[Billboard]) {
self.count = items.len() as u32;
if items.is_empty() {
self.instances = None;
return;
}
let mut bytes = Vec::with_capacity(items.len() * Billboard::STRIDE);
for b in items {
b.write_to(&mut bytes);
}
let buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-glow-instances"),
size: bytes.len() as u64,
usage: wgpu::BufferUsages::VERTEX,
mapped_at_creation: true,
});
buf.slice(..).get_mapped_range_mut().copy_from_slice(&bytes);
buf.unmap();
self.instances = Some(buf);
}
/// Sube el uniform del frame: `view_proj` + ejes `right`/`up` de la cámara.
pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) {
let view_proj = camera.view_proj(aspect);
let forward = (camera.target - camera.eye).normalize_or_zero();
let right = forward.cross(camera.up).normalize_or_zero();
let up = right.cross(forward);
let mut b = Vec::with_capacity(96);
for v in view_proj.to_cols_array() {
b.extend_from_slice(&v.to_ne_bytes());
}
for v in [right.x, right.y, right.z, 0.0] {
b.extend_from_slice(&v.to_ne_bytes());
}
for v in [up.x, up.y, up.z, 0.0] {
b.extend_from_slice(&v.to_ne_bytes());
}
queue.write_buffer(&self.uniform_buf, 0, &b);
}
/// Dibuja los glows en un pase ya abierto (con depth). No-op si falta algo.
pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
let (Some(atlas), Some(inst)) = (self.atlas.as_ref(), self.instances.as_ref()) else {
return;
};
if self.count == 0 {
return;
}
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.uniform_bg, &[]);
pass.set_bind_group(1, &atlas.bind_group, &[]);
pass.set_vertex_buffer(0, inst.slice(..));
pass.draw(0..6, 0..self.count);
}
}
/// Quad de cara a la cámara; el fragment SUMA luz (sin discard). La salida es
/// premultiplicada (`rgb*a`) para que el blend aditivo dé un halo suave.
const GLOW_WGSL: &str = r#"
struct U {
view_proj: mat4x4<f32>,
right: vec4<f32>,
up: vec4<f32>,
};
@group(0) @binding(0) var<uniform> u: U;
@group(1) @binding(0) var tex: texture_2d<f32>;
@group(1) @binding(1) var samp: sampler;
struct VIn {
@location(0) center: vec3<f32>,
@location(1) size: vec2<f32>,
@location(2) uv_min: vec2<f32>,
@location(3) uv_max: vec2<f32>,
@location(4) tint: vec4<f32>,
};
struct VOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) tint: vec4<f32>,
};
@vertex
fn vs(@builtin(vertex_index) vi: u32, in: VIn) -> VOut {
var cs = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 1.0),
);
let q = cs[vi];
let off = q - vec2<f32>(0.5, 0.5);
let world = in.center
+ u.right.xyz * (off.x * in.size.x)
+ u.up.xyz * (off.y * in.size.y);
var o: VOut;
o.clip = u.view_proj * vec4<f32>(world, 1.0);
o.uv = vec2<f32>(
mix(in.uv_min.x, in.uv_max.x, q.x),
mix(in.uv_min.y, in.uv_max.y, 1.0 - q.y),
);
o.tint = in.tint;
return o;
}
@fragment
fn fs(in: VOut) -> @location(0) vec4<f32> {
let c = textureSample(tex, samp, in.uv) * in.tint;
let a = c.a;
return vec4<f32>(c.rgb * a, a);
}
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glow_wgsl_valida() {
let module = naga::front::wgsl::parse_str(GLOW_WGSL).expect("GLOW_WGSL no parsea");
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.expect("GLOW_WGSL no valida");
}
}
+6 -2
View File
@@ -35,7 +35,9 @@ mod billboard;
mod camera;
mod cinema;
mod dimensions;
mod glow;
mod hud;
mod lines;
mod mesh;
mod postfx;
mod reflection;
@@ -48,10 +50,12 @@ mod voxel_renderer;
pub use billboard::{Billboard, Billboards};
pub use camera::Camera3d;
pub use cinema::{CamKey, CameraTrack};
pub use sky::{SkyBackdrop, SkyParams};
pub use glow::Glows;
pub use lines::{LineVertex, Lines3d};
pub use sky::{SkyBackdrop, SkyMapping, SkyParams};
pub use dimensions::{Dimension, Multiverse};
pub use hud::{Hud, HudQuad};
pub use mesh::{cube, push_cube, Vertex3d, CUBE_INDICES};
pub use mesh::{cube, push_cube, uv_sphere, Vertex3d, CUBE_INDICES};
pub use postfx::{PostFx, PostFxConfig};
pub use reflection::{PlanarReflection, ReflectionPlane, SurfaceParams};
pub use renderer::{Renderer3d, MESH_MAX_LIGHTS};
+244
View File
@@ -0,0 +1,244 @@
//! `Lines3d` — líneas 3D como `LineList`, depth-test pero **sin escribir**
//! profundidad, con alpha-blend y un atenuado por profundidad opcional para dar
//! volumen (el frente brilla, el fondo se apaga). Llena el hueco del crate, que
//! sólo tenía triángulos: sirve para anillos (círculos máximos), rejillas,
//! ejes y figuras de constelaciones — cualquier "alambre" 3D.
//!
//! Se dibuja en un pase ya abierto con depth attachment (el de
//! [`PostFx::scene_pass`](crate::PostFx) o [`Scene3d`](crate::Scene3d)), DESPUÉS
//! de la geometría sólida que deba ocluirlo:
//!
//! ```ignore
//! lines.set_lines(device, &verts); // pares de vértices = segmentos
//! lines.upload(queue, view_proj);
//! lines.draw(&mut pass);
//! ```
use glam::Mat4;
use crate::scene::DEPTH_FORMAT;
/// Vértice de línea: posición en mundo + color RGBA lineal.
#[derive(Debug, Clone, Copy)]
pub struct LineVertex {
pub pos: [f32; 3],
pub color: [f32; 4],
}
impl LineVertex {
/// `pos.xyz (12) + color.rgba (16)`.
pub const STRIDE: usize = 7 * 4;
fn write_to(&self, out: &mut Vec<u8>) {
for v in self.pos {
out.extend_from_slice(&v.to_ne_bytes());
}
for v in self.color {
out.extend_from_slice(&v.to_ne_bytes());
}
}
}
/// Renderer de líneas 3D reutilizable. Sin vértices, [`Self::draw`] es no-op.
pub struct Lines3d {
pipeline: wgpu::RenderPipeline,
uniform_buf: wgpu::Buffer,
uniform_bg: wgpu::BindGroup,
verts: Option<wgpu::Buffer>,
count: u32,
}
impl Lines3d {
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-3d-lines-uniform-layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-3d-lines-shader"),
source: wgpu::ShaderSource::Wgsl(LINES_WGSL.into()),
});
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-lines-pl"),
bind_group_layouts: &[&uniform_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-3d-lines-pipeline"),
layout: Some(&pl),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: LineVertex::STRIDE as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x3,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x4,
offset: 12,
shader_location: 1,
},
],
}],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::LineList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-lines-uniform"),
size: 64, // view_proj mat4
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-3d-lines-uniform-bg"),
layout: &uniform_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buf.as_entire_binding(),
}],
});
Self {
pipeline,
uniform_buf,
uniform_bg,
verts: None,
count: 0,
}
}
/// Reemplaza los vértices. Cada **par** consecutivo es un segmento; para una
/// polilínea, repetir el vértice interior (a,b, b,c, c,d…).
pub fn set_lines(&mut self, device: &wgpu::Device, verts: &[LineVertex]) {
if verts.is_empty() {
self.count = 0;
self.verts = None;
return;
}
let mut bytes = Vec::with_capacity(verts.len() * LineVertex::STRIDE);
for v in verts {
v.write_to(&mut bytes);
}
let buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-lines-vbuf"),
size: bytes.len() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: true,
});
buf.slice(..).get_mapped_range_mut().copy_from_slice(&bytes);
buf.unmap();
self.verts = Some(buf);
self.count = verts.len() as u32;
}
/// Sube la `view_proj` del frame. Llamar antes de [`Self::draw`].
pub fn upload(&self, queue: &wgpu::Queue, view_proj: Mat4) {
let mut b = Vec::with_capacity(64);
for v in view_proj.to_cols_array() {
b.extend_from_slice(&v.to_ne_bytes());
}
queue.write_buffer(&self.uniform_buf, 0, &b);
}
/// Dibuja en un pase ya abierto (con depth attachment). No-op sin vértices.
pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
let Some(vb) = self.verts.as_ref() else {
return;
};
if self.count < 2 {
return;
}
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.uniform_bg, &[]);
pass.set_vertex_buffer(0, vb.slice(..));
pass.draw(0..self.count, 0..1);
}
}
/// Atenúa el alpha por profundidad NDC (el fondo de la esfera se apaga → da
/// volumen). El color va tal cual; sólo el alpha se modula.
const LINES_WGSL: &str = r#"
struct U { view_proj: mat4x4<f32> };
@group(0) @binding(0) var<uniform> u: U;
struct VIn {
@location(0) pos: vec3<f32>,
@location(1) color: vec4<f32>,
};
struct VOut {
@builtin(position) clip: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) ndc_z: f32,
};
@vertex
fn vs(in: VIn) -> VOut {
var o: VOut;
o.clip = u.view_proj * vec4<f32>(in.pos, 1.0);
o.color = in.color;
o.ndc_z = o.clip.z / max(o.clip.w, 1e-6);
return o;
}
@fragment
fn fs(in: VOut) -> @location(0) vec4<f32> {
// ndc_z ~0 = cerca, ~1 = lejos. El fondo se apaga hasta 0.55 (da volumen
// sin que el hemisferio trasero desaparezca).
let depth_fade = mix(1.0, 0.55, clamp(in.ndc_z, 0.0, 1.0));
return vec4<f32>(in.color.rgb, in.color.a * depth_fade);
}
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lines_wgsl_valida() {
let module = naga::front::wgsl::parse_str(LINES_WGSL).expect("LINES_WGSL no parsea");
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.expect("LINES_WGSL no valida");
}
}
+38
View File
@@ -68,6 +68,44 @@ pub fn cube() -> (Vec<Vertex3d>, Vec<u16>) {
(verts, CUBE_INDICES.to_vec())
}
/// Genera una **esfera UV** unitaria (radio 1, centro en el origen): posiciones
/// (que también sirven de normales, por ser unitarias) + índices de triángulos
/// (winding CCW visto desde afuera). `stacks` = anillos de latitud, `slices` =
/// gajos de longitud. El caller arma los [`Vertex3d`] coloreando cada posición
/// (p.ej. por latitud/longitud y un sombreado horneado): el `Vertex3d` sólo
/// lleva color (no normal), así que para una esfera lisa conviene hornear el
/// sombreado en el color (Gouraud) en vez de depender de la normal plana del
/// shader de mallas.
pub fn uv_sphere(stacks: u32, slices: u32) -> (Vec<[f32; 3]>, Vec<u16>) {
let stacks = stacks.max(2);
let slices = slices.max(3);
let mut pos: Vec<[f32; 3]> = Vec::with_capacity(((stacks + 1) * (slices + 1)) as usize);
for i in 0..=stacks {
let v = i as f32 / stacks as f32;
let phi = v * std::f32::consts::PI; // 0 = polo norte, π = polo sur
let (sp, cp) = phi.sin_cos();
for j in 0..=slices {
let u = j as f32 / slices as f32;
let theta = u * std::f32::consts::TAU;
let (st, ct) = theta.sin_cos();
// +Y arriba (polo norte); x,z en el plano ecuatorial.
pos.push([sp * ct, cp, sp * st]);
}
}
let mut idx: Vec<u16> = Vec::with_capacity((stacks * slices * 6) as usize);
let row = slices + 1;
for i in 0..stacks {
for j in 0..slices {
let a = (i * row + j) as u16;
let b = (i * row + j + 1) as u16;
let c = ((i + 1) * row + j) as u16;
let d = ((i + 1) * row + j + 1) as u16;
idx.extend_from_slice(&[a, c, b, b, c, d]);
}
}
(pos, idx)
}
/// Apila un cubo transformado por `m` (mapea el cubo unitario `[-0.5,0.5]³` a su
/// caja en mundo) con color plano `color`, en `verts`/`indices`. Es el ladrillo
/// para componer mallas multi-caja en CPU: cada llamada agrega 8 vértices + 36
+49 -5
View File
@@ -2,7 +2,7 @@
//! pase de post-proceso reutilizable sobre cualquier render 3D wgpu.
//!
//! Cosechado del renderer 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde
//! estos efectos nacieron pegados a tipos del juego. A viven en su forma
//! estos efectos nacieron pegados a tipos del juego. Aquí viven en su forma
//! agnóstica: nada sabe de paredes, sprites ni WAD. Cualquier escena que se
//! pueda dibujar en un pase wgpu (las mallas de [`Renderer3d`](crate::Renderer3d),
//! los voxels de [`VoxelRenderer`](crate::VoxelRenderer), una carta 3D de cosmos,
@@ -25,7 +25,7 @@
//! let mut fx = PostFx::new(&device, color_format);
//! // ... en gpu_paint_with(dev, q, enc, view, rect, _vp):
//! fx.render_with(dev, q, enc, view, (w, h), wgpu::Color::BLACK, |pass| {
//! // `pass` ya está a supersample× con color+depth; sólo dibujá.
//! // `pass` ya está a supersample× con color+depth; sólo dibuja.
//! renderer3d.upload(q, w as f32 / h as f32, &camera); // antes del pase
//! renderer3d.draw(pass);
//! });
@@ -338,10 +338,37 @@ impl PostFx {
}
/// Resuelve la escena ya dibujada: bright-pass + blur → bloom, luego blit
/// (bajada SSAA + suma de bloom) sobre `target`, preservando lo que haya
/// debajo (`LoadOp::Load`). Requiere [`Self::prepare`] + el pase de
/// [`Self::scene_pass`] ya soltado.
/// (bajada SSAA + suma de bloom) sobre **todo** `target`, preservando lo
/// que haya debajo (`LoadOp::Load`). Requiere [`Self::prepare`] + el pase
/// de [`Self::scene_pass`] ya soltado.
pub fn resolve(&self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView) {
self.resolve_impl(encoder, target, None);
}
/// Como [`Self::resolve`] pero **confina** el blit final a la sub-región
/// `rect = (x, y, w, h)` (px del target, esquina sup-izq) vía
/// `set_viewport` + `set_scissor_rect`. Es lo que permite montar el
/// post-proceso en un **panel** de una UI sin pisar el chrome alrededor:
/// la escena supersampleada se baja ajustada al rect y el resto del
/// `target` queda intacto (`LoadOp::Load`). `viewport` = tamaño del
/// `target` completo (para clampear el scissor). El bright-pass sigue
/// siendo offscreen a media res (no necesita confinarse).
pub fn resolve_in(
&self,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
rect: (f32, f32, f32, f32),
viewport: (u32, u32),
) {
self.resolve_impl(encoder, target, Some((rect, viewport)));
}
fn resolve_impl(
&self,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
confine: Option<((f32, f32, f32, f32), (u32, u32))>,
) {
let scene_bg = match self.scene.as_ref() {
Some(s) => &s.tex_bg,
None => return,
@@ -388,6 +415,23 @@ impl PostFx {
timestamp_writes: None,
occlusion_query_set: 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
// otro modo desbordaría el rect y pisaría el chrome.
if let Some(((rx, ry, rw, rh), (vw, vh))) = confine {
if rw < 1.0 || rh < 1.0 {
return;
}
blit.set_viewport(rx, ry, rw, rh, 0.0, 1.0);
let sx = rx.max(0.0);
let sy = ry.max(0.0);
let sw = (rw.min(vw as f32 - sx)).max(0.0) as u32;
let sh = (rh.min(vh as f32 - sy)).max(0.0) as u32;
if sw == 0 || sh == 0 {
return;
}
blit.set_scissor_rect(sx as u32, sy as u32, sw, sh);
}
blit.set_pipeline(&self.blit_pipeline);
blit.set_bind_group(0, scene_bg, &[]);
blit.set_bind_group(1, &bloom.tex_bg, &[]);
+1 -1
View File
@@ -3,7 +3,7 @@
//!
//! Cosechado del agua 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde la
//! reflexión estaba cableada al plano horizontal de Doom (`reflect_across_z`) y
//! al shader del agua. A:
//! al shader del agua. Aquí:
//!
//! - [`ReflectionPlane`] modela un plano **arbitrario** (`n·x + d = 0`) y da su
//! matriz espejo ([`ReflectionPlane::mirror`]) por reflexión de Householder.
+61 -4
View File
@@ -5,7 +5,7 @@
//!
//! Cosechado del cielo 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde
//! estaba pegado a las constantes de Doom (tileo 4×/360°, estiramiento vertical
//! 1.8×). A esos números son **parámetros** ([`SkyParams`]), así un panorama
//! 1.8×). Aquí esos números son **parámetros** ([`SkyParams`]), así un panorama
//! normal usa los defaults y Doom reproduce su look exacto pasando los suyos.
//!
//! Se dibuja **primero** en un pase que ya tiene depth attachment (el de
@@ -19,6 +19,24 @@
use crate::scene::DEPTH_FORMAT;
/// Cómo se proyecta el panorama sobre la pantalla.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SkyMapping {
/// **Cilíndrico** (estilo Doom): la columna muestrea por el azimut y la fila
/// se desplaza linealmente con el `pitch`. Barato y exacto para una cámara
/// que casi no cabecea (FPS), pero **degenera** si la cámara mira muy
/// arriba/abajo (la textura se sale de pantalla y el borde se estira en
/// rayas verticales). Usa `wraps`/`v_scale`/`pitch_scale`/`v_offset`.
#[default]
Cylindrical,
/// **Esférico** (equirectangular): por cada píxel se reconstruye el rayo de
/// cámara desde `yaw`/`pitch`/`fov_x`/`aspect` y se mapea a la textura por
/// (azimut, elevación). Correcto a cualquier cabeceo y bloqueado al mundo
/// como la geometría — el cielo es una esfera de fondo, no una franja. Los
/// campos `wraps`/`v_scale`/`pitch_scale`/`v_offset` se ignoran.
Spherical,
}
/// Parámetros del cielo por frame. `yaw`/`pitch`/`fov_x` salen de la cámara;
/// el resto modela cómo se mapea la textura.
#[derive(Clone, Copy, Debug)]
@@ -41,6 +59,8 @@ pub struct SkyParams {
pub pitch_scale: f32,
/// Offset vertical fijo añadido a la coordenada de textura.
pub v_offset: f32,
/// Proyección del panorama. Ver [`SkyMapping`].
pub mapping: SkyMapping,
}
impl Default for SkyParams {
@@ -54,6 +74,7 @@ impl Default for SkyParams {
v_scale: 1.0,
pitch_scale: 1.0,
v_offset: 0.0,
mapping: SkyMapping::Cylindrical,
}
}
}
@@ -159,7 +180,7 @@ impl SkyBackdrop {
let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-sky-uniform"),
size: 32, // 8 × f32
size: 48, // 9 × f32 redondeado a múltiplo de 16
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -255,7 +276,11 @@ impl SkyBackdrop {
/// Sube los parámetros del frame. Llamar antes de [`Self::draw`].
pub fn upload(&self, queue: &wgpu::Queue, p: &SkyParams) {
let mut b = Vec::with_capacity(32);
let mode = match p.mapping {
SkyMapping::Cylindrical => 0.0,
SkyMapping::Spherical => 1.0,
};
let mut b = Vec::with_capacity(48);
for v in [
p.yaw,
p.pitch,
@@ -265,6 +290,10 @@ impl SkyBackdrop {
p.v_scale,
p.pitch_scale,
p.v_offset,
mode,
0.0, // pad a múltiplo de 16
0.0,
0.0,
] {
b.extend_from_slice(&v.to_ne_bytes());
}
@@ -289,6 +318,7 @@ const SKY_WGSL: &str = r#"
struct SkyU {
yaw: f32, pitch: f32, fov_x: f32, aspect: f32,
wraps: f32, v_scale: f32, pitch_scale: f32, v_offset: f32,
mode: f32, _pad0: f32, _pad1: f32, _pad2: f32,
};
@group(0) @binding(0) var<uniform> s: SkyU;
@group(1) @binding(0) var sky: texture_2d<f32>;
@@ -317,7 +347,34 @@ fn vs(@builtin(vertex_index) vi: u32) -> SOut {
@fragment
fn fs(in: SOut) -> @location(0) vec4<f32> {
// Azimut de la columna: centro = yaw, bordes ± fov/2.
if (s.mode > 0.5) {
// ---- Esférico (equirectangular): reconstruir el rayo de cámara ----
// Base de cámara igual a Camera3d::orbit(target=0): el ojo está en
// (cp·sy, sp, cp·cy)·dist y mira al origen, up=+Y.
let sy = sin(s.yaw); let cy = cos(s.yaw);
let sp = sin(s.pitch); let cp = cos(s.pitch);
let zaxis = normalize(vec3<f32>(cp * sy, sp, cp * cy)); // ojo→origen invertido
let xaxis = normalize(cross(vec3<f32>(0.0, 1.0, 0.0), zaxis));
let yaxis = cross(zaxis, xaxis);
let tan_x = tan(s.fov_x * 0.5);
let tan_y = tan_x / s.aspect;
let ndc_x = in.scr.x * 2.0 - 1.0;
let ndc_y = 1.0 - in.scr.y * 2.0; // arriba = +1
// La cámara mira hacia -zaxis (look_at_rh).
let dir = normalize(ndc_x * tan_x * xaxis + ndc_y * tan_y * yaxis - zaxis);
let az = atan2(dir.x, dir.z); // -PI..PI
let el = asin(clamp(dir.y, -1.0, 1.0)); // -PI/2..PI/2
let u = az / (2.0 * PI) + 0.5;
let v = 0.5 - el / PI; // arriba(+el) → v=0
let col = textureSample(sky, samp, vec2<f32>(u, v));
// El equirectangular tiene una singularidad en los polos (cenit/nadir):
// todas las columnas colapsan a una fila y la textura se abre en abanico
// ("starburst"). Atenuamos el muestreo a oscuro cerca del polo para que
// se disuelva en cielo profundo en vez de reventar en rayas radiales.
let pole = smoothstep(0.78, 0.995, abs(dir.y));
return col * (1.0 - pole);
}
// ---- Cilíndrico (Doom): azimut por columna, fila por pitch lineal ----
let colang = s.yaw - (in.scr.x - 0.5) * s.fov_x;
let su = fract(colang / (2.0 * PI) * s.wraps);
let sv = clamp(in.scr.y * s.v_scale - s.pitch * s.pitch_scale + s.v_offset, 0.0, 1.0);
+205
View File
@@ -20,6 +20,25 @@ pub type DirtyBox = [u32; 6];
pub struct VoxelGrid {
dim: [u32; 3],
data: Vec<[u8; 4]>,
/// **Detalle sub-voxel por voxel** (Rg8): `[rugosidad, bisel]` codificados con
/// **offset +1** para distinguir "sin autor" (`[0,0]`) de "autor puso 0"
/// (`[1,1]`). `rug`/`bisel` autorados en `[0,1]` (rug re-escalado a amplitud
/// `0..1.5` en el shader). Todo `0` = el voxel usa el **look global** del
/// ray-march (`VoxelRenderer::{rugosidad_amp, bisel}`) → retrocompat byte a
/// byte cuando nadie lo toca. Lo llena [`set_detail`](Self::set_detail); el
/// renderer lo sube a un atlas paralelo (`dpool`) que espeja los slots del
/// pool de color. Es una propiedad de **construcción** del material, no un
/// canal editado en vivo (`sync`/`scroll_to` no lo re-suben).
detail: Vec<[u8; 2]>,
/// **Pincel de detalle** activo (bytes ya codificados con offset +1, o `None`).
/// Cuando está puesto, cada [`set`](Self::set)/[`set_rgba`](Self::set_rgba)
/// estampa también ESTE detalle en el voxel — así los estampadores profundos
/// (`place_objeto`, `stamp_bicho`…) heredan el detalle del material/ser sin
/// tener que threadearlo por cada `put`. Se prende con
/// [`set_detail_brush`](Self::set_detail_brush) y se apaga con
/// [`clear_detail_brush`](Self::clear_detail_brush). `None` = comportamiento
/// clásico (el voxel queda "sin autor" salvo `set_detail` explícito).
detail_brush: Option<[u8; 2]>,
/// AABB de voxels mutados desde el último `take_dirty`. `None` = sin cambios.
dirty: Option<DirtyBox>,
}
@@ -31,6 +50,8 @@ impl VoxelGrid {
Self {
dim,
data: vec![[0, 0, 0, 0]; n],
detail: vec![[0, 0]; n],
detail_brush: None,
dirty: None,
}
}
@@ -72,15 +93,104 @@ impl VoxelGrid {
self.dirty = None;
}
/// Marca **todo** el grid como sucio (caja = grid entero) → el próximo
/// [`VoxelRenderer::sync`](crate::VoxelRenderer::sync) re-sube la ventana completa.
/// Útil al **regenerar** el grid por streaming (la ventana cambió de origen).
pub fn mark_all_dirty(&mut self) {
self.dirty = Some([0, 0, 0, self.dim[0] - 1, self.dim[1] - 1, self.dim[2] - 1]);
}
/// Marca un voxel sólido con color `rgb` (alpha = 255). Fuera de rango: no-op.
pub fn set(&mut self, x: u32, y: u32, z: u32, rgb: [u8; 3]) {
if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
let i = self.idx(x, y, z);
self.data[i] = [rgb[0], rgb[1], rgb[2], 255];
if let Some(d) = self.detail_brush {
self.detail[i] = d;
}
self.mark_dirty(x, y, z);
}
}
/// Marca un voxel con color `rgb` y **alpha** explícito. El alpha codifica
/// ocupación **y** brillo: `>127` = sólido; el shader lee el brillo como
/// `(1 - a/255)·2` (matte `255` → 0 brillo, `130` → lustre pleno). Pasar
/// alpha `≤127` lo dejaría "vacío" para el shader — usa `[130, 255]` para
/// voxels lustrosos. Fuera de rango: no-op.
pub fn set_rgba(&mut self, x: u32, y: u32, z: u32, rgb: [u8; 3], a: u8) {
if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
let i = self.idx(x, y, z);
self.data[i] = [rgb[0], rgb[1], rgb[2], a];
if let Some(d) = self.detail_brush {
self.detail[i] = d;
}
self.mark_dirty(x, y, z);
}
}
/// Convierte un factor de **brillo** `[0,1]` al alpha de voxel correspondiente
/// (`0` → `255` matte, `1` → `130` lustre pleno), manteniéndolo siempre sólido.
#[inline]
pub fn shine_to_alpha(brillo: f32) -> u8 {
255 - (brillo.clamp(0.0, 1.0) * 125.0) as u8
}
/// Amplitud de rugosidad `[0,1.5]` que el shader mapea desde el byte de detalle
/// (espeja `(byte-1)/254·1.5`). Expuesto para que el caller razone en la misma
/// escala que `VoxelRenderer::rugosidad_amp`.
pub const DETALLE_RUG_MAX: f32 = 1.5;
/// Codifica un factor `[0,1]` al byte de detalle con **offset +1** (`0` autorado
/// → `1`, distinguible del `0` "sin autor"). `norm` ya normalizado a `[0,1]`.
#[inline]
fn detail_byte(norm: f32) -> u8 {
1 + (norm.clamp(0.0, 1.0) * 254.0).round() as u8
}
/// **Detalle sub-voxel de este voxel** (capa 3 del plan): `rugosidad` en
/// amplitud `[0, DETALLE_RUG_MAX]` y `bisel` en `[0,1]`, autorados por voxel.
/// Sobrescribe el look **global** del ray-march sólo para este voxel (dos
/// materiales en la misma escena pueden lucir distinto a la vez — lo que el
/// escalar global no podía). No cambia la ocupación; pon el voxel sólido con
/// `set`/`set_rgba` aparte. Fuera de rango: no-op. No marca dirty: el detalle
/// es propiedad de construcción (el renderer lo hornea al armar/rearmar).
pub fn set_detail(&mut self, x: u32, y: u32, z: u32, rugosidad: f32, bisel: f32) {
if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
let i = self.idx(x, y, z);
self.detail[i] = [
Self::detail_byte(rugosidad / Self::DETALLE_RUG_MAX),
Self::detail_byte(bisel),
];
}
}
/// **Prende el pincel de detalle**: a partir de aquí cada `set`/`set_rgba`
/// estampa también este `(rugosidad, bisel)` en el voxel escrito (misma escala
/// que [`set_detail`](Self::set_detail)). Pensado para que los estampadores
/// profundos (flora en `place_objeto`, seres en `stamp_bicho`, muebles…) hereden
/// el detalle del material/ser sin threadearlo por cada `put`. Acuérdate de
/// apagarlo con [`clear_detail_brush`](Self::clear_detail_brush) al terminar el
/// objeto, o teñirás de más. No toca voxels ya escritos.
pub fn set_detail_brush(&mut self, rugosidad: f32, bisel: f32) {
self.detail_brush = Some([
Self::detail_byte(rugosidad / Self::DETALLE_RUG_MAX),
Self::detail_byte(bisel),
]);
}
/// Apaga el pincel de detalle (vuelve al comportamiento clásico: los `set`
/// posteriores no estampan detalle salvo `set_detail` explícito).
pub fn clear_detail_brush(&mut self) {
self.detail_brush = None;
}
/// Detalle codificado `[rug, bisel]` (bytes con offset +1; `[0,0]` = sin autor)
/// del voxel `(x,y,z)`, o `None` fuera de rango.
pub fn detail_at(&self, x: u32, y: u32, z: u32) -> Option<[u8; 2]> {
(x < self.dim[0] && y < self.dim[1] && z < self.dim[2])
.then(|| self.detail[self.idx(x, y, z)])
}
/// Vacía **todos** los voxels y marca el grid entero como dirty (la próxima
/// `VoxelRenderer::sync` re-sube todo). Para regenerar el contenido de una
/// ventana de *streaming* in-place sin reconstruir el renderer.
@@ -88,6 +198,9 @@ impl VoxelGrid {
for px in &mut self.data {
*px = [0, 0, 0, 0];
}
for d in &mut self.detail {
*d = [0, 0];
}
self.dirty = Some([0, 0, 0, self.dim[0] - 1, self.dim[1] - 1, self.dim[2] - 1]);
}
@@ -96,6 +209,7 @@ impl VoxelGrid {
if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
let i = self.idx(x, y, z);
self.data[i] = [0, 0, 0, 0];
self.detail[i] = [0, 0];
self.mark_dirty(x, y, z);
}
}
@@ -197,6 +311,28 @@ impl VoxelGrid {
out
}
/// Extrae el **detalle sub-voxel** de un brick `(cx,cy,cz)` de lado `brick`
/// como Rg8 plano (`brick³` voxels × 2 bytes, x contiguo), padeando con `[0,0]`
/// (sin autor → look global) los voxels fuera del grid. Espeja `extract_brick`
/// para el atlas paralelo `dpool` del renderer.
pub fn extract_brick_detail(&self, brick: u32, cx: u32, cy: u32, cz: u32) -> Vec<u8> {
let b = brick;
let mut out = vec![0u8; (b * b * b * 2) as usize];
for lz in 0..b {
for ly in 0..b {
for lx in 0..b {
let (x, y, z) = (cx * b + lx, cy * b + ly, cz * b + lz);
if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
let d = self.detail[self.idx(x, y, z)];
let o = ((lx + ly * b + lz * b * b) * 2) as usize;
out[o..o + 2].copy_from_slice(&d);
}
}
}
}
out
}
/// Extrae una sub-caja RGBA contigua `[origin, origin+ext)` para subirla con
/// `queue.write_texture` (M3: upload incremental de la región fina mutada).
pub fn extract_fine(&self, origin: [u32; 3], ext: [u32; 3]) -> Vec<u8> {
@@ -294,3 +430,72 @@ impl VoxelGrid {
g
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detalle_sin_autor_es_cero() {
// Un grid recién nacido no tiene detalle → todo `[0,0]` (look global).
let g = VoxelGrid::new([4, 4, 4]);
assert_eq!(g.detail_at(1, 1, 1), Some([0, 0]));
assert_eq!(g.detail_at(9, 0, 0), None, "fuera de rango");
}
#[test]
fn detalle_offset_distingue_cero_autorado() {
let mut g = VoxelGrid::new([4, 4, 4]);
// Autor pone 0 en ambos → bytes `[1,1]`, DISTINTOS del `[0,0]` sin autor.
g.set_detail(0, 0, 0, 0.0, 0.0);
assert_eq!(g.detail_at(0, 0, 0), Some([1, 1]), "0 autorado ≠ sin autor");
// Máximos → 255 (1 + 254).
g.set_detail(1, 0, 0, VoxelGrid::DETALLE_RUG_MAX, 1.0);
assert_eq!(g.detail_at(1, 0, 0), Some([255, 255]));
// Un valor intermedio queda por encima del piso.
g.set_detail(2, 0, 0, VoxelGrid::DETALLE_RUG_MAX * 0.5, 0.5);
let d = g.detail_at(2, 0, 0).unwrap();
assert!(d[0] > 120 && d[0] < 135, "rug medio ≈ 128: {}", d[0]);
assert!(d[1] > 120 && d[1] < 135, "bisel medio ≈ 128: {}", d[1]);
}
#[test]
fn pincel_de_detalle_tine_los_set_posteriores() {
let mut g = VoxelGrid::new([4, 4, 4]);
// Sin pincel: `set` deja el voxel "sin autor".
g.set(0, 0, 0, [10, 20, 30]);
assert_eq!(g.detail_at(0, 0, 0), Some([0, 0]), "sin pincel = sin autor");
// Con pincel: `set` y `set_rgba` heredan el detalle activo.
g.set_detail_brush(VoxelGrid::DETALLE_RUG_MAX, 1.0);
g.set(1, 0, 0, [10, 20, 30]);
g.set_rgba(2, 0, 0, [10, 20, 30], 130);
assert_eq!(g.detail_at(1, 0, 0), Some([255, 255]), "set hereda el pincel");
assert_eq!(g.detail_at(2, 0, 0), Some([255, 255]), "set_rgba hereda el pincel");
// Apagado: vuelve a "sin autor".
g.clear_detail_brush();
g.set(3, 0, 0, [10, 20, 30]);
assert_eq!(g.detail_at(3, 0, 0), Some([0, 0]), "pincel apagado = sin autor");
}
#[test]
fn extract_brick_detail_ubica_y_padea() {
let mut g = VoxelGrid::new([8, 8, 8]);
g.set_detail(1, 2, 3, VoxelGrid::DETALLE_RUG_MAX, 1.0);
let d = g.extract_brick_detail(8, 0, 0, 0);
assert_eq!(d.len(), 8 * 8 * 8 * 2);
// Índice del voxel (1,2,3) en el brick, ×2 bytes.
let o = (1 + 2 * 8 + 3 * 8 * 8) * 2;
assert_eq!(&d[o..o + 2], &[255, 255], "detalle en su lugar");
// El resto padeado en 0 (sin autor).
assert_eq!(&d[0..2], &[0, 0]);
}
#[test]
fn clear_borra_detalle() {
let mut g = VoxelGrid::new([4, 4, 4]);
g.set(0, 0, 0, [200, 100, 50]);
g.set_detail(0, 0, 0, 1.0, 0.8);
g.clear(0, 0, 0);
assert_eq!(g.detail_at(0, 0, 0), Some([0, 0]), "clear también zera el detalle");
}
}
+580 -40
View File
@@ -111,6 +111,14 @@ impl Default for Atmosphere {
/// Renderer de voxels por ray-march de dos niveles sobre un brick pool sparse.
pub struct VoxelRenderer {
pool: wgpu::Texture,
/// Atlas **paralelo** al `pool` (mismos slots/dimensiones): por voxel guarda el
/// **detalle sub-voxel** autorado (`Rg8Uint` = `[rugosidad, bisel]` con offset
/// +1; `[0,0]` = sin autor → look global). Se hornea al armar/rearmar el pool y
/// crece con él (`grow_layers`); `sync`/`scroll_to` NO lo re-suben (el detalle es
/// propiedad de construcción del material, no un canal editado en vivo). Su
/// isosuperficie/indirección son las mismas del pool → el shader lo direcciona
/// igual. Todo `[0,0]` (caso común) = retrocompat byte a byte.
dpool: wgpu::Texture,
indir: wgpu::Texture,
bind_group: wgpu::BindGroup,
/// Layout del bind group, guardado para re-armar el bind group cuando el pool
@@ -148,6 +156,47 @@ pub struct VoxelRenderer {
/// posición, acotado a la distancia a la luz). `true` por defecto. Apagarlo
/// recupera el MVP plano (más barato) — útil para comparar off/on.
pub point_shadows: bool,
/// **Rugosidad sub-voxel** (bump mapping procedural). Perturba la normal de
/// sombreado del voxel golpeado con el gradiente tangencial de un ruido de
/// valor 3D → la superficie deja de leerse rectangular a corta distancia
/// (roca/tierra granulada) sin tocar geometría ni memoria. `0.0` = off
/// (comportamiento clásico, cara plana axial). La perturbación se atenúa
/// sola en voxels lustrosos (vidrio/metal, `alpha` bajo) → sólo lo mate se
/// rugosiza. Rango útil ≈ `0.0..1.5`.
pub rugosidad_amp: f32,
/// Frecuencia del ruido de rugosidad (ciclos por voxel). Más alto = grano
/// más fino/apretado. Default `1.6`. Sólo importa si `rugosidad_amp > 0`.
pub rugosidad_freq: f32,
/// **Bisel / suavizado sub-voxel** (pendientes diagonales). Refina el hit del
/// ray-march contra la **isosuperficie trilineal de la ocupación** (derivada de
/// la forma, sin storage extra): el terreno escalonado se lee como rampa
/// diagonal y las esquinas convexas quedan biseladas, sin dejar de ser "bloque
/// tallado". `0.0` = off (voxel duro clásico, byte-idéntico); `1.0` = superficie
/// suave plena; intermedio = bisel parcial. Las zonas planas quedan planas (la
/// isosuperficie sólo se aparta de la cara en bordes/esquinas). Cuesta ~varias
/// muestras del campo por píxel golpeado → sólo se paga con `bisel > 0`.
pub bisel: f32,
/// Modo diagnóstico: pinta la **normal** de sombreado como color (`n·0.5+0.5`)
/// en vez del sombreado. Lo usa la certificación (`example bisel_demo`) para
/// medir qué fracción de la superficie dejó de ser axial. `false` en producción.
pub debug_normals: bool,
/// **Silueta sub-voxel POR MATERIAL** (`false` = off, retrocompat byte-idéntico).
/// Con `bisel` global prendido, `trace_smooth` traza la isosuperficie GLOBAL: la
/// silueta sale igual para todos (el `bisel` por voxel sólo cambiaba la NORMAL de
/// sombreado, no el contorno). Con este flag, el CAMPO mismo mezcla escalón↔trilineal
/// según el `bisel` POR VOXEL del material sólido cercano → un material duro (bisel
/// bajo) conserva la silueta axial/blocky mientras otro liso (bisel alto) la redondea,
/// en el MISMO frame. Cuesta: samplea el detalle de las 8 esquinas por evaluación del
/// campo → sólo pagalo cuando de veras quieres siluetas distintas por material. Sólo
/// tiene efecto con `bisel > 0` (que enciende `trace_smooth`).
pub silueta_por_voxel: bool,
/// **Cantidad de oclusión ambiental** `[0,1]` (`1.0` = clásica, retrocompat
/// byte-idéntico; `0.0` = sin AO). La AO se computa **axialmente** (`axis_of` de la
/// normal indexa voxels enteros); sobre una superficie REDONDEADA (bisel) el eje
/// dominante salta a lo largo de la esfera y la AO pinta **anillos concéntricos** —
/// correcto para bloques, artefacto para un ser orgánico. Bajala (≈0,20,4) al rendir
/// seres/blobs voxel suaves para que el cuerpo lea liso. Viaja en `n_lights.w`.
pub ao_amount: f32,
}
impl VoxelRenderer {
@@ -194,6 +243,22 @@ impl VoxelRenderer {
});
let pool_view = pool.create_view(&wgpu::TextureViewDescriptor::default());
// Atlas de detalle sub-voxel, espejo del pool (mismos slots). Rg8Uint = dos
// bytes por voxel: `[rugosidad, bisel]` (con offset +1; `[0,0]` = sin autor).
let dpool = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-3d-voxel-dpool"),
size: extent([ax * BRICK, ay * BRICK, az * BRICK]),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D3,
format: wgpu::TextureFormat::Rg8Uint,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let dpool_view = dpool.create_view(&wgpu::TextureViewDescriptor::default());
let indir = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-3d-voxel-indir"),
size: extent(cdim),
@@ -236,6 +301,17 @@ impl VoxelRenderer {
},
uniform_entry(2),
uniform_entry(3),
// Atlas de detalle sub-voxel (Rg8Uint, direccionado como el pool).
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Uint,
view_dimension: wgpu::TextureViewDimension::D3,
multisampled: false,
},
count: None,
},
],
});
@@ -275,6 +351,10 @@ impl VoxelRenderer {
binding: 3,
resource: ubuf_ent.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::TextureView(&dpool_view),
},
],
});
@@ -328,6 +408,7 @@ impl VoxelRenderer {
let mut r = Self {
pool,
dpool,
indir,
bind_group,
bgl,
@@ -346,6 +427,12 @@ impl VoxelRenderer {
entities: Vec::new(),
lights: Vec::new(),
point_shadows: true,
rugosidad_amp: 0.0,
rugosidad_freq: 1.6,
bisel: 0.0,
debug_normals: false,
silueta_por_voxel: false,
ao_amount: 1.0,
};
// Poblar el pool: cada brick ocupado toma un slot incremental.
@@ -359,6 +446,7 @@ impl VoxelRenderer {
let idx = r.cell_idx(cx, cy, cz);
r.slots[idx] = slot + 1;
r.upload_brick(queue, slot, grid, cx, cy, cz);
r.upload_brick_detail(queue, slot, grid, cx, cy, cz);
}
}
}
@@ -422,6 +510,22 @@ impl VoxelRenderer {
);
}
/// Sube el brick de **detalle sub-voxel** al `dpool` (espeja `upload_brick`,
/// mismo slot/origen, 2 bytes/voxel). Se llama junto a `upload_brick` al armar
/// el pool y al hornear bricks nuevos.
fn upload_brick_detail(&self, queue: &wgpu::Queue, slot: u32, grid: &VoxelGrid, cx: u32, cy: u32, cz: u32) {
let data = grid.extract_brick_detail(BRICK, cx, cy, cz);
let o = self.slot_origin(slot);
write_3d(
queue,
&self.dpool,
[o[0] * BRICK, o[1] * BRICK, o[2] * BRICK],
[BRICK, BRICK, BRICK],
2,
&data,
);
}
fn upload_indirection_full(&self, queue: &wgpu::Queue) {
let mut bytes = Vec::with_capacity(self.slots.len() * 4);
for &s in &self.slots {
@@ -631,30 +735,49 @@ impl VoxelRenderer {
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
// El dpool crece en paralelo (mismos slots): la franja nueva queda en `[0,0]`
// (sin autor → look global), que es lo que corresponde al contenido streameado.
let new_dpool = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-3d-voxel-dpool"),
size: extent([ax * BRICK, ay * BRICK, new_az * BRICK]),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D3,
format: wgpu::TextureFormat::Rg8Uint,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
// Copia el atlas viejo (mismas dimensiones x/y, az capas) al nuevo.
// Copia ambos atlas viejos (mismas dimensiones x/y, az capas) al nuevo.
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("llimphi-3d-voxel-pool-grow"),
});
enc.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: &self.pool,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyTextureInfo {
texture: &new_pool,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
extent([ax * BRICK, ay * BRICK, az * BRICK]),
);
let copy = |enc: &mut wgpu::CommandEncoder, src: &wgpu::Texture, dst: &wgpu::Texture| {
enc.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: src,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyTextureInfo {
texture: dst,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
extent([ax * BRICK, ay * BRICK, az * BRICK]),
);
};
copy(&mut enc, &self.pool, &new_pool);
copy(&mut enc, &self.dpool, &new_dpool);
queue.submit(std::iter::once(enc.finish()));
// Re-armar el bind group con la view del pool nuevo (indir/ubufs intactos).
// Re-armar el bind group con las views nuevas (indir/ubufs intactos).
let pool_view = new_pool.create_view(&wgpu::TextureViewDescriptor::default());
let dpool_view = new_dpool.create_view(&wgpu::TextureViewDescriptor::default());
let indir_view = self.indir.create_view(&wgpu::TextureViewDescriptor::default());
self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-3d-voxel-bg"),
@@ -664,12 +787,14 @@ impl VoxelRenderer {
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&indir_view) },
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) },
],
});
let old_cap = ax * ay * az;
let new_cap = ax * ay * new_az;
self.pool = new_pool;
self.dpool = new_dpool;
self.atlas[2] = new_az;
self.free.extend((old_cap..new_cap).rev());
}
@@ -695,10 +820,22 @@ impl VoxelRenderer {
for v in [s[0], s[1], s[2], self.atmosphere.god_rays.max(0.0)] {
u.extend_from_slice(&v.to_ne_bytes());
}
for v in [self.cdim[0] as f32, self.cdim[1] as f32, self.cdim[2] as f32, 0.0] {
// cdim.w piggyback = amplitud de rugosidad sub-voxel (0 = off); atlas.w = su
// frecuencia. Reusan lanes que estaban en 0 → retrocompatible.
for v in [
self.cdim[0] as f32,
self.cdim[1] as f32,
self.cdim[2] as f32,
self.rugosidad_amp.max(0.0),
] {
u.extend_from_slice(&v.to_ne_bytes());
}
for v in [self.atlas[0] as f32, self.atlas[1] as f32, self.atlas[2] as f32, 0.0] {
for v in [
self.atlas[0] as f32,
self.atlas[1] as f32,
self.atlas[2] as f32,
self.rugosidad_freq.max(0.0),
] {
u.extend_from_slice(&v.to_ne_bytes());
}
let a = &self.atmosphere;
@@ -710,11 +847,12 @@ impl VoxelRenderer {
] {
u.extend_from_slice(&v.to_ne_bytes());
}
// sky_horizon.w piggyback = bisel/suavizado sub-voxel (0 = off, retrocompat).
for v in [
a.sky_horizon[0] as f32 / 255.0,
a.sky_horizon[1] as f32 / 255.0,
a.sky_horizon[2] as f32 / 255.0,
0.0,
self.bisel.clamp(0.0, 1.0),
] {
u.extend_from_slice(&v.to_ne_bytes());
}
@@ -726,18 +864,23 @@ impl VoxelRenderer {
// Se sube YA REDUCIDO a `[0, cdim)` (floormod) para que el `%` del shader
// nunca opere sobre un negativo — el `%` de WGSL sobre enteros con signo es
// ambiguo entre plataformas y rompía el wrap con orígenes negativos.
// scroll.w piggyback = flag de diagnóstico de normales (0 = sombreado normal).
for v in [
floormod(self.brick_origin[0], self.cdim[0] as i32) as f32,
floormod(self.brick_origin[1], self.cdim[1] as i32) as f32,
floormod(self.brick_origin[2], self.cdim[2] as i32) as f32,
0.0,
if self.debug_normals { 1.0 } else { 0.0 },
] {
u.extend_from_slice(&v.to_ne_bytes());
}
// Luces puntuales: count (vec4) + MAX_LIGHTS × [pos+range, color].
let nl = self.lights.len().min(MAX_LIGHTS);
let shadow_flag = if self.point_shadows { 1.0 } else { 0.0 };
for v in [nl as f32, shadow_flag, 0.0, 0.0] {
// n_lights.z piggyback = flag de silueta sub-voxel por material (0 = off,
// retrocompat: el campo queda trilineal puro).
let silueta_flag = if self.silueta_por_voxel { 1.0 } else { 0.0 };
// n_lights.w piggyback = cantidad de AO [0,1] (1 = clásico byte-idéntico).
for v in [nl as f32, shadow_flag, silueta_flag, self.ao_amount.clamp(0.0, 1.0)] {
u.extend_from_slice(&v.to_ne_bytes());
}
for i in 0..MAX_LIGHTS {
@@ -915,18 +1058,26 @@ struct U {
sky_horizon: vec4<f32>,// xyz = color horizonte / hacia el que niebla desvanece
vp: mat4x4<f32>, // world→clip (forward) para escribir frag_depth
scroll: vec4<f32>, // xyz = origen de brick (streaming toroidal); 0 = sin scroll
n_lights: vec4<f32>, // x = cantidad de luces puntuales, y = sombras on/off
n_lights: vec4<f32>, // x = luces puntuales, y = sombras on/off, z = silueta sub-voxel por material, w = cantidad de AO [0,1]
lights: array<vec4<f32>, 8>, // por luz: [pos.xyz, range], [color.rgb, radio_area]
};
// Disco de muestreo para sombras blandas (penumbra): 8 taps en un patrón anular
// fijo (determinista, sin RNG por píxel — evita ruido temporal en el reel).
const SOFT_TAPS: i32 = 8;
const SOFT_DISK = array<vec2<f32>, 8>(
vec2<f32>( 0.35, 0.0), vec2<f32>(-0.35, 0.0),
vec2<f32>( 0.0, 0.35), vec2<f32>( 0.0, -0.35),
vec2<f32>( 0.7, 0.7), vec2<f32>(-0.7, 0.7),
vec2<f32>( 0.7, -0.7), vec2<f32>(-0.7, -0.7),
// Disco de muestreo para la penumbra: 16 taps en dos anillos (Poisson-ish) →
// borde de sombra suave sin bandeo. Más taps = penumbra más limpia (a más costo).
const SOFT_TAPS: i32 = 16;
const SOFT_DISK = array<vec2<f32>, 16>(
// anillo interno (r≈0.4)
vec2<f32>( 0.40, 0.00), vec2<f32>( 0.28, 0.28),
vec2<f32>( 0.00, 0.40), vec2<f32>(-0.28, 0.28),
vec2<f32>(-0.40, 0.00), vec2<f32>(-0.28, -0.28),
vec2<f32>( 0.00, -0.40), vec2<f32>( 0.28, -0.28),
// anillo externo (r≈0.85, rotado 22.5°)
vec2<f32>( 0.785, 0.325), vec2<f32>( 0.325, 0.785),
vec2<f32>(-0.325, 0.785), vec2<f32>(-0.785, 0.325),
vec2<f32>(-0.785, -0.325), vec2<f32>(-0.325, -0.785),
vec2<f32>( 0.325, -0.785), vec2<f32>( 0.785, -0.325),
);
struct Entity {
pos: vec4<f32>,
@@ -941,6 +1092,9 @@ struct EntU {
@group(0) @binding(1) var indir: texture_3d<u32>;
@group(0) @binding(2) var<uniform> u: U;
@group(0) @binding(3) var<uniform> ent: EntU;
// Detalle sub-voxel por voxel (Rg8Uint = [rugosidad, bisel] con offset +1; [0,0] =
// sin autor → look global). Direccionado como el pool (mismos slots/indirección).
@group(0) @binding(4) var dpool: texture_3d<u32>;
struct VOut {
@builtin(position) clip: vec4<f32>,
@@ -997,6 +1151,34 @@ fn voxel_at(voxel: vec3<f32>) -> vec4<f32> {
return textureLoad(pool, acell * bu + local, 0);
}
// Detalle sub-voxel crudo del voxel `voxel` = `[rug_byte, bisel_byte]` (offset +1;
// `[0,0]` = sin autor). Direccionamiento idéntico a `voxel_at`, sobre el `dpool`.
fn detail_raw(voxel: vec3<f32>) -> vec2<u32> {
let vi = vec3<i32>(voxel);
if (any(vi < vec3<i32>(0)) || any(vec3<f32>(vi) >= u.grid_dim.xyz)) { return vec2<u32>(0u); }
let bu = i32(u.grid_dim.w);
let cc = vi / bu;
let s = slot_at(cc);
if (s == 0u) { return vec2<u32>(0u); }
let slot = i32(s - 1u);
let ax = i32(u.atlas.x);
let ay = i32(u.atlas.y);
let acell = vec3<i32>(slot % ax, (slot / ax) % ay, slot / (ax * ay));
let local = vi - cc * bu;
return textureLoad(dpool, acell * bu + local, 0).xy;
}
// Detalle DECODIFICADO = `(has, rug_amp, bisel)`. `has` = 1 si el voxel tiene detalle
// autorado (sobrescribe el look global), 0 = usar el global. `rug_amp` en `[0,1.5]`,
// `bisel` en `[0,1]` (espeja el offset +1 de CPU: `(byte-1)/254`).
fn pv_detail(voxel: vec3<f32>) -> vec3<f32> {
let d = detail_raw(voxel);
if ((d.x | d.y) == 0u) { return vec3<f32>(0.0, 0.0, 0.0); }
let rug = (f32(d.x) - 1.0) / 254.0 * 1.5;
let bis = (f32(d.y) - 1.0) / 254.0;
return vec3<f32>(1.0, max(rug, 0.0), clamp(bis, 0.0, 1.0));
}
struct Hit {
hit: bool,
vox: vec3<f32>,
@@ -1006,7 +1188,10 @@ struct Hit {
// DDA de dos niveles sobre el brick pool: marcha la grilla gruesa (indirección),
// baja a la fina sólo en bricks con slot.
fn trace(ro: vec3<f32>, rd_in: vec3<f32>, dim: vec3<f32>, B: f32) -> Hit {
// DDA por el grid. `pass_glass` = los rayos de SOMBRA atraviesan el vidrio (voxels
// muy lustrosos, alpha < GLASS_A) para que las ventanas dejen entrar la luz; los
// rayos primarios (pass_glass=false) sí lo dibujan.
fn trace(ro: vec3<f32>, rd_in: vec3<f32>, dim: vec3<f32>, B: f32, pass_glass: bool) -> Hit {
var h: Hit;
h.hit = false;
@@ -1050,7 +1235,10 @@ fn trace(ro: vec3<f32>, rd_in: vec3<f32>, dim: vec3<f32>, B: f32) -> Hit {
if (any(voxel < vec3<f32>(0.0)) || any(voxel >= dim)) { return h; }
if (any(floor(voxel / B) != cc)) { break; }
let c = voxel_at(voxel);
if (c.a > 0.5) {
// Vidrio (muy lustroso): transparente a los rayos de sombra → la
// ventana no proyecta sombra y deja pasar la luz del día.
let es_vidrio = c.a < 0.62;
if (c.a > 0.5 && !(pass_glass && es_vidrio)) {
h.hit = true;
h.vox = voxel;
h.normal = fnorm;
@@ -1187,6 +1375,275 @@ fn frag_depth(p: vec3<f32>, dim: vec3<f32>) -> f32 {
return clip.z / clip.w;
}
// --- Rugosidad sub-voxel (bump procedural) ---------------------------------
// Hash 3D determinista → [0,1) (estilo iq). Sin RNG por-píxel: estable en el reel.
fn hash13(p3: vec3<f32>) -> f32 {
var p = fract(p3 * 0.1031);
p = p + dot(p, p.zyx + vec3<f32>(31.32));
return fract((p.x + p.y) * p.z);
}
// Ruido de valor 3D (trilineal sobre hashes de lattice, con suavizado smoothstep).
fn vnoise(x: vec3<f32>) -> f32 {
let i = floor(x);
let f = fract(x);
let w = f * f * (3.0 - 2.0 * f);
let n000 = hash13(i + vec3<f32>(0.0, 0.0, 0.0));
let n100 = hash13(i + vec3<f32>(1.0, 0.0, 0.0));
let n010 = hash13(i + vec3<f32>(0.0, 1.0, 0.0));
let n110 = hash13(i + vec3<f32>(1.0, 1.0, 0.0));
let n001 = hash13(i + vec3<f32>(0.0, 0.0, 1.0));
let n101 = hash13(i + vec3<f32>(1.0, 0.0, 1.0));
let n011 = hash13(i + vec3<f32>(0.0, 1.0, 1.0));
let n111 = hash13(i + vec3<f32>(1.0, 1.0, 1.0));
let x00 = mix(n000, n100, w.x);
let x10 = mix(n010, n110, w.x);
let x01 = mix(n001, n101, w.x);
let x11 = mix(n011, n111, w.x);
return mix(mix(x00, x10, w.y), mix(x01, x11, w.y), w.z);
}
// Inclina la normal geométrica `geo_n` por el gradiente TANGENCIAL de una altura
// de ruido en `p` (espacio de voxel). `amp` = fuerza, `freq` = grano. Proyecta el
// gradiente al plano tangente → la normal se queda en el hemisferio exterior (sin
// auto-sombra) para amplitudes moderadas. Diferencias hacia-adelante (4 taps).
fn perturb_normal(geo_n: vec3<f32>, p: vec3<f32>, freq: f32, amp: f32) -> vec3<f32> {
let sp = p * freq;
let e = 0.5;
let h0 = vnoise(sp);
let grad = vec3<f32>(
vnoise(sp + vec3<f32>(e, 0.0, 0.0)) - h0,
vnoise(sp + vec3<f32>(0.0, e, 0.0)) - h0,
vnoise(sp + vec3<f32>(0.0, 0.0, e)) - h0,
);
let tang = grad - geo_n * dot(grad, geo_n); // sólo la componente tangente
return normalize(geo_n - tang * amp);
}
// --- Bisel / suavizado sub-voxel (pendientes diagonales) -------------------
// Ocupación de la celda que contiene `v` (1 sólido / 0 vacío). Fuera de grilla → 0.
fn occ_f(v: vec3<f32>) -> f32 {
return select(0.0, 1.0, voxel_at(v).a > 0.5);
}
// Campo escalar TRILINEAL = ocupación sobre los 8 centros de celda vecinos. Su
// isosuperficie (`field = 0.5`) es una superficie suave: plana donde la forma es
// plana, diagonal donde escalona, redondeada en las esquinas → el "voxel suave".
fn field_trilinear(p: vec3<f32>) -> f32 {
let g = p - vec3<f32>(0.5);
let i = floor(g);
let f = fract(g);
let c000 = occ_f(i + vec3<f32>(0.0, 0.0, 0.0));
let c100 = occ_f(i + vec3<f32>(1.0, 0.0, 0.0));
let c010 = occ_f(i + vec3<f32>(0.0, 1.0, 0.0));
let c110 = occ_f(i + vec3<f32>(1.0, 1.0, 0.0));
let c001 = occ_f(i + vec3<f32>(0.0, 0.0, 1.0));
let c101 = occ_f(i + vec3<f32>(1.0, 0.0, 1.0));
let c011 = occ_f(i + vec3<f32>(0.0, 1.0, 1.0));
let c111 = occ_f(i + vec3<f32>(1.0, 1.0, 1.0));
let x00 = mix(c000, c100, f.x);
let x10 = mix(c010, c110, f.x);
let x01 = mix(c001, c101, f.x);
let x11 = mix(c011, c111, f.x);
return mix(mix(x00, x10, f.y), mix(x01, x11, f.y), f.z);
}
// **Suavidad local del material** en `p` para la silueta POR VOXEL: media, ponderada
// por peso trilineal Y ocupación, del `bisel` autorado de las 8 celdas vecinas SÓLIDAS
// (celda sin autor = 1.0 = suave). Un material duro (bisel bajo) tira su vecindario a
// 0 → el campo se vuelve escalón (silueta axial); uno liso lo deja trilineal (silueta
// redondeada). El ratio num/den ignora la magnitud del peso → aun en la celda vacía
// diagonal de una esquina convexa, si la única esquina sólida es dura, la suavidad ahí
// es baja y la esquina se conserva afilada. Sólo se llama con el flag prendido.
fn smoothness_local(p: vec3<f32>) -> f32 {
let g = p - vec3<f32>(0.5);
let i = floor(g);
let f = fract(g);
var num = 0.0;
var den = 0.0;
for (var dx = 0; dx < 2; dx = dx + 1) {
for (var dy = 0; dy < 2; dy = dy + 1) {
for (var dz = 0; dz < 2; dz = dz + 1) {
let corner = i + vec3<f32>(f32(dx), f32(dy), f32(dz));
if (occ_f(corner) > 0.5) {
let wx = select(1.0 - f.x, f.x, dx == 1);
let wy = select(1.0 - f.y, f.y, dy == 1);
let wz = select(1.0 - f.z, f.z, dz == 1);
let w = wx * wy * wz;
let pv = pv_detail(corner);
let s = select(1.0, pv.z, pv.x > 0.5); // sin autor = suave
num = num + w * s;
den = den + w;
}
}
}
}
if (den < 1e-5) { return 1.0; }
return num / den;
}
// Campo escalar del ray-march suave. Por defecto = trilineal (voxel suave). Con la
// silueta POR VOXEL prendida (`n_lights.z`), mezcla escalón↔trilineal según la suavidad
// del material sólido cercano: `mix(occ_step, trilineal, s)` — `s=1` deja el cruce en la
// isosuperficie (redondeado), `s=0` lo empuja a la cara del voxel (silueta dura axial),
// porque el escalón `occ_f` salta 0→1 justo ahí. Así dos materiales comparten un frame
// con siluetas distintas. Fast path byte-idéntico cuando el flag está off o todo suave.
fn field(p: vec3<f32>) -> f32 {
let suave = field_trilinear(p);
if (u.n_lights.z < 0.5) { return suave; }
let s = smoothness_local(p);
if (s >= 0.999) { return suave; }
let hard = occ_f(p); // escalón 0/1: su iso 0.5 cae en la cara del voxel (axial)
return mix(hard, suave, s);
}
// Normal exterior de la isosuperficie en `p` = opuesta del gradiente del campo
// (que apunta hacia el sólido). Diferencias hacia-adelante. Gradiente nulo (plano
// saturado) → arriba por defecto. **Usa el campo TRILINEAL** (no el mixto): con la
// silueta por voxel, el campo duro es un escalón `occ_f` cuyo gradiente por diferencias
// es degenerado (salta 0/1) y haría titilar `axis_of` en las caras planas. La geometría
// (silueta) sale del campo mixto; el NORMAL del trilineal → `axis_of(sn)` elige la cara
// axial limpia para el material duro, y `sn` queda redondeado para el liso.
fn smooth_normal(p: vec3<f32>) -> vec3<f32> {
// Gradiente de campo DIFUMINADO por diferencias centrales ANCHAS. El trilineal
// binario con una diferencia hacia-adelante corta (e=0.5) sólo toma direcciones
// cuantizadas por el patrón de ocupación 2×2×2 local → la normal salta en
// escalones a lo largo de una superficie curva grande → **anillos de cebolla**
// en el sombreado (el artefacto que hacía "monstruo" a un ser redondo). Promediar
// el gradiente sobre un radio mayor (diferencias centrales a dos radios, 0.9 y 1.8)
// integra suficiente vecindario para que la normal varíe continua → esfera lisa.
// Sólo se llama desde `trace_smooth` (bisel>0) → el terreno duro (bisel=0, `trace`)
// queda byte-idéntico. Radios < que el semieje de un miembro fino (≈6 vox) para no
// cancelar el gradiente cruzando la pieza.
let e0 = 1.0;
let e1 = 2.2;
let gx =
(field_trilinear(p + vec3<f32>(e0, 0.0, 0.0)) - field_trilinear(p - vec3<f32>(e0, 0.0, 0.0)))
+ (field_trilinear(p + vec3<f32>(e1, 0.0, 0.0)) - field_trilinear(p - vec3<f32>(e1, 0.0, 0.0)));
let gy =
(field_trilinear(p + vec3<f32>(0.0, e0, 0.0)) - field_trilinear(p - vec3<f32>(0.0, e0, 0.0)))
+ (field_trilinear(p + vec3<f32>(0.0, e1, 0.0)) - field_trilinear(p - vec3<f32>(0.0, e1, 0.0)));
let gz =
(field_trilinear(p + vec3<f32>(0.0, 0.0, e0)) - field_trilinear(p - vec3<f32>(0.0, 0.0, e0)))
+ (field_trilinear(p + vec3<f32>(0.0, 0.0, e1)) - field_trilinear(p - vec3<f32>(0.0, 0.0, e1)));
let g = vec3<f32>(gx, gy, gz);
let gl = length(g);
if (gl < 1e-5) { return vec3<f32>(0.0, 1.0, 0.0); }
return -g / gl;
}
// Eje dominante de una normal (para AO/facetado): la cara axial más cercana.
fn axis_of(n: vec3<f32>) -> vec3<f32> {
let a = abs(n);
if (a.x >= a.y && a.x >= a.z) { return vec3<f32>(sign(n.x), 0.0, 0.0); }
if (a.y >= a.z) { return vec3<f32>(0.0, sign(n.y), 0.0); }
return vec3<f32>(0.0, 0.0, sign(n.z));
}
// **Capa 2b — traza la ISOSUPERFICIE suave del campo** (no el voxel duro): el hit,
// la silueta Y la normal salen del cruce `field = iso`. Mantiene el DDA GRUESO para
// saltar bricks vacíos (aceleración), pero dentro de un brick con contenido marcha el
// campo a sub-pasos y biseca el primer cruce de fuera(<iso)→dentro(≥iso). `bisel`
// mezcla la normal hacia su eje dominante (facetado↔suave); la geometría siempre es
// la superficie suave cuando se llama. Sólo la rimaria (las sombras usan `trace`).
fn trace_smooth(ro: vec3<f32>, rd_in: vec3<f32>, dim: vec3<f32>, B: f32, bisel: f32) -> Hit {
var h: Hit;
h.hit = false;
let safe_rd = vec3<f32>(
select(rd_in.x, 1e-6, abs(rd_in.x) < 1e-6),
select(rd_in.y, 1e-6, abs(rd_in.y) < 1e-6),
select(rd_in.z, 1e-6, abs(rd_in.z) < 1e-6),
);
let inv_rd = 1.0 / safe_rd;
let step = sign(safe_rd);
let tb = ray_box(ro, inv_rd, vec3<f32>(0.0), dim);
if (tb.x > tb.y || tb.y < 0.0) { return h; }
let t_enter = max(tb.x, 0.0);
let iso = 0.5;
let cdim = ceil(dim / B);
let p_enter = ro + safe_rd * t_enter;
var cc = clamp(floor(p_enter / B), vec3<f32>(0.0), cdim - 1.0);
let t_delta_c = abs(B * inv_rd);
var t_max_c = ((cc + max(step, vec3<f32>(0.0))) * B - ro) * inv_rd;
var t_cell = t_enter;
let dt = 0.5;
var f_prev = field(ro + safe_rd * t_enter) - iso;
let max_coarse = i32(cdim.x + cdim.y + cdim.z) + 3;
for (var ci = 0; ci < max_coarse; ci = ci + 1) {
if (slot_at(vec3<i32>(cc)) != 0u) {
let t_exit = min(min(t_max_c.x, t_max_c.y), t_max_c.z);
var t = t_cell;
// Cota de sub-pasos: un brick en diagonal mide hasta B·√3 de recorrido;
// a dt=0.5 hacen falta ~3.5·B pasos para cruzarlo (menos → pinholes en
// rayos diagonales, que se saltan el cruce en la parte lejana del brick).
let max_sub = i32(B) * 4 + 4;
for (var si = 0; si < max_sub; si = si + 1) {
let t_next = min(t + dt, t_exit);
let f_next = field(ro + safe_rd * t_next) - iso;
if (f_prev < 0.0 && f_next >= 0.0) {
// Bisección del cruce en [t, t_next].
var a = t;
var b = t_next;
var fa = field(ro + safe_rd * a) - iso;
for (var k = 0; k < 6; k = k + 1) {
let tm = 0.5 * (a + b);
let fm = field(ro + safe_rd * tm) - iso;
if (fa * fm <= 0.0) { b = tm; } else { a = tm; fa = fm; }
}
let th = 0.5 * (a + b);
let ph = ro + safe_rd * th;
let sn = smooth_normal(ph);
// Color/brillo: la celda SÓLIDA más cercana entre las 8 esquinas del
// punto (en la isosuperficie hay ≥1 sólida). Muestrear la celda por
// proximidad al punto de hit y no por `floor(ph - n·k)` evita las
// motas negras (una celda con ocupación 0 aunque el campo ahí sea >½).
let bc = floor(ph - vec3<f32>(0.5));
var best_c = clamp(floor(ph - sn * 0.5), vec3<f32>(0.0), dim - 1.0);
var best_d = 1e9;
for (var dx = 0; dx < 2; dx = dx + 1) {
for (var dy = 0; dy < 2; dy = dy + 1) {
for (var dz = 0; dz < 2; dz = dz + 1) {
let cell = bc + vec3<f32>(f32(dx), f32(dy), f32(dz));
if (voxel_at(cell).a > 0.5) {
let d = distance(cell + vec3<f32>(0.5), ph);
if (d < best_d) { best_d = d; best_c = cell; }
}
}
}
}
// Bisel por voxel: si la celda golpeada trae detalle autorado, su
// bisel manda la mezcla facetado↔suave de la normal (dos materiales
// en la misma escena mezclan distinto); si no, el bisel global. La
// geometría/silueta ya salió de la isosuperficie global (barato).
let vox = clamp(best_c, vec3<f32>(0.0), dim - 1.0);
let pv = pv_detail(vox);
let bmix = select(bisel, pv.z, pv.x > 0.5);
h.hit = true;
h.t = th;
h.vox = vox;
h.normal = normalize(mix(axis_of(sn), sn, bmix));
return h;
}
f_prev = f_next;
t = t_next;
if (t >= t_exit) { break; }
}
}
if (t_max_c.x < t_max_c.y && t_max_c.x < t_max_c.z) {
cc.x = cc.x + step.x;
t_cell = t_max_c.x;
t_max_c.x = t_max_c.x + t_delta_c.x;
} else if (t_max_c.y < t_max_c.z) {
cc.y = cc.y + step.y;
t_cell = t_max_c.y;
t_max_c.y = t_max_c.y + t_delta_c.y;
} else {
cc.z = cc.z + step.z;
t_cell = t_max_c.z;
t_max_c.z = t_max_c.z + t_delta_c.z;
}
if (any(cc < vec3<f32>(0.0)) || any(cc >= cdim)) { return h; }
}
return h;
}
struct FOut {
@location(0) color: vec4<f32>,
@builtin(frag_depth) depth: f32,
@@ -1219,7 +1676,7 @@ fn god_rays(ro: vec3<f32>, rd: vec3<f32>, max_t: f32, dim: vec3<f32>, B: f32) ->
let tt = (f32(i) + jitter) * dt;
if (tt >= max_t) { break; }
let sp = ro + rd * tt;
let sh = trace(sp, u.sun_dir.xyz, dim, B);
let sh = trace(sp, u.sun_dir.xyz, dim, B, true);
acc = acc + select(1.0, 0.0, sh.hit); // 1 si el sol llega, 0 si está en sombra
}
acc = acc / f32(steps);
@@ -1238,7 +1695,16 @@ fn fs(in: VOut) -> FOut {
let B = u.grid_dim.w;
let ro = ro_world + dim * 0.5;
let h = trace(ro, rd, dim, B);
// Bisel/suavizado sub-voxel: con `bisel > 0` la primaria TRAZA la isosuperficie
// suave del campo (silueta + normal diagonales, no sólo sombreado); `bisel = 0` =
// hit duro clásico byte-idéntico. Las sombras siempre usan el trace duro (barato).
let bisel = u.sky_horizon.w;
var h: Hit;
if (bisel > 0.0) {
h = trace_smooth(ro, rd, dim, B, bisel);
} else {
h = trace(ro, rd, dim, B, false);
}
let t_vox = select(1e30, h.t, h.hit);
let eh = trace_entities(ro, rd, t_vox);
@@ -1249,6 +1715,9 @@ fn fs(in: VOut) -> FOut {
var p: vec3<f32>;
var ao: f32;
var t_hit: f32;
// 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) {
albedo = eh.color;
normal = eh.normal;
@@ -1256,11 +1725,27 @@ fn fs(in: VOut) -> FOut {
ao = 1.0;
t_hit = eh.t;
} else if (h.hit) {
albedo = voxel_at(h.vox).rgb;
let vtex = voxel_at(h.vox);
albedo = vtex.rgb;
shine_a = vtex.a;
// `h.normal` ya es la normal (suave si bisel>0, axial si no). La AO se calcula
// con el eje dominante (indexa voxels enteros); para el hit duro es idéntica.
normal = h.normal;
p = ro + rd * h.t;
ao = compute_ao(h.vox, h.normal, p, dim);
ao = compute_ao(h.vox, axis_of(h.normal), p, dim);
t_hit = h.t;
// Rugosidad sub-voxel: inclina la normal de SOMBREADO (ya biselada si aplica)
// por bump procedural. Sólo lo mate (roca/tierra) se rugosiza; el vidrio/metal
// lustroso (alpha bajo) queda liso. Si el voxel golpeado trae detalle autorado,
// su rugosidad manda; si no, la amplitud global (`u.cdim.w`).
let pvd = pv_detail(h.vox);
let rug_amp = select(u.cdim.w, pvd.y, pvd.x > 0.5);
if (rug_amp > 0.0) {
let matte = clamp((shine_a - 0.62) / 0.38, 0.0, 1.0);
if (matte > 0.0) {
normal = perturb_normal(normal, p, max(u.atlas.w, 1e-3), rug_amp * matte);
}
}
} else {
// Sin impacto: con niebla activa pintamos cielo propio (a profundidad
// lejana, así una malla por delante igual se dibuja); sin niebla,
@@ -1280,8 +1765,12 @@ fn fs(in: VOut) -> FOut {
let ldir = u.sun_dir.xyz;
let diff = max(dot(normal, ldir), 0.0);
let so = p + normal * 0.5 + ldir * 0.01;
let sh_v = trace(so, ldir, dim, B);
// 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);
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);
@@ -1289,7 +1778,10 @@ fn fs(in: VOut) -> FOut {
// sale de su elevación (cálido al ras del horizonte → blanco en lo alto) y el
// ambiente del color del cielo (rebote frío del cenit). El mood se controla
// moviendo `sun_dir` y la paleta de cielo, que ya viajan en el uniform.
let ao_term = 0.35 + 0.65 * ao;
// AO escalada por `n_lights.w` (1 = clásica `0.35 + 0.65*ao` byte-idéntica; 0 = sin AO).
// La AO axial pinta anillos sobre superficies redondeadas (bisel) → bajarla para seres
// orgánicos, subirla para bloques.
let ao_term = 1.0 - u.n_lights.w * 0.65 * (1.0 - ao);
let sun_h = clamp(u.sun_dir.y, 0.0, 1.0);
let sun_col = mix(vec3<f32>(1.0, 0.52, 0.24), vec3<f32>(1.0, 0.97, 0.9), sun_h);
// Ambiente tintado por el cielo pero con ~la misma luminancia que el flat 0.32
@@ -1321,7 +1813,7 @@ fn fs(in: VOut) -> FOut {
let lrad = lc.w; // radio de área (penumbra); 0 = sombra dura.
if (lrad <= 0.0) {
// Sombra dura: un solo shadow ray hacia el centro de la luz.
let hv = trace(lso, ldir2, dim, B);
let hv = trace(lso, ldir2, dim, B, true);
let blocked_v = hv.hit && hv.t < d - bias;
let he = trace_entities(lso, ldir2, d - bias);
vis = select(1.0, 0.0, blocked_v || he.hit);
@@ -1341,7 +1833,7 @@ fn fs(in: VOut) -> FOut {
let to2 = lp2 - p;
let d2 = length(to2);
let dir2 = to2 / max(d2, 1e-3);
let hv = trace(lso, dir2, dim, B);
let hv = trace(lso, dir2, dim, B, true);
let bv = hv.hit && hv.t < d2 - bias;
let he = trace_entities(lso, dir2, d2 - bias);
if (bv || he.hit) { occ = occ + 1.0; }
@@ -1354,6 +1846,25 @@ fn fs(in: VOut) -> FOut {
var color = albedo * light * ao_term;
// Brillo especular + glaseado por voxel (algo que la luz plana de Minecraft no
// hace): reflejo dependiente de la vista sobre los minerales lustrosos. `shine`
// sale del alpha del voxel (matte 1.0 → 0). Nuestro ray-march ya tiene la
// normal de cara y el rayo de cámara, así que el destello es real por-píxel.
let shine = clamp((1.0 - shine_a) * 2.04, 0.0, 1.0);
if (shine > 0.0) {
let vdir = -rd; // hacia la cámara
let hvec = normalize(u.sun_dir.xyz + vdir); // half-vector Blinn-Phong
let ndh = max(dot(normal, hvec), 0.0);
// Lóbulo moderado: las normales del voxel son axiales, un exponente alto
// no engancharía ninguna cara visible. La dureza sube un poco el foco.
let power = mix(2.0, 9.0, shine);
let spec = pow(ndh, power) * shine * shadow * 3.0;
// Glaseado (fresnel): lustre vidrioso a ángulo rasante, tinte frío del cielo.
let fres = pow(1.0 - max(dot(normal, vdir), 0.0), 4.0) * shine * 0.45;
let gloss = sun_col * spec + mix(sun_col, u.sky_zenith.xyz, 0.6) * fres;
color = color + gloss;
}
// Niebla / perspectiva aérea: lo lejano desvanece hacia el cielo en esa
// dirección, lo que hace legible el borde de un mundo grande.
if (fog_density > 0.0) {
@@ -1364,9 +1875,38 @@ fn fs(in: VOut) -> FOut {
color = color + vec3<f32>(1.0, 0.95, 0.82) * gr;
}
// Diagnóstico: pinta la normal de sombreado como color (n·0.5+0.5). Lo usa la
// certificación para medir qué fracción de la superficie dejó de ser axial.
if (u.scroll.w > 0.5) {
var dbg: FOut;
dbg.color = vec4<f32>(normal * 0.5 + 0.5, 1.0);
dbg.depth = frag_depth(p, dim);
return dbg;
}
var out: FOut;
out.color = vec4<f32>(color, 1.0);
out.depth = frag_depth(p, dim);
return out;
}
"#;
#[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`).
#[test]
fn wgsl_valida() {
let module = naga::front::wgsl::parse_str(WGSL)
.unwrap_or_else(|e| panic!("WGSL del voxel-renderer no parsea: {e:?}"));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("WGSL del voxel-renderer no valida: {e:?}"));
}
}
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "llimphi-anim-studio"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Studio de máquinas de animación estilo Rive: una interfaz Llimphi para autorar el grafo de estados de llimphi-anim (estados, transiciones, condiciones, inputs) sobre un nodegraph, con preview en vivo dirigido por inputs y persistencia RON. Frontend intercambiable sobre el runtime agnóstico — el StateMachine no sabe quién lo edita."
[[bin]]
name = "llimphi-anim-studio"
path = "src/main.rs"
[dependencies]
# El runtime estilo Rive — el core agnóstico que esta UI autora.
llimphi-anim = { workspace = true }
# Render de la malla deformada (solid/wireframe + fit/bounds) para el preview del rig.
llimphi-mesh = { workspace = true }
# Carga de imágenes de disco (PNG/JPEG/WEBP → ImageBrush) para texturizar la malla.
llimphi-image = { workspace = true }
# Bucle Elm + ventana + re-exports (raster/layout/text).
llimphi-ui = { workspace = true }
# Paletas semánticas de color.
llimphi-theme = { workspace = true }
# El lienzo de nodos: estados = nodos, transiciones = cables (drag pin→pin).
llimphi-widget-nodegraph = { workspace = true }
# Botones y sliders del inspector + controles de inputs en vivo.
llimphi-widget-button = { workspace = true }
llimphi-widget-slider = { workspace = true }
# Campo de texto para nombrar estados / inputs.
llimphi-widget-text-input = { workspace = true }
# Portapapeles del sistema para copiar/cortar/pegar en los campos.
llimphi-clipboard = { workspace = true }
# Persistencia del documento (grafo) en texto editable a mano.
serde = { workspace = true, features = ["derive"] }
ron = { workspace = true }
+24
View File
@@ -0,0 +1,24 @@
# llimphi-anim-studio
*Read this in English: [README.md](README.md).*
Biblioteca del studio de animación «rive»: los documentos **serializables**
que el editor autora —el grafo de estados (`doc::Doc`) y el rig esqueletal
(`rig::RigDoc`)— y el `Project` que los junta en el `.ron` que se guarda
y se lee.
Antes estos tipos vivían dentro del binario del editor; exponerlos como
biblioteca permite que **otros consumidores** carguen el mismo formato sin
duplicar los structs. El primero es `mirada-fondo`, que reproduce un proyecto
como **fondo** (splash/greeter/wallpaper): bakea el rig deformado a frames y
los blitea. El editor (`main.rs`) es ahora un frontend más sobre esta lib.
## Uso
```sh
cargo run --release -p llimphi-anim-studio
```
---
Parte de **llimphi** — ver [llimphi](../LEEME.md).
+22
View File
@@ -0,0 +1,22 @@
# llimphi-anim-studio
The library of the "rive" animation studio: the **serializable** documents the
editor authors — the state graph (`doc::Doc`) and the skeletal rig
(`rig::RigDoc`) — plus the `Project` that joins them into the `.ron` that is
saved and read.
These types used to live inside the editor's binary; exposing them as a library
lets **other consumers** load the same format without duplicating the structs.
The first is `mirada-fondo`, which plays a project as a **background**
(splash/greeter/wallpaper): it bakes the deformed rig to frames and blits them.
The editor (`main.rs`) is now just one more frontend over this library.
## Use
```sh
cargo run --release -p llimphi-anim-studio
```
---
Part of **llimphi** — see [llimphi](../README.md).
+410
View File
@@ -0,0 +1,410 @@
//! El **documento** del studio: la representación editable del grafo de estados,
//! agnóstica del render. Es la fuente de verdad que la UI manipula y que se
//! **compila** a un [`llimphi_anim::StateMachine`] ejecutable cada vez que cambia,
//! para alimentar el preview en vivo.
//!
//! ## Por qué un modelo propio
//!
//! `llimphi_anim::StateMachine` es un *builder* de una sola dirección: se
//! construye con `add_state`/`transition`, se congela en un `Arc` al hacer
//! `instance()`, y **no expone introspección** (sus campos son privados). Un
//! editor necesita leer, reordenar y reescribir el grafo, así que el studio
//! mantiene este `Doc` editable (con posiciones de canvas, defaults de inputs,
//! etc.) y lo proyecta al runtime con [`Doc::compile`]. La relación es la misma
//! que `Project → render` en voxel-studio: el documento es rico, el runtime es
//! la proyección ejecutable.
//!
//! El `ClipId` que consume el runtime es simplemente el **índice del estado**:
//! como el studio no carga assets reales (Lottie/rig) en la Fase 1, el preview
//! pinta cada estado con un color/movimiento sintético derivado de su índice. El
//! día que se cableen clips reales, sólo cambia el consumidor del `RenderFrame`.
use llimphi_anim::{Cmp, Condition, StateMachine};
use serde::{Deserialize, Serialize};
/// Operador de comparación numérica — espejo serializable de [`llimphi_anim::Cmp`]
/// (que no deriva serde). Se convierte a él al compilar.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CmpOp {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
}
impl CmpOp {
pub const ALL: [CmpOp; 6] = [
CmpOp::Eq,
CmpOp::Ne,
CmpOp::Lt,
CmpOp::Le,
CmpOp::Gt,
CmpOp::Ge,
];
pub fn symbol(self) -> &'static str {
match self {
CmpOp::Eq => "==",
CmpOp::Ne => "",
CmpOp::Lt => "<",
CmpOp::Le => "",
CmpOp::Gt => ">",
CmpOp::Ge => "",
}
}
fn to_anim(self) -> Cmp {
match self {
CmpOp::Eq => Cmp::Eq,
CmpOp::Ne => Cmp::Ne,
CmpOp::Lt => Cmp::Lt,
CmpOp::Le => Cmp::Le,
CmpOp::Gt => Cmp::Gt,
CmpOp::Ge => Cmp::Ge,
}
}
}
/// Tipo de un input de la máquina (espejo del trío Rive bool/number/trigger).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InputKind {
Bool,
Number,
Trigger,
}
impl InputKind {
pub fn label(self) -> &'static str {
match self {
InputKind::Bool => "bool",
InputKind::Number => "número",
InputKind::Trigger => "trigger",
}
}
}
/// Una guarda editable de una transición. Se compila a [`llimphi_anim::Condition`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CondDef {
/// `bool input == value`.
Bool { input: String, value: bool },
/// `number input <op> value`.
Number { input: String, op: CmpOp, value: f64 },
/// el trigger `input` fue disparado este frame.
Trigger { input: String },
/// el clip del estado de origen terminó (sólo estados no-loop).
ClipDone,
}
impl CondDef {
fn to_anim(&self) -> Condition {
match self {
CondDef::Bool { input, value } => Condition::bool(input.clone(), *value),
CondDef::Number { input, op, value } => {
Condition::number(input.clone(), op.to_anim(), *value)
}
CondDef::Trigger { input } => Condition::trigger(input.clone()),
CondDef::ClipDone => Condition::clip_done(),
}
}
}
/// Un estado del grafo: un clip (por índice) con su velocidad y loop, más su
/// posición en el lienzo y la duración nominal del clip (para `ClipDone`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StateDef {
pub name: String,
/// Velocidad de reproducción del clip (escala el tiempo).
pub speed: f64,
/// ¿El clip hace loop? Si no, su `ClipDone` puede disparar transiciones.
pub looping: bool,
/// Duración nominal del clip en segundos (para `ClipDone` y el preview).
pub clip_len: f64,
/// Posición del nodo en el lienzo (pixels relativos al canvas).
pub x: f32,
pub y: f32,
}
impl StateDef {
pub fn new(name: impl Into<String>, x: f32, y: f32) -> Self {
StateDef {
name: name.into(),
speed: 1.0,
looping: true,
clip_len: 2.0,
x,
y,
}
}
}
/// Una transición editable. `from = None` ⇒ transición *any-state*.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TransDef {
/// Estado de origen (índice en `states`), o `None` para any-state.
pub from: Option<usize>,
/// Estado destino (índice en `states`).
pub to: usize,
/// Guardas AND. Vacío ⇒ la transición **nunca** dispara (el runtime la ignora).
pub conditions: Vec<CondDef>,
/// Duración del crossfade (segundos). `0.0` = salto instantáneo.
pub duration_secs: f64,
}
/// Un input declarado, con su default. Los defaults siembran los controles en
/// vivo del panel de preview.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InputDef {
pub name: String,
pub kind: InputKind,
pub bool_default: bool,
pub num_default: f64,
}
impl InputDef {
pub fn new(name: impl Into<String>, kind: InputKind) -> Self {
InputDef {
name: name.into(),
kind,
bool_default: false,
num_default: 0.0,
}
}
}
/// El documento entero: estados + transiciones + inputs + estado de entrada.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Doc {
pub states: Vec<StateDef>,
pub transitions: Vec<TransDef>,
pub inputs: Vec<InputDef>,
/// Índice del estado de entrada.
pub entry: usize,
}
impl Default for Doc {
fn default() -> Self {
Doc {
states: Vec::new(),
transitions: Vec::new(),
inputs: Vec::new(),
entry: 0,
}
}
}
impl Doc {
/// Proyecta el documento a un [`StateMachine`] ejecutable. Índice de estado =
/// `ClipId`. Transiciones con índices fuera de rango se descartan (defensa
/// ante un documento inconsistente recién editado).
pub fn compile(&self) -> StateMachine {
let mut sm = StateMachine::new();
let n = self.states.len();
for (i, s) in self.states.iter().enumerate() {
sm.add_state(s.name.clone(), i as u32, s.speed, s.looping);
sm.set_clip_duration(i as u32, s.clip_len);
}
if n > 0 {
sm.set_entry(self.entry.min(n - 1));
}
for t in &self.transitions {
if t.to >= n {
continue;
}
let conds: Vec<Condition> = t.conditions.iter().map(CondDef::to_anim).collect();
match t.from {
Some(f) if f < n => sm.transition(f, t.to, conds, t.duration_secs),
None => sm.transition_any(t.to, conds, t.duration_secs),
Some(_) => {} // origen fuera de rango → descartar
}
}
sm
}
/// Documento de arranque: el clásico `idle ⇄ walk` por un bool `moving`, más
/// un `jump` any-state por trigger. Da algo vivo que tocar al abrir.
pub fn starter() -> Self {
let mut doc = Doc::default();
doc.states.push(StateDef::new("idle", 60.0, 80.0));
let mut walk = StateDef::new("walk", 320.0, 80.0);
walk.speed = 1.0;
doc.states.push(walk);
let mut jump = StateDef::new("jump", 190.0, 240.0);
jump.looping = false;
jump.clip_len = 0.6;
doc.states.push(jump);
doc.entry = 0;
doc.inputs.push(InputDef::new("moving", InputKind::Bool));
doc.inputs.push(InputDef::new("jump", InputKind::Trigger));
doc.transitions.push(TransDef {
from: Some(0),
to: 1,
conditions: vec![CondDef::Bool {
input: "moving".into(),
value: true,
}],
duration_secs: 0.2,
});
doc.transitions.push(TransDef {
from: Some(1),
to: 0,
conditions: vec![CondDef::Bool {
input: "moving".into(),
value: false,
}],
duration_secs: 0.2,
});
// any-state: ⚡jump → jump (instantáneo)
doc.transitions.push(TransDef {
from: None,
to: 2,
conditions: vec![CondDef::Trigger {
input: "jump".into(),
}],
duration_secs: 0.0,
});
// jump termina → idle
doc.transitions.push(TransDef {
from: Some(2),
to: 0,
conditions: vec![CondDef::ClipDone],
duration_secs: 0.0,
});
doc
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compila_y_arranca_en_entry() {
let doc = Doc::starter();
let inst = doc.compile().instance();
assert_eq!(inst.current_state(), "idle");
}
#[test]
fn bool_dispara_idle_a_walk() {
let mut inst = Doc::starter().compile().instance();
inst.set_bool("moving", true);
inst.advance(0.3); // > duración de blend (0.2)
assert_eq!(inst.current_state(), "walk");
inst.set_bool("moving", false);
inst.advance(0.3);
assert_eq!(inst.current_state(), "idle");
}
#[test]
fn trigger_anystate_salta_a_jump_y_vuelve() {
let mut inst = Doc::starter().compile().instance();
inst.fire("jump");
inst.advance(0.016);
assert_eq!(inst.current_state(), "jump");
// El clip de 0.6 s termina (no loop) → vuelve a idle por ClipDone.
inst.advance(0.7);
assert_eq!(inst.current_state(), "idle");
}
#[test]
fn transicion_con_indice_fuera_de_rango_se_descarta() {
let mut doc = Doc::default();
doc.states.push(StateDef::new("a", 0.0, 0.0));
// destino inexistente → no debe panickear al compilar
doc.transitions.push(TransDef {
from: Some(0),
to: 99,
conditions: vec![CondDef::ClipDone],
duration_secs: 0.0,
});
let inst = doc.compile().instance();
assert_eq!(inst.current_state(), "a");
}
#[test]
fn condicion_numerica_compila() {
let mut doc = Doc::default();
doc.states.push(StateDef::new("slow", 0.0, 0.0));
doc.states.push(StateDef::new("fast", 0.0, 0.0));
doc.inputs.push(InputDef::new("speed", InputKind::Number));
doc.transitions.push(TransDef {
from: Some(0),
to: 1,
conditions: vec![CondDef::Number {
input: "speed".into(),
op: CmpOp::Gt,
value: 5.0,
}],
duration_secs: 0.0,
});
let mut inst = doc.compile().instance();
inst.set_number("speed", 3.0);
inst.advance(0.016);
assert_eq!(inst.current_state(), "slow");
inst.set_number("speed", 9.0);
inst.advance(0.016);
assert_eq!(inst.current_state(), "fast");
}
#[test]
fn round_trip_ron() {
let doc = Doc::starter();
let ron = ron::ser::to_string_pretty(&doc, ron::ser::PrettyConfig::default())
.expect("serializa");
let back: Doc = ron::from_str(&ron).expect("deserializa");
assert_eq!(doc, back);
}
/// Un grafo con forma de **botón reactivo** (idle/hover/press, hovered+pressed)
/// authored en el studio sobrevive el round-trip RON y, al compilar, corre el
/// ciclo completo dirigido por inputs. Certifica el lazo autor→export→consumo
/// que enchufa `llimphi-widget-rive-button::from_state_machine`.
fn boton_doc() -> Doc {
let mut doc = Doc::default();
doc.states.push(StateDef::new("idle", 60.0, 80.0));
doc.states.push(StateDef::new("hover", 260.0, 80.0));
let mut press = StateDef::new("press", 460.0, 80.0);
press.looping = false;
press.clip_len = 0.4;
doc.states.push(press);
doc.entry = 0;
doc.inputs.push(InputDef::new("hovered", InputKind::Bool));
doc.inputs.push(InputDef::new("pressed", InputKind::Trigger));
let b = |input: &str, value: bool| CondDef::Bool { input: input.into(), value };
doc.transitions.push(TransDef { from: Some(0), to: 1, conditions: vec![b("hovered", true)], duration_secs: 0.18 });
doc.transitions.push(TransDef { from: Some(1), to: 0, conditions: vec![b("hovered", false)], duration_secs: 0.18 });
doc.transitions.push(TransDef { from: None, to: 2, conditions: vec![CondDef::Trigger { input: "pressed".into() }], duration_secs: 0.06 });
doc.transitions.push(TransDef { from: Some(2), to: 1, conditions: vec![CondDef::ClipDone, b("hovered", true)], duration_secs: 0.18 });
doc.transitions.push(TransDef { from: Some(2), to: 0, conditions: vec![CondDef::ClipDone, b("hovered", false)], duration_secs: 0.18 });
doc
}
#[test]
fn boton_authorado_round_trip_corre_el_ciclo() {
let doc = boton_doc();
// Export → import (lo que hace Project::save/load del studio).
let ron = ron::ser::to_string_pretty(&doc, ron::ser::PrettyConfig::default()).expect("serializa");
let back: Doc = ron::from_str(&ron).expect("deserializa");
assert_eq!(doc, back);
// Consumo: la máquina compilada del doc reimportado corre el ciclo.
let mut inst = back.compile().instance();
assert_eq!(inst.current_state(), "idle");
inst.set_bool("hovered", true);
inst.advance(0.5);
assert_eq!(inst.current_state(), "hover");
inst.fire("pressed");
inst.advance(0.2);
assert_eq!(inst.current_state(), "press");
inst.advance(0.6); // clip de 0.4 s termina → ClipDone, sigue hovered → hover
assert_eq!(inst.current_state(), "hover");
inst.set_bool("hovered", false);
inst.advance(0.5);
assert_eq!(inst.current_state(), "idle");
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Biblioteca del studio de animación «rive»: los documentos **serializables**
//! que el editor autora —el grafo de estados ([`doc::Doc`]) y el rig esqueletal
//! ([`rig::RigDoc`])— y el [`Project`] que los junta en el `.ron` que se guarda
//! y se lee.
//!
//! Antes estos tipos vivían dentro del binario del editor; exponerlos como
//! biblioteca permite que **otros consumidores** carguen el mismo formato sin
//! duplicar los structs. El primero es `mirada-fondo`, que reproduce un proyecto
//! como **fondo** (splash/greeter/wallpaper): bakea el rig deformado a frames y
//! los blitea. El editor (`main.rs`) es ahora un frontend más sobre esta lib.
#![forbid(unsafe_code)]
pub mod doc;
pub mod rig;
use serde::{Deserialize, Serialize};
/// El proyecto persistido: ambas superficies juntas en un solo `.ron` (el grafo
/// de estados + el rig esqueletal). Es el formato que escribe el editor y el que
/// carga `mirada-fondo` para reproducir un «rive» como fondo.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Project {
pub doc: doc::Doc,
#[serde(default = "rig::RigDoc::starter")]
pub rig: rig::RigDoc,
}
impl Default for Project {
fn default() -> Self {
Project {
doc: doc::Doc::starter(),
rig: rig::RigDoc::starter(),
}
}
}
impl Project {
/// Carga y parsea un proyecto `.ron` de disco.
pub fn load(path: impl AsRef<std::path::Path>) -> Result<Self, String> {
let p = path.as_ref();
let s = std::fs::read_to_string(p).map_err(|e| format!("no se pudo leer {p:?}: {e}"))?;
ron::from_str(&s).map_err(|e| format!("RON inválido en {p:?}: {e}"))
}
}
File diff suppressed because it is too large Load Diff
+444
View File
@@ -0,0 +1,444 @@
//! El **rig esqueletal**: la representación editable de una cadena de huesos con
//! una malla-tira auto-skinneada, que se **compila** a un
//! [`llimphi_anim::skel::Skeleton`] + [`llimphi_anim::skel::Mesh`] deformables.
//! Es la Fase 2 del studio: sobre el mismo runtime estilo Rive, pero ahora el
//! "clip" es una **deformación esqueletal** en vez de una máquina de estados.
//!
//! ## Qué autora (y qué no, todavía)
//!
//! Autora una **cadena** de huesos (cada uno con su largo y su ángulo de pose),
//! una malla-tubo generada paramétricamente alrededor de la cadena (skinning
//! suave en las articulaciones, igual que el `build_arm` canónico del demo
//! `lottie_rive_demo`), y un **IK de 2 huesos** opcional sobre los dos primeros
//! huesos persiguiendo un objetivo. El *weight-paint* a mano y la malla
//! arbitraria (importada/dibujada) quedan para F2.5/F3 — aquí la malla es
//! derivada de la cadena, no editable vértice-a-vértice.
//!
//! La matemática (jerarquía, LBS, IK analítico) vive entera en `llimphi-anim`;
//! este módulo sólo **describe** el rig y lo proyecta al runtime, igual que
//! [`crate::doc::Doc`] hace con el `StateMachine`.
use llimphi_anim::constraint::solve_two_bone_ik;
use llimphi_anim::skel::{Mesh, Pose, Skeleton, Vertex, Weight};
use llimphi_ui::llimphi_raster::kurbo::{Point, Vec2};
use serde::{Deserialize, Serialize};
/// Un hueso de la cadena. El hueso apunta a lo largo de su eje local **+x**
/// (convención de `skel`/`constraint`): su hijo se traslada `len` en +x.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BoneDef {
/// Largo del hueso (distancia al hijo, en unidades de modelo).
pub len: f64,
/// Ángulo de pose **local** en radianes (lo que editan los sliders). En el
/// bind pose la cadena está recta (todos los ángulos en 0).
pub angle: f64,
}
impl BoneDef {
pub fn new(len: f64) -> Self {
BoneDef { len, angle: 0.0 }
}
}
/// Cómo se genera la malla deformable alrededor de la cadena.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MeshMode {
/// Tira-tubo a lo largo de la cadena (skinning rígido+blend en joints).
/// Ideal para un miembro (brazo, cola).
Tube,
/// Rejilla rectangular que cubre toda la silueta, con cada vértice
/// auto-skinneado a los huesos por distancia. Es la malla para **deformar
/// una imagen/arte arbitrario** (sus UV mapean la textura completa).
Grid,
}
/// El documento del rig: la cadena + parámetros de malla + IK + textura.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RigDoc {
pub bones: Vec<BoneDef>,
/// Medio-grosor de la malla-tubo (mitad de la altura de la tira).
pub thickness: f64,
/// Columnas de la malla por unidad de largo (densidad del tubo).
pub cols: usize,
/// Modo de generación de la malla.
pub mesh_mode: MeshMode,
/// Resolución de la rejilla (celdas a lo largo de la cadena, modo Grid).
pub grid_res: usize,
/// Relación alto/ancho de la rejilla (modo Grid); se ajusta al cargar una
/// imagen para respetar su aspecto.
pub mesh_aspect: f64,
/// Path de la textura a deformar (se recarga al abrir el proyecto). Los
/// píxeles NO se serializan — sólo la referencia al archivo.
pub texture_path: Option<String>,
/// ¿IK de 2 huesos activo sobre los huesos 0 y 1?
pub ik_enabled: bool,
/// Objetivo del IK (espacio de modelo).
pub ik_target: (f64, f64),
/// Solución de codo (flip de la rama del IK).
pub ik_flip: bool,
}
impl Default for RigDoc {
fn default() -> Self {
RigDoc {
bones: Vec::new(),
thickness: 22.0,
cols: 16,
mesh_mode: MeshMode::Tube,
grid_res: 10,
mesh_aspect: 0.6,
texture_path: None,
ik_enabled: false,
ik_target: (200.0, 40.0),
ik_flip: false,
}
}
}
impl RigDoc {
/// Rig de arranque: un brazo de 2 huesos, el ejemplo canónico.
pub fn starter() -> Self {
RigDoc {
bones: vec![BoneDef::new(140.0), BoneDef::new(120.0)],
ik_target: (180.0, 90.0),
..Default::default()
}
}
/// Largo total de la cadena (suma de los huesos).
pub fn total_len(&self) -> f64 {
self.bones.iter().map(|b| b.len).sum()
}
/// Posición de arranque (arc-length) de cada hueso a lo largo de la cadena
/// recta: `starts[i] = Σ len[0..i]`.
fn starts(&self) -> Vec<f64> {
let mut s = Vec::with_capacity(self.bones.len());
let mut acc = 0.0;
for b in &self.bones {
s.push(acc);
acc += b.len;
}
s
}
/// Construye el esqueleto en **bind pose** (cadena recta) y lo congela. El
/// orden de los `BoneId` coincide con `self.bones`.
fn build_skeleton_bind(&self) -> Skeleton {
let mut s = Skeleton::new();
let mut prev = None;
for (i, _b) in self.bones.iter().enumerate() {
// El hijo se traslada el largo del PADRE en +x; el root en el origen.
let local = if i == 0 {
Pose::identity()
} else {
Pose::translate(Vec2::new(self.bones[i - 1].len, 0.0))
};
prev = Some(s.add_bone(prev, local));
}
s.bind();
s
}
/// Aplica los ángulos de pose actuales (y el IK, si está activo) sobre un
/// esqueleto ya en bind, y lo deja `update()`-eado listo para deformar.
fn pose_skeleton(&self, s: &mut Skeleton) {
for (i, b) in self.bones.iter().enumerate() {
let t = if i == 0 {
Vec2::ZERO
} else {
Vec2::new(self.bones[i - 1].len, 0.0)
};
s.set_pose(i, Pose::new(t, b.angle, Vec2::new(1.0, 1.0)));
}
// IK sobre los dos primeros huesos: sobrescribe sus poses para que la
// punta del hueso 1 alcance el objetivo. Los huesos ≥2 conservan su
// ángulo de slider (relativo al hueso 1).
if self.ik_enabled && self.bones.len() >= 2 {
let tip_local = Vec2::new(self.bones[1].len, 0.0);
let target = Point::new(self.ik_target.0, self.ik_target.1);
solve_two_bone_ik(s, 0, 1, tip_local, target, self.ik_flip);
}
s.update();
}
/// Esqueleto posado (bind + poses + IK + update), listo para `deform`.
pub fn skeleton(&self) -> Skeleton {
let mut s = self.build_skeleton_bind();
self.pose_skeleton(&mut s);
s
}
/// Una copia del rig con una **deriva idle** procedural aplicada en la fase
/// `phase` ∈ `[0,1)` del loop: un vaivén suave de cada hueso (desfasado por
/// índice) y, si el IK está activo, una pequeña órbita del objetivo. Le da
/// vida al rig —como «Alley Cat»— mientras el formato del studio todavía no
/// serializa pistas de animación por keyframe (Fase 1). Lo comparten el bake
/// de fondos (`mirada-fondo`) y la reproducción en vivo del greeter, para que
/// el mismo `.ron` se vea igual en las tres superficies.
pub fn idle_at(&self, phase: f64) -> RigDoc {
const SWAY: f64 = 0.18;
const ORBIT: f64 = 24.0;
let p = phase * std::f64::consts::TAU;
let mut rig = self.clone();
for (k, b) in rig.bones.iter_mut().enumerate() {
b.angle += SWAY * (p + k as f64 * 0.6).sin();
}
if rig.ik_enabled {
rig.ik_target.0 += ORBIT * p.cos();
rig.ik_target.1 += ORBIT * (p * 2.0).sin() * 0.5;
}
rig
}
/// La malla deformable según el modo activo.
pub fn mesh(&self) -> Mesh {
match self.mesh_mode {
MeshMode::Tube => self.tube_mesh(),
MeshMode::Grid => self.grid_mesh(),
}
}
/// Malla-tubo skinneada alrededor de la cadena recta (bind space). Skinning
/// suave en las articulaciones: lejos de un joint el vértice es rígido a su
/// hueso; dentro de la ventana de blend mezcla con el hueso vecino.
fn tube_mesh(&self) -> Mesh {
let mut m = Mesh::new();
let n = self.bones.len();
if n == 0 {
return m;
}
let total = self.total_len();
let starts = self.starts();
let cols = self.cols.max(2);
let half = self.thickness;
// Ventana de blend: una fracción del hueso más corto.
let min_len = self.bones.iter().map(|b| b.len).fold(f64::MAX, f64::min);
let blend = (min_len * 0.4).max(1.0);
for i in 0..=cols {
let p = total * i as f64 / cols as f64;
let weights = self.weights_at(p, &starts, blend);
let u = i as f64 / cols as f64;
m.vertices.push(Vertex {
rest: Point::new(p, -half),
uv: (u, 0.0),
weights: weights.clone(),
});
m.vertices.push(Vertex {
rest: Point::new(p, half),
uv: (u, 1.0),
weights,
});
}
for i in 0..cols {
let (t0, t1) = ((2 * i) as u32, (2 * (i + 1)) as u32);
let (b0, b1) = ((2 * i + 1) as u32, (2 * (i + 1) + 1) as u32);
m.triangles.push([t0, t1, b1]);
m.triangles.push([t0, b1, b0]);
}
m
}
/// Pesos de un vértice a arc-position `p`: rígido a su segmento, con blend
/// lineal hacia el hueso vecino cerca de cada joint.
fn weights_at(&self, p: f64, starts: &[f64], blend: f64) -> Vec<Weight> {
let n = self.bones.len();
// Segmento que contiene a p.
let mut k = 0;
while k + 1 < n && p >= starts[k + 1] {
k += 1;
}
// Joint de entrada (con k-1) y de salida (con k+1).
if k + 1 < n {
let d = starts[k + 1] - p; // distancia al joint siguiente
if d < blend {
let t = (d / blend).clamp(0.0, 1.0);
let wk = 0.5 + 0.5 * t;
return vec![
Weight { bone: k, weight: wk },
Weight { bone: k + 1, weight: 1.0 - wk },
];
}
}
if k > 0 {
let d = p - starts[k]; // distancia al joint anterior
if d < blend {
let t = (d / blend).clamp(0.0, 1.0);
let wk = 0.5 + 0.5 * t;
return vec![
Weight { bone: k, weight: wk },
Weight { bone: k - 1, weight: 1.0 - wk },
];
}
}
vec![Weight { bone: k, weight: 1.0 }]
}
/// Malla-rejilla que cubre la silueta (`[0,total] × [-H/2,H/2]`, con
/// `H = total·aspect`), cada vértice **auto-skinneado** a los huesos por
/// distancia (inverse-distance, top-2). Sus UV mapean la textura completa
/// `0..1`, así que deforma una imagen arbitraria, no sólo un miembro.
fn grid_mesh(&self) -> Mesh {
let mut m = Mesh::new();
let n = self.bones.len();
if n == 0 {
return m;
}
let total = self.total_len();
let h = (total * self.mesh_aspect).max(1.0);
let y_top = -h * 0.5;
let starts = self.starts();
let gx = self.grid_res.max(2);
let gy = ((gx as f64 * self.mesh_aspect).round() as usize).max(2);
for j in 0..=gy {
for i in 0..=gx {
let x = total * i as f64 / gx as f64;
let y = y_top + h * j as f64 / gy as f64;
let weights = self.skin_weights_at(Point::new(x, y), &starts);
let uv = (i as f64 / gx as f64, j as f64 / gy as f64);
m.vertices.push(Vertex {
rest: Point::new(x, y),
uv,
weights,
});
}
}
let stride = (gx + 1) as u32;
for j in 0..gy as u32 {
for i in 0..gx as u32 {
let a = j * stride + i;
let b = a + 1;
let c = a + stride;
let d = c + 1;
m.triangles.push([a, b, d]);
m.triangles.push([a, d, c]);
}
}
m
}
/// Pesos auto-skin de un punto: distancia a cada segmento-hueso (en bind,
/// recta sobre el eje x), inverse-distance², se queda con los 2 huesos más
/// cercanos y normaliza. Da una deformación suave de la rejilla.
fn skin_weights_at(&self, p: Point, starts: &[f64]) -> Vec<Weight> {
let n = self.bones.len();
// (bone, dist) por hueso.
let mut ds: Vec<(usize, f64)> = (0..n)
.map(|k| {
let x0 = starts[k];
let x1 = starts[k] + self.bones[k].len;
let dx = if p.x < x0 {
x0 - p.x
} else if p.x > x1 {
p.x - x1
} else {
0.0
};
(k, dx.hypot(p.y))
})
.collect();
// Top-2 más cercanos.
ds.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
ds.truncate(2);
let eps = 1e-3;
let mut raw: Vec<(usize, f64)> =
ds.iter().map(|(k, d)| (*k, 1.0 / (d * d + eps))).collect();
let sum: f64 = raw.iter().map(|(_, w)| w).sum();
if sum <= 0.0 {
return vec![Weight { bone: 0, weight: 1.0 }];
}
for (_, w) in &mut raw {
*w /= sum;
}
raw.into_iter()
.map(|(bone, weight)| Weight { bone, weight })
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compila_esqueleto_y_malla() {
let rig = RigDoc::starter();
let s = rig.skeleton();
assert_eq!(s.len(), 2);
let m = rig.mesh();
assert!(!m.vertices.is_empty());
assert!(!m.triangles.is_empty());
// En bind (todos los ángulos 0, IK off) la deformación = reposo.
let pos = m.deform(&s);
assert_eq!(pos.len(), m.vertices.len());
for (p, v) in pos.iter().zip(&m.vertices) {
assert!((p.x - v.rest.x).abs() < 1e-6 && (p.y - v.rest.y).abs() < 1e-6);
}
}
#[test]
fn posar_un_hueso_deforma_la_malla() {
let mut rig = RigDoc::starter();
let rest = rig.mesh().deform(&rig.skeleton());
// Doblar el codo (hueso 1) 0.8 rad.
rig.bones[1].angle = 0.8;
let bent = rig.mesh().deform(&rig.skeleton());
// Al menos un vértice de la punta se movió respecto al reposo.
let moved = rest
.iter()
.zip(&bent)
.any(|(a, b)| (a.x - b.x).hypot(a.y - b.y) > 1.0);
assert!(moved, "doblar el codo debería mover la malla");
}
#[test]
fn grid_mesh_pesos_normalizados_y_deforma() {
let mut rig = RigDoc::starter();
rig.mesh_mode = MeshMode::Grid;
rig.grid_res = 8;
let m = rig.mesh();
assert!(!m.vertices.is_empty() && !m.triangles.is_empty());
// Cada vértice tiene pesos que suman ~1 (skinning bien normalizado).
for v in &m.vertices {
let s: f64 = v.weights.iter().map(|w| w.weight).sum();
assert!((s - 1.0).abs() < 1e-6, "pesos deben sumar 1, fue {s}");
assert!(v.weights.iter().all(|w| w.bone < rig.bones.len()));
}
// Posar el codo deforma la rejilla.
let rest = m.deform(&rig.skeleton());
rig.bones[1].angle = 0.9;
let bent = rig.mesh().deform(&rig.skeleton());
let moved = rest
.iter()
.zip(&bent)
.any(|(a, b)| (a.x - b.x).hypot(a.y - b.y) > 1.0);
assert!(moved, "doblar el codo debería deformar la rejilla");
}
#[test]
fn ik_alcanza_el_objetivo() {
let mut rig = RigDoc::starter();
rig.ik_enabled = true;
// Objetivo dentro del alcance (l1+l2 = 260).
rig.ik_target = (150.0, 80.0);
let s = rig.skeleton();
// Punta del hueso 1 en mundo = world(1) * (len1, 0).
let tip = s.world(1) * Point::new(rig.bones[1].len, 0.0);
let target = Point::new(rig.ik_target.0, rig.ik_target.1);
let err = (tip.x - target.x).hypot(tip.y - target.y);
assert!(err < 1.0, "el IK debería alcanzar el objetivo, err={err}");
}
#[test]
fn ik_clampa_objetivo_inalcanzable_sin_panickear() {
let mut rig = RigDoc::starter();
rig.ik_enabled = true;
rig.ik_target = (10_000.0, 0.0); // mucho más lejos que el alcance
let s = rig.skeleton();
// No panickea y el brazo queda estirado hacia el objetivo (x≈260).
let tip = s.world(1) * Point::new(rig.bones[1].len, 0.0);
assert!(tip.x > 200.0, "brazo estirado hacia el objetivo lejano");
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "llimphi-anim"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-anim — máquina de estados de animación (estilo Rive) clip-agnóstica: estados, inputs (bool/number/trigger), transiciones con condiciones y blend. El núcleo no sabe de Lottie ni de vello — opera sobre ClipId + duración; el render lo cablea el consumidor (llimphi-lottie hoy; rigs nativos mañana)."
[dependencies]
# Álgebra afín 2D (Affine/Point/Vec2) para la jerarquía de huesos y el skinning.
# Misma versión que expone vello 0.7 → los Point deformados tipan directo con el
# renderer (llimphi-lottie/mesh) sin conversiones.
kurbo = "0.13"
+41
View File
@@ -0,0 +1,41 @@
# llimphi-anim
*Read this in English: [README.md](README.md).*
Máquina de estados de animación, estilo Rive, **clip-agnóstica**.
Es el escalón sobre el playback lineal: en vez de "reproduce esta animación",
modela "según estos *inputs*, en qué estado estoy y a cuál transiciono, con
qué *blend*". Es lo que vuelve una animación *interactiva* (hover, progreso,
triggers) en lugar de un loop fijo.
## Por qué clip-agnóstico
El núcleo **no sabe de Lottie ni de vello**. Un clip es un `ClipId` (u32)
con una duración conocida; la máquina secuencia y mezcla clips por id y
tiempo, y emite un `RenderFrame` que dice *qué* renderizar (clip primario +
un clip entrante con su mezcla 0..1 durante una transición). El consumidor
mapea `ClipId → asset` y pinta. Hoy ese consumidor es `llimphi-lottie`;
mañana puede ser un rig de huesos nativo o un tween de `llimphi-motion` — el
mismo motor de estados sirve a todos.
## Modelo (espejo del de Rive)
- **Inputs**: `bool`, `number`, `trigger` (los triggers se consumen en cada
`advance`).
- **Estado**: un clip con velocidad y loop, más sus transiciones salientes.
- **Transición**: destino + condiciones (AND) sobre inputs + duración de
blend. Las transiciones *any-state* se evalúan sin importar el estado
actual (típico para "fire trigger → ir a X desde donde sea").
- **Condición**: `bool == v`, `number <cmp> v`, `trigger disparado`, o
`clip terminó` (para "cuando la animación acaba, pasa a la siguiente").
## Bucle
El consumidor empuja inputs (`Instance::set_bool` etc.), llama
`Instance::advance` con el `dt` del frame, y pinta `Instance::render_frame`.
Encaja directo en el bucle Elm de Llimphi vía `Handle::spawn_periodic`.
---
Parte de **llimphi** — ver [llimphi](../LEEME.md).
+30
View File
@@ -0,0 +1,30 @@
# llimphi-anim
A Rive-style, **clip-agnostic** animation state machine.
It is the step above linear playback: instead of "play this animation", it models
"given these *inputs*, which state am I in and which do I transition to, with what
*blend*". That is what turns an animation *interactive* (hover, progress,
triggers) rather than a fixed loop.
## Why clip-agnostic
The core **knows nothing about Lottie or vello**. A clip is a `ClipId` (u32) with
a known duration; the machine sequences and blends clips by id and time, and
emits a `RenderFrame` saying *what* to render (the primary clip plus an incoming
clip with its 0..1 blend during a transition). The consumer maps
`ClipId → asset` and paints. Today that consumer is `llimphi-lottie`; tomorrow it
could be a native bone rig or a `llimphi-motion` tween — the same state engine
serves them all.
## Model (mirroring Rive's)
- **Inputs**: `bool`, `number`, `trigger` (triggers are consumed on each
`advance`).
- **State**: a clip with speed and looping, plus its outgoing transitions.
- **Transition**: a target + (AND-ed) conditions over inputs + a blend duration.
*Any-state* transitions are evaluated regardless of the current state.
---
Part of **llimphi** — see [llimphi](../README.md).
+182
View File
@@ -0,0 +1,182 @@
//! Constraints de rigging sobre la jerarquía de huesos (estilo Rive): IK de 2
//! huesos y aim. Corren **después** de que la animación posó el esqueleto,
//! ajustando rotaciones para satisfacer un objetivo; luego el skinning deforma.
//!
//! Convención: los huesos apuntan a lo largo de su eje local **+x** (su hijo se
//! traslada en +x, el efector está en +x) — la misma que arma `skel`. Todo en
//! 2D, analítico (sin iteración), sobre `kurbo`.
use kurbo::{Affine, Point, Vec2};
use crate::skel::{BoneId, Pose, Skeleton};
/// Ángulo de rotación de un `Affine` (asume escala ~uniforme y sin shear).
fn affine_rotation(aff: Affine) -> f64 {
let c = aff.as_coeffs(); // [a, b, c, d, e, f]; a=cosθ·sx, b=sinθ·sx
c[1].atan2(c[0])
}
/// Origen (punto de pivote) de un hueso en mundo. La rotación del propio hueso
/// no mueve su origen, así que sirve aunque vayamos a re-rotarlo.
fn bone_origin(skel: &Skeleton, bone: BoneId) -> Point {
skel.world(bone) * Point::ZERO
}
fn parent_rotation(skel: &Skeleton, bone: BoneId) -> f64 {
match skel.parent(bone) {
Some(p) => affine_rotation(skel.world(p)),
None => 0.0,
}
}
/// Resuelve **IK de 2 huesos**: ajusta las rotaciones locales de `upper` (padre)
/// y `lower` (hijo de `upper`) para que el efector — el punto `tip_local` en el
/// frame de `lower` — alcance `target` (en espacio de mundo del esqueleto).
/// `flip` elige la solución del codo (arriba/abajo). Si `target` está fuera de
/// alcance, el brazo se estira hacia él (alcance máximo). Llama `update` al final.
///
/// Requiere que los world transforms estén actualizados antes (la animación ya
/// posó el esqueleto). Las longitudes salen del rig: `L1` = offset `upper→lower`,
/// `L2` = `|tip_local|`.
pub fn solve_two_bone_ik(
skel: &mut Skeleton,
upper: BoneId,
lower: BoneId,
tip_local: Vec2,
target: Point,
flip: bool,
) {
let base = bone_origin(skel, upper);
let l1 = skel.pose(lower).translation.hypot();
let l2 = tip_local.hypot();
if l1 < 1e-9 || l2 < 1e-9 {
return;
}
let s = if flip { -1.0 } else { 1.0 };
let d = target - base;
let dist = d.hypot().clamp((l1 - l2).abs() + 1e-6, l1 + l2 - 1e-6);
let alpha = d.y.atan2(d.x);
let cos_beta = ((dist * dist + l1 * l1 - l2 * l2) / (2.0 * dist * l1)).clamp(-1.0, 1.0);
let beta = cos_beta.acos();
let cos_elbow = ((l1 * l1 + l2 * l2 - dist * dist) / (2.0 * l1 * l2)).clamp(-1.0, 1.0);
let elbow = cos_elbow.acos();
let theta_upper_world = alpha - s * beta;
let theta_lower_local = s * (std::f64::consts::PI - elbow);
let up = skel.pose(upper);
skel.set_pose(
upper,
Pose {
rotation: theta_upper_world - parent_rotation(skel, upper),
..up
},
);
let low = skel.pose(lower);
skel.set_pose(
lower,
Pose {
rotation: theta_lower_local,
..low
},
);
skel.update();
}
/// **Aim**: rota `bone` para que su eje local `forward` apunte desde su origen
/// hacia `target` (mundo). Llama `update`. Útil para que una cabeza/ojo/torreta
/// siga a un objetivo.
pub fn aim_at(skel: &mut Skeleton, bone: BoneId, forward: Vec2, target: Point) {
let origin = bone_origin(skel, bone);
let d = target - origin;
if d.hypot() < 1e-9 || forward.hypot() < 1e-9 {
return;
}
let desired_world = d.y.atan2(d.x) - forward.y.atan2(forward.x);
let pose = skel.pose(bone);
skel.set_pose(
bone,
Pose {
rotation: desired_world - parent_rotation(skel, bone),
..pose
},
);
skel.update();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skel::Pose;
fn two_bone(l1: f64) -> (Skeleton, BoneId, BoneId) {
let mut s = Skeleton::new();
let a = s.add_bone(None, Pose::identity());
let b = s.add_bone(Some(a), Pose::translate(Vec2::new(l1, 0.0)));
s.bind();
s.update();
(s, a, b)
}
fn tip(skel: &Skeleton, lower: BoneId, tip_local: Vec2) -> Point {
skel.world(lower) * Point::new(tip_local.x, tip_local.y)
}
#[test]
fn ik_alcanza_un_objetivo_dentro_de_rango() {
let (mut s, a, b) = two_bone(50.0);
let tip_local = Vec2::new(50.0, 0.0); // L2 = 50, alcance 0..100
let target = Point::new(60.0, 40.0); // dist ≈ 72 < 100
solve_two_bone_ik(&mut s, a, b, tip_local, target, false);
let t = tip(&s, b, tip_local);
assert!(
(t.x - target.x).abs() < 1e-6 && (t.y - target.y).abs() < 1e-6,
"tip {t:?} debería alcanzar {target:?}"
);
}
#[test]
fn ik_fuera_de_rango_se_estira() {
let (mut s, a, b) = two_bone(50.0);
let tip_local = Vec2::new(50.0, 0.0);
let target = Point::new(300.0, 0.0); // muy lejos → estira a ~100 en x
solve_two_bone_ik(&mut s, a, b, tip_local, target, false);
let t = tip(&s, b, tip_local);
assert!((t.x - 100.0).abs() < 1e-3, "x del tip {} ~ 100", t.x);
assert!(t.y.abs() < 1e-3, "y del tip {} ~ 0 (en línea al objetivo)", t.y);
}
#[test]
fn ik_flip_da_el_otro_codo() {
let (mut s, a, b) = two_bone(50.0);
let tip_local = Vec2::new(50.0, 0.0);
let target = Point::new(60.0, 40.0);
solve_two_bone_ik(&mut s, a, b, tip_local, target, false);
let elbow_down = bone_origin(&s, b);
solve_two_bone_ik(&mut s, a, b, tip_local, target, true);
let elbow_up = bone_origin(&s, b);
// Ambas soluciones alcanzan el target, pero el codo cae en lados
// opuestos de la línea base→target.
assert!(
(elbow_down.y - elbow_up.y).abs() > 1.0,
"los codos deberían diferir: {elbow_down:?} vs {elbow_up:?}"
);
let t = tip(&s, b, tip_local);
assert!((t.x - target.x).abs() < 1e-6 && (t.y - target.y).abs() < 1e-6);
}
#[test]
fn aim_apunta_el_eje_forward_al_objetivo() {
let mut s = Skeleton::new();
let bone = s.add_bone(None, Pose::identity());
s.bind();
s.update();
aim_at(&mut s, bone, Vec2::new(1.0, 0.0), Point::new(0.0, 10.0));
// El eje +x del hueso ahora debe apuntar hacia +y (al objetivo).
let dir = s.world(bone) * Point::new(1.0, 0.0);
assert!(dir.x.abs() < 1e-9 && dir.y > 0.0, "dir {dir:?} debería ser +y");
}
}
+758
View File
@@ -0,0 +1,758 @@
//! `llimphi-anim` — máquina de estados de animación, estilo Rive, **clip-agnóstica**.
//!
//! Es el escalón sobre el playback lineal: en vez de "reproduce esta animación",
//! modela "según estos *inputs*, en qué estado estoy y a cuál transiciono, con
//! qué *blend*". Es lo que vuelve una animación *interactiva* (hover, progreso,
//! triggers) en lugar de un loop fijo.
//!
//! ## Por qué clip-agnóstico
//!
//! El núcleo **no sabe de Lottie ni de vello**. Un clip es un [`ClipId`] (u32)
//! con una duración conocida; la máquina secuencia y mezcla clips por id y
//! tiempo, y emite un [`RenderFrame`] que dice *qué* renderizar (clip primario +
//! un clip entrante con su mezcla 0..1 durante una transición). El consumidor
//! mapea `ClipId → asset` y pinta. Hoy ese consumidor es `llimphi-lottie`;
//! mañana puede ser un rig de huesos nativo o un tween de `llimphi-motion` — el
//! mismo motor de estados sirve a todos.
//!
//! ## Modelo (espejo del de Rive)
//!
//! - **Inputs**: `bool`, `number`, `trigger` (los triggers se consumen en cada
//! `advance`).
//! - **Estado**: un clip con velocidad y loop, más sus transiciones salientes.
//! - **Transición**: destino + condiciones (AND) sobre inputs + duración de
//! blend. Las transiciones *any-state* se evalúan sin importar el estado
//! actual (típico para "fire trigger → ir a X desde donde sea").
//! - **Condición**: `bool == v`, `number <cmp> v`, `trigger disparado`, o
//! `clip terminó` (para "cuando la animación acaba, pasa a la siguiente").
//!
//! ## Bucle
//!
//! El consumidor empuja inputs ([`Instance::set_bool`] etc.), llama
//! [`Instance::advance`] con el `dt` del frame, y pinta [`Instance::render_frame`].
//! Encaja directo en el bucle Elm de Llimphi vía `Handle::spawn_periodic`.
#![forbid(unsafe_code)]
pub mod constraint;
pub mod physics;
pub mod skel;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
/// Índice de un estado dentro de la máquina (devuelto por `add_state`).
pub type StateId = usize;
/// Identificador opaco de un clip de animación. Lo interpreta el consumidor
/// (p. ej. índice en un `Vec<LottieAsset>` o clave de un mapa).
pub type ClipId = u32;
/// Operador de comparación para condiciones sobre inputs numéricos.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cmp {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
}
impl Cmp {
fn test(self, a: f64, b: f64) -> bool {
match self {
Cmp::Eq => a == b,
Cmp::Ne => a != b,
Cmp::Lt => a < b,
Cmp::Le => a <= b,
Cmp::Gt => a > b,
Cmp::Ge => a >= b,
}
}
}
/// Condición de guarda de una transición. Una transición dispara cuando **todas**
/// sus condiciones se cumplen (AND).
#[derive(Debug, Clone)]
pub enum Condition {
/// El input booleano `name` vale `value` (default `false` si no está seteado).
Bool { name: String, value: bool },
/// El input numérico `name` comparado con `value` por `op` (default `0.0`).
Number { name: String, op: Cmp, value: f64 },
/// El trigger `name` fue disparado en este frame (se consume al `advance`).
Trigger { name: String },
/// El clip del estado actual terminó (sólo aplica a estados no-loop).
ClipDone,
}
impl Condition {
/// `bool == value`.
pub fn bool(name: impl Into<String>, value: bool) -> Self {
Condition::Bool {
name: name.into(),
value,
}
}
/// `number <op> value`.
pub fn number(name: impl Into<String>, op: Cmp, value: f64) -> Self {
Condition::Number {
name: name.into(),
op,
value,
}
}
/// trigger disparado.
pub fn trigger(name: impl Into<String>) -> Self {
Condition::Trigger { name: name.into() }
}
/// el clip actual terminó.
pub fn clip_done() -> Self {
Condition::ClipDone
}
}
/// Región sensible al puntero, en coordenadas **normalizadas** `0..1` sobre el
/// rect donde se pinta la animación (origen arriba-izquierda). Resolución- e
/// independiente del tamaño del clip: el consumidor mapea el puntero de pantalla
/// a este espacio.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Area {
/// Toda la superficie de la animación.
All,
/// Un rectángulo normalizado.
Rect { x: f64, y: f64, w: f64, h: f64 },
}
impl Area {
fn contains(&self, px: f64, py: f64) -> bool {
match self {
Area::All => (0.0..=1.0).contains(&px) && (0.0..=1.0).contains(&py),
Area::Rect { x, y, w, h } => {
px >= *x && px <= x + w && py >= *y && py <= y + h
}
}
}
}
/// Qué evento de puntero dispara un [`Listener`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PointerTrigger {
/// El puntero entró al área.
Enter,
/// El puntero salió del área.
Exit,
/// Se presionó el botón con el puntero dentro del área.
Down,
/// Se soltó el botón con el puntero dentro del área.
Up,
/// El puntero se movió dentro del área (cada movimiento).
Move,
}
/// Qué le hace un [`Listener`] a los inputs cuando dispara.
#[derive(Debug, Clone)]
pub enum Action {
/// Setea un input booleano.
SetBool { name: String, value: bool },
/// Dispara un trigger.
Fire { name: String },
}
impl Action {
pub fn set_bool(name: impl Into<String>, value: bool) -> Self {
Action::SetBool {
name: name.into(),
value,
}
}
pub fn fire(name: impl Into<String>) -> Self {
Action::Fire { name: name.into() }
}
}
/// Un *listener* estilo Rive: cuando el puntero hace `trigger` sobre `area`,
/// aplica `action` a los inputs. Es el puente puntero → máquina de estados
/// (hover/click → bool/trigger → transición).
#[derive(Debug, Clone)]
pub struct Listener {
pub area: Area,
pub trigger: PointerTrigger,
pub action: Action,
}
/// Una transición saliente: a qué estado, bajo qué condiciones, con cuánto blend.
#[derive(Debug, Clone)]
pub struct Transition {
pub to: StateId,
pub conditions: Vec<Condition>,
/// Duración del crossfade hacia el estado destino, en segundos. `0.0` =
/// instantáneo (sin mezcla).
pub duration_secs: f64,
}
/// Un estado de la máquina: un clip con su velocidad y loop, más sus salidas.
#[derive(Debug, Clone)]
struct State {
name: String,
clip: ClipId,
speed: f64,
looping: bool,
transitions: Vec<Transition>,
}
/// La máquina de estados *definición* (inmutable, compartible). Construida con
/// `new` + `add_state`/`transition`, congelada en un `Arc` por `instance`.
#[derive(Debug, Clone, Default)]
pub struct StateMachine {
states: Vec<State>,
/// Duración conocida de cada clip (segundos). La necesita la condición
/// `ClipDone` y el wrap de tiempo.
clip_durations: HashMap<ClipId, f64>,
/// Transiciones evaluadas desde *cualquier* estado.
any: Vec<Transition>,
/// Listeners de puntero (hover/click → inputs).
listeners: Vec<Listener>,
entry: StateId,
}
impl StateMachine {
pub fn new() -> Self {
Self::default()
}
/// Agrega un estado-clip. Devuelve su `StateId`. El primero agregado es el
/// estado de entrada por defecto (cambialo con [`set_entry`]).
///
/// [`set_entry`]: StateMachine::set_entry
pub fn add_state(
&mut self,
name: impl Into<String>,
clip: ClipId,
speed: f64,
looping: bool,
) -> StateId {
let id = self.states.len();
self.states.push(State {
name: name.into(),
clip,
speed,
looping,
transitions: Vec::new(),
});
id
}
/// Registra la duración (segundos) de un clip — necesaria para `ClipDone`.
pub fn set_clip_duration(&mut self, clip: ClipId, secs: f64) {
self.clip_durations.insert(clip, secs);
}
/// Fija el estado de entrada.
pub fn set_entry(&mut self, entry: StateId) {
self.entry = entry;
}
/// Agrega una transición `from → to` con condiciones y blend.
pub fn transition(
&mut self,
from: StateId,
to: StateId,
conditions: Vec<Condition>,
duration_secs: f64,
) {
self.states[from].transitions.push(Transition {
to,
conditions,
duration_secs,
});
}
/// Agrega una transición *any-state* `* → to` (se evalúa desde cualquier
/// estado, antes que las salientes del estado actual).
pub fn transition_any(
&mut self,
to: StateId,
conditions: Vec<Condition>,
duration_secs: f64,
) {
self.any.push(Transition {
to,
conditions,
duration_secs,
});
}
/// Agrega un listener de puntero: cuando el puntero hace `trigger` sobre
/// `area`, aplica `action` a los inputs (que luego rigen transiciones).
pub fn listener(&mut self, area: Area, trigger: PointerTrigger, action: Action) {
self.listeners.push(Listener {
area,
trigger,
action,
});
}
/// Crea una instancia ejecutable de esta máquina.
pub fn instance(self) -> Instance {
let entry = self.entry;
Instance {
machine: Arc::new(self),
inputs: Inputs::default(),
current: entry,
state_time: 0.0,
active: None,
last_pointer: None,
}
}
fn clip_of(&self, state: StateId) -> ClipId {
self.states[state].clip
}
fn speed_of(&self, state: StateId) -> f64 {
self.states[state].speed
}
fn duration_of_clip(&self, clip: ClipId) -> Option<f64> {
self.clip_durations.get(&clip).copied()
}
}
/// Estado de los inputs de una instancia. Booleanos y números persisten; los
/// triggers se limpian al final de cada `advance`.
#[derive(Debug, Clone, Default)]
struct Inputs {
bools: HashMap<String, bool>,
numbers: HashMap<String, f64>,
triggers: HashSet<String>,
}
/// Una transición en curso: hacia dónde, su tiempo propio y cuánto blend lleva.
#[derive(Debug, Clone)]
struct Active {
to: StateId,
to_time: f64,
elapsed: f64,
duration: f64,
}
/// Instancia ejecutable: inputs vivos + estado actual + transición en curso.
/// `Clone` barato más allá de los inputs (la definición va por `Arc`).
#[derive(Debug, Clone)]
pub struct Instance {
machine: Arc<StateMachine>,
inputs: Inputs,
current: StateId,
state_time: f64,
active: Option<Active>,
/// Última posición del puntero en coords normalizadas `0..1` (`None` =
/// fuera de la animación). La usan los listeners de `Down`/`Up` y la
/// detección de `Enter`/`Exit`.
last_pointer: Option<(f64, f64)>,
}
/// Una muestra de clip a renderizar: qué clip y en qué instante (segundos).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ClipSample {
pub clip: ClipId,
pub time_secs: f64,
}
/// Lo que el consumidor debe pintar este frame: el clip primario y, si hay una
/// transición en curso, el clip entrante con su mezcla (`0.0`=nada del entrante,
/// `1.0`=todo el entrante).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RenderFrame {
pub primary: ClipSample,
pub blend: Option<(ClipSample, f32)>,
}
impl Instance {
/// Setea un input booleano.
pub fn set_bool(&mut self, name: impl Into<String>, value: bool) {
self.inputs.bools.insert(name.into(), value);
}
/// Setea un input numérico.
pub fn set_number(&mut self, name: impl Into<String>, value: f64) {
self.inputs.numbers.insert(name.into(), value);
}
/// Dispara un trigger (se consume en el próximo `advance`).
pub fn fire(&mut self, name: impl Into<String>) {
self.inputs.triggers.insert(name.into());
}
/// Nombre del estado actual (de origen mientras hay una transición en curso).
pub fn current_state(&self) -> &str {
&self.machine.states[self.current].name
}
/// ¿Hay una transición (crossfade) en curso?
pub fn is_transitioning(&self) -> bool {
self.active.is_some()
}
/// ¿El clip del estado actual terminó? (`false` si el estado es loop o si no
/// se registró su duración.)
fn clip_done(&self) -> bool {
let st = &self.machine.states[self.current];
if st.looping {
return false;
}
match self.machine.duration_of_clip(st.clip) {
Some(dur) => self.state_time >= dur,
None => false,
}
}
fn cond_met(&self, c: &Condition, clip_done: bool) -> bool {
match c {
Condition::Bool { name, value } => {
self.inputs.bools.get(name).copied().unwrap_or(false) == *value
}
Condition::Number { name, op, value } => {
op.test(self.inputs.numbers.get(name).copied().unwrap_or(0.0), *value)
}
Condition::Trigger { name } => self.inputs.triggers.contains(name),
Condition::ClipDone => clip_done,
}
}
fn transition_ready(&self, t: &Transition, clip_done: bool) -> bool {
// Sin condiciones = nunca dispara sola (evita loops degenerados);
// las transiciones útiles siempre tienen al menos una guarda.
!t.conditions.is_empty() && t.conditions.iter().all(|c| self.cond_met(c, clip_done))
}
/// Busca la primera transición disparable: any-state primero, luego las
/// salientes del estado actual. No retorna self-transiciones (to == current).
fn pick_transition(&self, clip_done: bool) -> Option<Transition> {
self.machine
.any
.iter()
.chain(self.machine.states[self.current].transitions.iter())
.find(|t| t.to != self.current && self.transition_ready(t, clip_done))
.cloned()
}
fn begin(&mut self, t: Transition) {
if t.duration_secs <= 0.0 {
// Instantáneo: saltamos sin blend.
self.current = t.to;
self.state_time = 0.0;
self.active = None;
} else {
self.active = Some(Active {
to: t.to,
to_time: 0.0,
elapsed: 0.0,
duration: t.duration_secs,
});
}
}
/// Avanza la transición en curso (si la hay) por `dt` y la confirma cuando
/// el blend completa. Aislado para poder aplicarlo tanto a una transición
/// que ya venía como a una recién arrancada en este mismo frame.
fn step_active(&mut self, dt: f64) {
let Some(at) = self.active.as_ref() else {
return;
};
let to = at.to;
let speed = self.machine.speed_of(to);
let at = self.active.as_mut().unwrap();
at.to_time += dt * speed;
at.elapsed += dt;
if at.elapsed >= at.duration {
let to_time = at.to_time;
self.current = to;
self.state_time = to_time;
self.active = None;
}
}
fn apply_action(&mut self, action: &Action) {
match action {
Action::SetBool { name, value } => {
self.inputs.bools.insert(name.clone(), *value);
}
Action::Fire { name } => {
self.inputs.triggers.insert(name.clone());
}
}
}
/// Reporta la posición del puntero en coords **normalizadas** `0..1` sobre
/// el rect de la animación, o `None` si el puntero salió. Dispara los
/// listeners `Enter`/`Exit`/`Move` correspondientes y deja la posición
/// guardada para los de `Down`/`Up`.
pub fn pointer_move(&mut self, pos: Option<(f64, f64)>) {
let machine = self.machine.clone();
let was = self.last_pointer;
for l in &machine.listeners {
let was_in = was.map_or(false, |(x, y)| l.area.contains(x, y));
let now_in = pos.map_or(false, |(x, y)| l.area.contains(x, y));
let fire = match l.trigger {
PointerTrigger::Enter => !was_in && now_in,
PointerTrigger::Exit => was_in && !now_in,
PointerTrigger::Move => now_in,
_ => false,
};
if fire {
self.apply_action(&l.action);
}
}
self.last_pointer = pos;
}
/// Botón presionado: dispara los listeners `Down` cuya área contiene la
/// última posición conocida del puntero.
pub fn pointer_down(&mut self) {
self.pointer_button(PointerTrigger::Down);
}
/// Botón soltado: dispara los listeners `Up`.
pub fn pointer_up(&mut self) {
self.pointer_button(PointerTrigger::Up);
}
fn pointer_button(&mut self, which: PointerTrigger) {
let Some((px, py)) = self.last_pointer else {
return;
};
let machine = self.machine.clone();
for l in &machine.listeners {
if l.trigger == which && l.area.contains(px, py) {
self.apply_action(&l.action);
}
}
}
/// Avanza la máquina `dt` segundos: corre el tiempo del estado, avanza/
/// confirma la transición en curso, evalúa una nueva transición (y le aplica
/// el `dt` de este frame si arranca) y consume los triggers.
pub fn advance(&mut self, dt: f64) {
let dt = dt.max(0.0);
self.state_time += dt * self.machine.speed_of(self.current);
// Transición que ya venía de frames anteriores.
self.step_active(dt);
// Nueva transición sólo si no estamos mezclando (sin interrupción de
// blend en el MVP). Si arranca, le aplicamos el dt de este frame para
// que el blend progrese (y se complete si el dt es grande).
if self.active.is_none() {
let done = self.clip_done();
if let Some(t) = self.pick_transition(done) {
self.begin(t);
self.step_active(dt);
}
}
self.inputs.triggers.clear();
}
/// Qué pintar este frame. Durante una transición, `primary` es el estado de
/// origen y `blend` el entrante con su mezcla 0..1.
pub fn render_frame(&self) -> RenderFrame {
let primary = ClipSample {
clip: self.machine.clip_of(self.current),
time_secs: self.state_time,
};
let blend = self.active.as_ref().map(|at| {
let mix = (at.elapsed / at.duration).clamp(0.0, 1.0) as f32;
(
ClipSample {
clip: self.machine.clip_of(at.to),
time_secs: at.to_time,
},
mix,
)
});
RenderFrame { primary, blend }
}
}
#[cfg(test)]
mod tests {
use super::*;
const IDLE: ClipId = 0;
const WALK: ClipId = 1;
const JUMP: ClipId = 2;
/// idle ⇄ walk por un bool "moving", con blend de 0.2 s.
fn idle_walk() -> StateMachine {
let mut sm = StateMachine::new();
let idle = sm.add_state("idle", IDLE, 1.0, true);
let walk = sm.add_state("walk", WALK, 1.0, true);
sm.set_entry(idle);
sm.transition(idle, walk, vec![Condition::bool("moving", true)], 0.2);
sm.transition(walk, idle, vec![Condition::bool("moving", false)], 0.2);
sm
}
#[test]
fn arranca_en_entry() {
let inst = idle_walk().instance();
assert_eq!(inst.current_state(), "idle");
assert_eq!(inst.render_frame().primary.clip, IDLE);
assert!(inst.render_frame().blend.is_none());
}
#[test]
fn bool_dispara_transicion_con_blend() {
let mut inst = idle_walk().instance();
inst.set_bool("moving", true);
inst.advance(0.1); // arranca la transición (dur 0.2)
assert!(inst.is_transitioning(), "debería estar mezclando");
let rf = inst.render_frame();
assert_eq!(rf.primary.clip, IDLE); // origen
let (incoming, mix) = rf.blend.expect("hay blend");
assert_eq!(incoming.clip, WALK);
assert!((mix - 0.5).abs() < 1e-6, "0.1/0.2 = 0.5, fue {mix}");
// Completar el blend.
inst.advance(0.15);
assert!(!inst.is_transitioning());
assert_eq!(inst.current_state(), "walk");
assert!(inst.render_frame().blend.is_none());
}
#[test]
fn vuelve_cuando_el_bool_se_apaga() {
let mut inst = idle_walk().instance();
inst.set_bool("moving", true);
inst.advance(0.3); // idle→walk completo
assert_eq!(inst.current_state(), "walk");
inst.set_bool("moving", false);
inst.advance(0.3); // walk→idle completo
assert_eq!(inst.current_state(), "idle");
}
#[test]
fn trigger_es_de_un_solo_frame() {
let mut sm = idle_walk();
let jump = sm.add_state("jump", JUMP, 1.0, false);
sm.set_clip_duration(JUMP, 0.5);
// any-state: fire "jump" → jump (instantáneo)
sm.transition_any(jump, vec![Condition::trigger("jump")], 0.0);
// jump termina → vuelve a idle
sm.transition(jump, 0, vec![Condition::clip_done()], 0.0);
let mut inst = sm.instance();
inst.fire("jump");
inst.advance(0.016);
assert_eq!(inst.current_state(), "jump");
// El trigger se consumió: avanzar de nuevo no re-dispara nada raro.
inst.advance(0.016);
assert_eq!(inst.current_state(), "jump");
// Cuando el clip de 0.5 s termina, vuelve a idle.
inst.advance(0.5);
assert_eq!(inst.current_state(), "idle");
}
#[test]
fn condicion_numerica() {
let mut sm = StateMachine::new();
let slow = sm.add_state("slow", IDLE, 1.0, true);
let fast = sm.add_state("fast", WALK, 1.0, true);
sm.set_entry(slow);
sm.transition(
slow,
fast,
vec![Condition::number("speed", Cmp::Gt, 5.0)],
0.0,
);
let mut inst = sm.instance();
inst.set_number("speed", 3.0);
inst.advance(0.016);
assert_eq!(inst.current_state(), "slow");
inst.set_number("speed", 9.0);
inst.advance(0.016);
assert_eq!(inst.current_state(), "fast");
}
#[test]
fn transicion_instantanea_sin_blend() {
let mut sm = StateMachine::new();
let a = sm.add_state("a", IDLE, 1.0, true);
let b = sm.add_state("b", WALK, 1.0, true);
sm.set_entry(a);
sm.transition(a, b, vec![Condition::bool("go", true)], 0.0);
let mut inst = sm.instance();
inst.set_bool("go", true);
inst.advance(0.016);
assert!(!inst.is_transitioning());
assert_eq!(inst.current_state(), "b");
}
#[test]
fn hover_enter_exit_maneja_un_bool() {
// idle ⇄ walk por "moving"; hover sobre toda la animación setea moving.
let mut sm = idle_walk();
sm.listener(Area::All, PointerTrigger::Enter, Action::set_bool("moving", true));
sm.listener(Area::All, PointerTrigger::Exit, Action::set_bool("moving", false));
let mut inst = sm.instance();
// Puntero entra al centro → moving=true → transiciona a walk.
inst.pointer_move(Some((0.5, 0.5)));
inst.advance(0.3);
assert_eq!(inst.current_state(), "walk");
// Puntero sale → moving=false → vuelve a idle.
inst.pointer_move(None);
inst.advance(0.3);
assert_eq!(inst.current_state(), "idle");
}
#[test]
fn click_dispara_trigger_en_su_area() {
let mut sm = idle_walk();
let jump = sm.add_state("jump", JUMP, 1.0, false);
sm.set_clip_duration(JUMP, 0.5);
sm.transition_any(jump, vec![Condition::trigger("tap")], 0.0);
sm.transition(jump, 0, vec![Condition::clip_done()], 0.0);
// Sólo la mitad derecha responde al click.
sm.listener(
Area::Rect { x: 0.5, y: 0.0, w: 0.5, h: 1.0 },
PointerTrigger::Down,
Action::fire("tap"),
);
let mut inst = sm.instance();
// Click en la mitad IZQUIERDA: fuera del área, no dispara.
inst.pointer_move(Some((0.2, 0.5)));
inst.pointer_down();
inst.advance(0.016);
assert_eq!(inst.current_state(), "idle");
// Click en la mitad DERECHA: dispara "tap" → jump.
inst.pointer_move(Some((0.8, 0.5)));
inst.pointer_down();
inst.advance(0.016);
assert_eq!(inst.current_state(), "jump");
}
#[test]
fn move_fuera_del_area_no_dispara() {
let mut sm = idle_walk();
sm.listener(
Area::Rect { x: 0.0, y: 0.0, w: 0.4, h: 0.4 },
PointerTrigger::Enter,
Action::set_bool("moving", true),
);
let mut inst = sm.instance();
// Entra pero a una zona fuera del rect del listener.
inst.pointer_move(Some((0.9, 0.9)));
inst.advance(0.3);
assert_eq!(inst.current_state(), "idle", "fuera del área no debe disparar");
}
#[test]
fn la_velocidad_escala_el_tiempo() {
let mut sm = StateMachine::new();
let s = sm.add_state("s", IDLE, 2.0, true);
sm.set_entry(s);
let mut inst = sm.instance();
inst.advance(1.0);
// speed 2.0 → 1 s real = 2 s de clip.
assert!((inst.render_frame().primary.time_secs - 2.0).abs() < 1e-9);
}
}
+284
View File
@@ -0,0 +1,284 @@
//! Física de partículas (Verlet) + constraints de distancia, para mover
//! esqueletos con **leyes físicas**: cuerdas, péndulos, ragdolls que caen y se
//! balancean bajo gravedad, con piso y paredes.
//!
//! El patrón es: una cadena de [`Particle`]s unidas por [`Link`]s (distancias
//! rígidas = longitudes de hueso) se integra con gravedad; luego
//! [`pose_chain_from_points`] convierte las posiciones de las partículas en poses
//! de una cadena de huesos, y el skinning deforma la malla. Así la física maneja
//! el esqueleto, no una animación keyframeada.
//!
//! Verlet (en vez de Euler con velocidades explícitas) hace los constraints de
//! distancia triviales y estables: se resuelven por proyección iterativa.
use kurbo::{Point, Vec2};
use crate::skel::{BoneId, Skeleton};
/// Una partícula puntual. `inv_mass = 0` la fija (pin); `damping` global la frena.
#[derive(Debug, Clone, Copy)]
pub struct Particle {
pub pos: Point,
pub prev: Point,
pub inv_mass: f64,
}
impl Particle {
pub fn new(pos: Point, pinned: bool) -> Self {
Self {
pos,
prev: pos,
inv_mass: if pinned { 0.0 } else { 1.0 },
}
}
pub fn pinned(&self) -> bool {
self.inv_mass == 0.0
}
}
/// Restricción de distancia entre dos partículas (un "hueso" rígido).
#[derive(Debug, Clone, Copy)]
pub struct Link {
pub a: usize,
pub b: usize,
pub rest: f64,
/// 0..1: 1 = rígido, <1 = elástico.
pub stiffness: f64,
}
/// Mundo físico 2D: partículas + links + gravedad + piso/paredes.
#[derive(Debug, Clone)]
pub struct Physics {
pub particles: Vec<Particle>,
pub links: Vec<Link>,
pub gravity: Vec2,
/// Factor de retención de velocidad por step (0..1, ~0.99).
pub damping: f64,
/// Si está, las partículas no bajan de esta `y` (piso).
pub floor_y: Option<f64>,
/// Paredes `(x0, x1)` que contienen en x.
pub walls_x: Option<(f64, f64)>,
}
impl Default for Physics {
fn default() -> Self {
Self {
particles: Vec::new(),
links: Vec::new(),
gravity: Vec2::new(0.0, 980.0), // px/s² hacia abajo (y crece para abajo)
damping: 0.99,
floor_y: None,
walls_x: None,
}
}
}
impl Physics {
pub fn new() -> Self {
Self::default()
}
/// Agrega una partícula y devuelve su índice.
pub fn particle(&mut self, pos: Point, pinned: bool) -> usize {
let i = self.particles.len();
self.particles.push(Particle::new(pos, pinned));
i
}
/// Une dos partículas con `rest` = su distancia actual y rigidez total.
pub fn link(&mut self, a: usize, b: usize) {
let rest = (self.particles[b].pos - self.particles[a].pos).hypot();
self.links.push(Link { a, b, rest, stiffness: 1.0 });
}
/// Une dos partículas con una distancia de reposo y rigidez dadas.
pub fn link_with(&mut self, a: usize, b: usize, rest: f64, stiffness: f64) {
self.links.push(Link { a, b, rest, stiffness });
}
/// Empuja las partículas lejos de `center` dentro de `radius` (campo de
/// repulsión — p. ej. el cursor barriendo las cuerdas).
pub fn repel(&mut self, center: Point, radius: f64, strength: f64) {
for p in &mut self.particles {
if p.pinned() {
continue;
}
let d = p.pos - center;
let dist = d.hypot();
if dist < radius && dist > 1e-6 {
let push = (1.0 - dist / radius) * strength;
p.pos += d * (push / dist);
}
}
}
/// Un paso de simulación: integra (gravedad + inercia) y resuelve los
/// constraints `iterations` veces (más iteraciones = más rígido/estable).
pub fn step(&mut self, dt: f64, iterations: usize) {
let dt2 = dt * dt;
for p in &mut self.particles {
if p.pinned() {
continue;
}
let vel = (p.pos - p.prev) * self.damping;
p.prev = p.pos;
p.pos = p.pos + vel + self.gravity * dt2;
}
for _ in 0..iterations.max(1) {
self.solve_links();
self.solve_bounds();
}
}
fn solve_links(&mut self) {
for k in 0..self.links.len() {
let Link { a, b, rest, stiffness } = self.links[k];
let pa = self.particles[a].pos;
let pb = self.particles[b].pos;
let wa = self.particles[a].inv_mass;
let wb = self.particles[b].inv_mass;
let wsum = wa + wb;
if wsum < 1e-12 {
continue;
}
let delta = pb - pa;
let d = delta.hypot();
if d < 1e-9 {
continue;
}
let diff = (d - rest) / d * stiffness;
let corr = delta * diff;
self.particles[a].pos = pa + corr * (wa / wsum);
self.particles[b].pos = pb - corr * (wb / wsum);
}
}
fn solve_bounds(&mut self) {
for p in &mut self.particles {
if p.pinned() {
continue;
}
if let Some(fy) = self.floor_y {
if p.pos.y > fy {
p.pos.y = fy;
// Fricción: amortigua el deslizamiento horizontal en el piso.
p.prev.x = p.pos.x + (p.prev.x - p.pos.x) * 0.5;
}
}
if let Some((x0, x1)) = self.walls_x {
p.pos.x = p.pos.x.clamp(x0, x1);
}
}
}
/// Posiciones actuales de las partículas (para construir poses / pintar).
pub fn positions(&self) -> Vec<Point> {
self.particles.iter().map(|p| p.pos).collect()
}
}
/// Posa una cadena de huesos para que siga una cadena de puntos (las partículas
/// físicas). `points` tiene `bones.len() + 1` entradas: el hueso `i` va de
/// `points[i]` a `points[i+1]`. Setea la translación de la raíz a `points[0]` y
/// las rotaciones a los ángulos de cada segmento (relativos en la jerarquía).
/// Llama `update`. Es el puente física → esqueleto.
pub fn pose_chain_from_points(skel: &mut Skeleton, bones: &[BoneId], points: &[Point]) {
if bones.is_empty() || points.len() < bones.len() + 1 {
return;
}
let seg_angle = |i: usize| {
let d = points[i + 1] - points[i];
if d.hypot() < 1e-9 {
0.0
} else {
d.y.atan2(d.x)
}
};
// Raíz: su origen va a points[0]; rotación = ángulo del primer segmento.
let root = bones[0];
let mut rp = skel.pose(root);
rp.translation = points[0].to_vec2();
rp.rotation = seg_angle(0);
skel.set_pose(root, rp);
// Eslabones: rotación local = ángulo del segmento ángulo del anterior
// (la rotación world se acumula por la jerarquía).
for i in 1..bones.len() {
let mut p = skel.pose(bones[i]);
p.rotation = seg_angle(i) - seg_angle(i - 1);
skel.set_pose(bones[i], p);
}
skel.update();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skel::{Pose, Skeleton};
#[test]
fn la_gravedad_hace_caer_una_particula_libre() {
let mut w = Physics::new();
let i = w.particle(Point::new(0.0, 0.0), false);
w.step(0.1, 1);
assert!(w.particles[i].pos.y > 0.0, "debería caer (y crece)");
}
#[test]
fn una_particula_fija_no_se_mueve() {
let mut w = Physics::new();
let i = w.particle(Point::new(5.0, 5.0), true);
for _ in 0..10 {
w.step(0.1, 2);
}
assert_eq!(w.particles[i].pos, Point::new(5.0, 5.0));
}
#[test]
fn un_pendulo_cuelga_a_la_distancia_de_reposo() {
let mut w = Physics::new();
let anchor = w.particle(Point::new(0.0, 0.0), true);
let bob = w.particle(Point::new(0.0, 50.0), false);
w.link(anchor, bob); // rest = 50
for _ in 0..400 {
w.step(1.0 / 120.0, 8);
}
// En reposo cuelga recto hacia abajo a ~50 del ancla.
let p = w.particles[bob].pos;
assert!((p.x).abs() < 1.0, "x ~ 0, fue {}", p.x);
assert!((p.y - 50.0).abs() < 1.0, "y ~ 50, fue {}", p.y);
}
#[test]
fn el_piso_detiene_la_caida() {
let mut w = Physics::new();
w.floor_y = Some(100.0);
let i = w.particle(Point::new(0.0, 0.0), false);
for _ in 0..200 {
w.step(1.0 / 120.0, 2);
}
assert!(w.particles[i].pos.y <= 100.0 + 1e-6, "no pasa el piso");
assert!(w.particles[i].pos.y > 90.0, "llegó cerca del piso");
}
#[test]
fn pose_chain_sigue_los_puntos() {
// Cadena de 2 huesos; puntos en L vertical-luego-horizontal.
let mut s = Skeleton::new();
let a = s.add_bone(None, Pose::translate(Vec2::new(0.0, 0.0)));
let b = s.add_bone(Some(a), Pose::translate(Vec2::new(50.0, 0.0)));
s.bind();
let points = [
Point::new(0.0, 0.0),
Point::new(0.0, 50.0), // primer segmento apunta hacia abajo (+y)
Point::new(50.0, 50.0), // segundo apunta a la derecha (+x)
];
pose_chain_from_points(&mut s, &[a, b], &points);
// El origen del hueso b debe estar en points[1]; su punta (local 50,0) en points[2].
let b_origin = s.world(b) * Point::ZERO;
assert!((b_origin.x - 0.0).abs() < 1e-6 && (b_origin.y - 50.0).abs() < 1e-6, "{b_origin:?}");
let tip = s.world(b) * Point::new(50.0, 0.0);
assert!((tip.x - 50.0).abs() < 1e-6 && (tip.y - 50.0).abs() < 1e-6, "tip {tip:?}");
}
}
+616
View File
@@ -0,0 +1,616 @@
//! Jerarquía de huesos 2D + skinning ponderado de vértices (linear blend
//! skinning, LBS) — el sustrato de la deformación esqueletal estilo Rive.
//!
//! **Puro y sin renderer.** Este módulo sólo calcula geometría: dada una
//! jerarquía de huesos posada y una malla con pesos por vértice, produce las
//! **posiciones deformadas** de los vértices (`kurbo::Point`). Quién las pinta
//! (vello vía paths para malla vectorial, o clip+imagen para malla texturizada)
//! es trabajo del consumidor; el spike confirmó que ambas rutas funcionan en
//! vello 0.7.
//!
//! ## Bind pose vs pose actual
//!
//! El esqueleto se construye en su **bind pose** (la configuración en la que se
//! ató la malla) y se llama [`Skeleton::bind`] para congelarla: por cada hueso
//! se guarda su *inverse-bind* (el world transform inverso en el bind). Al
//! animar, se cambian las poses locales ([`Skeleton::set_pose`]),
//! [`Skeleton::update`] recompone los world transforms, y la matriz de skinning
//! de un hueso es `world_actual · inverse_bind`. En el bind pose esa matriz es
//! la identidad → los vértices quedan en reposo.
//!
//! ## LBS
//!
//! La posición deformada de un vértice es la media ponderada, sobre los huesos
//! que lo influyen, de aplicarle a su posición de reposo la matriz de skinning
//! de cada hueso: `v' = Σ_b w_b · (skin_b · v_rest)`.
use std::collections::HashMap;
use kurbo::{Affine, Point, Vec2};
use crate::RenderFrame;
/// Índice de un hueso dentro del esqueleto.
pub type BoneId = usize;
/// Pose local de un hueso (TRS) relativa a su padre. Se compone como
/// `translate · rotate · scale` — el orden estándar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Pose {
pub translation: Vec2,
/// Rotación en radianes (convención de kurbo).
pub rotation: f64,
pub scale: Vec2,
}
impl Default for Pose {
fn default() -> Self {
Self::identity()
}
}
impl Pose {
pub fn identity() -> Self {
Self {
translation: Vec2::ZERO,
rotation: 0.0,
scale: Vec2::new(1.0, 1.0),
}
}
pub fn translate(t: Vec2) -> Self {
Self {
translation: t,
..Self::identity()
}
}
pub fn rotate(r: f64) -> Self {
Self {
rotation: r,
..Self::identity()
}
}
/// Translación + rotación (lo más común al animar un hueso).
pub fn new(translation: Vec2, rotation: f64, scale: Vec2) -> Self {
Self {
translation,
rotation,
scale,
}
}
pub fn to_affine(&self) -> Affine {
Affine::translate(self.translation)
* Affine::rotate(self.rotation)
* Affine::scale_non_uniform(self.scale.x, self.scale.y)
}
}
#[derive(Debug, Clone)]
struct Bone {
parent: Option<BoneId>,
local: Pose,
}
/// Una jerarquía de huesos posable. Los huesos se agregan **padre antes que
/// hijo** (el índice del padre debe ser menor), así el cómputo de world
/// transforms es una sola pasada hacia adelante.
#[derive(Debug, Clone, Default)]
pub struct Skeleton {
bones: Vec<Bone>,
/// World transform inverso de cada hueso, capturado en `bind`.
inverse_bind: Vec<Affine>,
/// Scratch: world transforms actuales (recomputados por `update`).
world: Vec<Affine>,
}
impl Skeleton {
pub fn new() -> Self {
Self::default()
}
/// Agrega un hueso con `parent` (o `None` = raíz) y su pose local. Devuelve
/// su `BoneId`. **Pánico** si el padre no fue agregado antes (índice ≥ id).
pub fn add_bone(&mut self, parent: Option<BoneId>, local: Pose) -> BoneId {
if let Some(p) = parent {
assert!(
p < self.bones.len(),
"el padre {p} debe agregarse antes que el hijo"
);
}
let id = self.bones.len();
self.bones.push(Bone { parent, local });
self.inverse_bind.push(Affine::IDENTITY);
self.world.push(Affine::IDENTITY);
id
}
fn recompute_world(&mut self) {
for i in 0..self.bones.len() {
let local = self.bones[i].local.to_affine();
self.world[i] = match self.bones[i].parent {
Some(p) => self.world[p] * local,
None => local,
};
}
}
/// Congela la pose actual como **bind pose**: recompone los world transforms
/// y guarda el inverse-bind de cada hueso. Llamar una vez tras construir el
/// esqueleto en reposo (antes de animar).
pub fn bind(&mut self) {
self.recompute_world();
for i in 0..self.bones.len() {
self.inverse_bind[i] = self.world[i].inverse();
}
}
/// Cambia la pose local de un hueso (animar). Requiere [`update`] después
/// para que los world transforms reflejen el cambio.
///
/// [`update`]: Skeleton::update
pub fn set_pose(&mut self, bone: BoneId, local: Pose) {
self.bones[bone].local = local;
}
/// Pose local actual de un hueso.
pub fn pose(&self, bone: BoneId) -> Pose {
self.bones[bone].local
}
/// Recalcula los world transforms desde las poses actuales. Llamar tras
/// posear y antes de deformar/leer `world`/`skin_matrix`.
pub fn update(&mut self) {
self.recompute_world();
}
/// Matriz de skinning del hueso: `world_actual · inverse_bind`. En el bind
/// pose es la identidad.
pub fn skin_matrix(&self, bone: BoneId) -> Affine {
self.world[bone] * self.inverse_bind[bone]
}
/// World transform actual del hueso (útil para dibujar el hueso mismo o
/// adjuntar algo a su punta).
pub fn world(&self, bone: BoneId) -> Affine {
self.world[bone]
}
/// Padre de un hueso (`None` si es raíz). Lo usan los constraints para
/// convertir una rotación deseada en mundo a la pose local.
pub fn parent(&self, bone: BoneId) -> Option<BoneId> {
self.bones[bone].parent
}
pub fn len(&self) -> usize {
self.bones.len()
}
pub fn is_empty(&self) -> bool {
self.bones.is_empty()
}
}
/// Influencia de un hueso sobre un vértice. Idealmente los pesos de un vértice
/// suman 1; si no, `deform` normaliza.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Weight {
pub bone: BoneId,
pub weight: f64,
}
/// Un vértice de la malla: su posición de reposo (en model/bind space), su UV
/// `0..1` (para textura, lo usa el render de malla texturizada) y los huesos que
/// lo influyen.
#[derive(Debug, Clone)]
pub struct Vertex {
pub rest: Point,
pub uv: (f64, f64),
pub weights: Vec<Weight>,
}
impl Vertex {
/// Vértice rígido a un solo hueso (peso 1).
pub fn rigid(rest: Point, uv: (f64, f64), bone: BoneId) -> Self {
Self {
rest,
uv,
weights: vec![Weight { bone, weight: 1.0 }],
}
}
}
/// Una malla deformable: vértices con pesos + topología de triángulos (índices
/// a `vertices`). El render la consume tras `deform`.
#[derive(Debug, Clone, Default)]
pub struct Mesh {
pub vertices: Vec<Vertex>,
pub triangles: Vec<[u32; 3]>,
}
impl Mesh {
pub fn new() -> Self {
Self::default()
}
/// Calcula las posiciones deformadas (LBS) en `out`, una por vértice, en el
/// mismo orden que `self.vertices`. `skel` debe tener sus world transforms
/// actualizados ([`Skeleton::update`]). Reutiliza el buffer `out` (sin
/// asignar por frame).
pub fn deform_into(&self, skel: &Skeleton, out: &mut Vec<Point>) {
out.clear();
out.reserve(self.vertices.len());
for v in &self.vertices {
if v.weights.is_empty() {
out.push(v.rest);
continue;
}
let mut acc = Vec2::ZERO;
let mut wsum = 0.0;
for w in &v.weights {
let p = skel.skin_matrix(w.bone) * v.rest;
acc += p.to_vec2() * w.weight;
wsum += w.weight;
}
let p = if wsum.abs() > 1e-12 {
(acc * (1.0 / wsum)).to_point()
} else {
v.rest
};
out.push(p);
}
}
/// Variante que asigna y devuelve el `Vec` (conveniencia para tests/uso
/// ocasional; en el bucle de render preferí [`deform_into`]).
///
/// [`deform_into`]: Mesh::deform_into
pub fn deform(&self, skel: &Skeleton) -> Vec<Point> {
let mut out = Vec::new();
self.deform_into(skel, &mut out);
out
}
}
/// Interpola linealmente dos poses (translación/escala lerp, rotación lerp del
/// ángulo). Es el blend de poses que usa el crossfade esqueletal — mezclar
/// *poses* da una deformación correcta, a diferencia del crossfade de píxeles.
pub fn lerp_pose(a: Pose, b: Pose, f: f64) -> Pose {
let lerp = |x: f64, y: f64| x + (y - x) * f;
Pose {
translation: Vec2::new(
lerp(a.translation.x, b.translation.x),
lerp(a.translation.y, b.translation.y),
),
rotation: lerp(a.rotation, b.rotation),
scale: Vec2::new(lerp(a.scale.x, b.scale.x), lerp(a.scale.y, b.scale.y)),
}
}
/// Keyframe de pose de un hueso en un instante.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PoseKey {
pub t: f64,
pub pose: Pose,
}
/// Pista de animación de un hueso: keyframes de pose ordenados por tiempo.
#[derive(Debug, Clone, Default)]
pub struct BoneTrack {
pub bone: BoneId,
pub keys: Vec<PoseKey>,
}
impl BoneTrack {
/// Pose en el instante `t` (lineal entre keyframes, clamp en los extremos).
pub fn sample(&self, t: f64) -> Pose {
match self.keys.as_slice() {
[] => Pose::identity(),
[only] => only.pose,
keys => {
if t <= keys[0].t {
return keys[0].pose;
}
let last = keys[keys.len() - 1];
if t >= last.t {
return last.pose;
}
for w in keys.windows(2) {
let (a, b) = (w[0], w[1]);
if t >= a.t && t <= b.t {
let span = b.t - a.t;
let f = if span > 1e-12 { (t - a.t) / span } else { 0.0 };
return lerp_pose(a.pose, b.pose, f);
}
}
last.pose
}
}
}
}
/// Una animación esqueletal: pistas de pose por hueso, con duración y loop. Es
/// lo que un *estado* de la máquina reproduce (un clip = una `BoneAnimation`).
/// Los huesos no incluidos en ninguna pista conservan su pose actual.
#[derive(Debug, Clone, Default)]
pub struct BoneAnimation {
pub duration: f64,
pub looping: bool,
pub tracks: Vec<BoneTrack>,
}
impl BoneAnimation {
/// Samplea todas las pistas en `t` (envuelto por la duración si es loop) a
/// pares `(hueso, pose)`, reusando el buffer `out`.
pub fn sample_into(&self, t: f64, out: &mut Vec<(BoneId, Pose)>) {
let tt = if self.looping && self.duration > 0.0 {
t.rem_euclid(self.duration)
} else {
t
};
out.clear();
out.extend(self.tracks.iter().map(|tr| (tr.bone, tr.sample(tt))));
}
/// Posa el esqueleto con esta animación en el instante `t` y llama `update`.
pub fn apply(&self, skel: &mut Skeleton, t: f64) {
let mut buf = Vec::new();
self.sample_into(t, &mut buf);
for (b, p) in buf {
skel.set_pose(b, p);
}
skel.update();
}
}
/// Posa el esqueleto según el [`RenderFrame`] de una máquina de estados, usando
/// las `clips` ([`BoneAnimation`]) indexadas por `ClipId`. Durante una
/// transición blendea **poses** (no píxeles) por la mezcla del frame. Llama
/// `skel.update()` al final. Es el puente que une máquina-de-estados →
/// esqueleto: estado/transición deciden la pose, el skinning la convierte en
/// deformación.
///
/// [`RenderFrame`]: crate::RenderFrame
pub fn pose_from_render_frame(skel: &mut Skeleton, frame: &RenderFrame, clips: &[BoneAnimation]) {
let mut poses: HashMap<BoneId, Pose> = HashMap::new();
let mut buf = Vec::new();
if let Some(a) = clips.get(frame.primary.clip as usize) {
a.sample_into(frame.primary.time_secs, &mut buf);
for (b, p) in buf.drain(..) {
poses.insert(b, p);
}
}
if let Some((inc, mix)) = frame.blend {
if let Some(a) = clips.get(inc.clip as usize) {
a.sample_into(inc.time_secs, &mut buf);
let mix = mix as f64;
for (b, p) in buf.drain(..) {
poses
.entry(b)
.and_modify(|cur| *cur = lerp_pose(*cur, p, mix))
.or_insert(p);
}
}
}
for (b, p) in poses {
skel.set_pose(b, p);
}
skel.update();
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: Point, x: f64, y: f64) -> bool {
(a.x - x).abs() < 1e-9 && (a.y - y).abs() < 1e-9
}
#[test]
fn bind_pose_deja_los_vertices_en_reposo() {
let mut s = Skeleton::new();
let b = s.add_bone(None, Pose::translate(Vec2::new(5.0, 7.0)));
s.bind();
s.update();
let mut m = Mesh::new();
m.vertices.push(Vertex::rigid(Point::new(3.0, 4.0), (0.0, 0.0), b));
let d = m.deform(&s);
// Sin re-posar: la matriz de skinning es identidad → reposo intacto.
assert!(approx(d[0], 3.0, 4.0), "fue {:?}", d[0]);
}
#[test]
fn rotar_un_hueso_rota_su_vertice() {
let mut s = Skeleton::new();
let b = s.add_bone(None, Pose::identity());
s.bind();
// Rotar 90°: (10,0) → (0,10) en convención de kurbo.
s.set_pose(b, Pose::rotate(std::f64::consts::FRAC_PI_2));
s.update();
let mut m = Mesh::new();
m.vertices.push(Vertex::rigid(Point::new(10.0, 0.0), (0.0, 0.0), b));
let d = m.deform(&s);
assert!(approx(d[0], 0.0, 10.0), "fue {:?}", d[0]);
}
#[test]
fn cadena_padre_hijo_compone_transforms() {
let mut s = Skeleton::new();
let root = s.add_bone(None, Pose::identity());
let child = s.add_bone(Some(root), Pose::translate(Vec2::new(10.0, 0.0)));
s.bind();
// Rotar la raíz 90° rota rígidamente al hijo y su vértice.
s.set_pose(root, Pose::rotate(std::f64::consts::FRAC_PI_2));
s.update();
let mut m = Mesh::new();
m.vertices
.push(Vertex::rigid(Point::new(20.0, 0.0), (0.0, 0.0), child));
let d = m.deform(&s);
// (20,0) rotado 90° sobre el origen → (0,20).
assert!(approx(d[0], 0.0, 20.0), "fue {:?}", d[0]);
}
#[test]
fn peso_repartido_mezcla_dos_huesos() {
let mut s = Skeleton::new();
let a = s.add_bone(None, Pose::identity());
let b = s.add_bone(None, Pose::identity());
s.bind();
// A queda quieto; B se traslada (0,20).
s.set_pose(b, Pose::translate(Vec2::new(0.0, 20.0)));
s.update();
let mut m = Mesh::new();
m.vertices.push(Vertex {
rest: Point::new(0.0, 0.0),
uv: (0.5, 0.5),
weights: vec![
Weight { bone: a, weight: 0.5 },
Weight { bone: b, weight: 0.5 },
],
});
let d = m.deform(&s);
// 0.5·(0,0) + 0.5·(0,20) = (0,10).
assert!(approx(d[0], 0.0, 10.0), "fue {:?}", d[0]);
}
#[test]
fn pesos_sin_normalizar_se_normalizan() {
let mut s = Skeleton::new();
let a = s.add_bone(None, Pose::identity());
let b = s.add_bone(None, Pose::translate(Vec2::new(0.0, 10.0)));
s.bind();
s.update();
let mut m = Mesh::new();
// Pesos 2 y 2 (suman 4) → media ponderada, no suma cruda.
m.vertices.push(Vertex {
rest: Point::new(0.0, 0.0),
uv: (0.0, 0.0),
weights: vec![
Weight { bone: a, weight: 2.0 },
Weight { bone: b, weight: 2.0 },
],
});
let d = m.deform(&s);
// skin_a = id → (0,0); skin_b = id (b en bind pose, no re-posado) → (0,0).
// Ambos en reposo: (0,0). (Verifica que no explota por wsum=4.)
assert!(approx(d[0], 0.0, 0.0), "fue {:?}", d[0]);
}
#[test]
fn vertice_sin_pesos_queda_en_reposo() {
let mut s = Skeleton::new();
let _b = s.add_bone(None, Pose::rotate(1.0));
s.bind();
s.update();
let mut m = Mesh::new();
m.vertices.push(Vertex {
rest: Point::new(7.0, 7.0),
uv: (0.0, 0.0),
weights: vec![],
});
let d = m.deform(&s);
assert!(approx(d[0], 7.0, 7.0), "fue {:?}", d[0]);
}
#[test]
fn track_samplea_lineal_entre_keyframes() {
let tr = BoneTrack {
bone: 0,
keys: vec![
PoseKey { t: 0.0, pose: Pose::rotate(0.0) },
PoseKey { t: 2.0, pose: Pose::rotate(2.0) },
],
};
assert!((tr.sample(0.0).rotation - 0.0).abs() < 1e-9);
assert!((tr.sample(2.0).rotation - 2.0).abs() < 1e-9);
assert!((tr.sample(1.0).rotation - 1.0).abs() < 1e-9, "punto medio");
// Clamp fuera de rango.
assert!((tr.sample(-5.0).rotation - 0.0).abs() < 1e-9);
assert!((tr.sample(99.0).rotation - 2.0).abs() < 1e-9);
}
#[test]
fn animacion_en_loop_envuelve_el_tiempo() {
let anim = BoneAnimation {
duration: 2.0,
looping: true,
tracks: vec![BoneTrack {
bone: 0,
keys: vec![
PoseKey { t: 0.0, pose: Pose::rotate(0.0) },
PoseKey { t: 2.0, pose: Pose::rotate(2.0) },
],
}],
};
let mut buf = Vec::new();
anim.sample_into(3.0, &mut buf); // 3 % 2 = 1 → rotación ~1.0
assert!((buf[0].1.rotation - 1.0).abs() < 1e-9, "fue {}", buf[0].1.rotation);
}
#[test]
fn render_frame_simple_posa_el_esqueleto() {
let mut s = Skeleton::new();
let b = s.add_bone(None, Pose::identity());
s.bind();
let anim = BoneAnimation {
duration: 2.0,
looping: true,
tracks: vec![BoneTrack {
bone: b,
keys: vec![
PoseKey { t: 0.0, pose: Pose::identity() },
PoseKey {
t: 1.0,
pose: Pose::rotate(std::f64::consts::FRAC_PI_2),
},
],
}],
};
let clips = vec![anim];
let frame = crate::RenderFrame {
primary: crate::ClipSample { clip: 0, time_secs: 1.0 },
blend: None,
};
pose_from_render_frame(&mut s, &frame, &clips);
let mut m = Mesh::new();
m.vertices.push(Vertex::rigid(Point::new(10.0, 0.0), (0.0, 0.0), b));
let d = m.deform(&s);
assert!(approx(d[0], 0.0, 10.0), "fue {:?}", d[0]);
}
#[test]
fn render_frame_con_blend_mezcla_poses() {
let mut s = Skeleton::new();
let b = s.add_bone(None, Pose::identity());
s.bind();
// clip0: identidad; clip1: 90°. Blend 0.5 → 45°.
let rest = BoneAnimation {
duration: 1.0,
looping: true,
tracks: vec![BoneTrack {
bone: b,
keys: vec![PoseKey { t: 0.0, pose: Pose::identity() }],
}],
};
let turn = BoneAnimation {
duration: 1.0,
looping: true,
tracks: vec![BoneTrack {
bone: b,
keys: vec![PoseKey {
t: 0.0,
pose: Pose::rotate(std::f64::consts::FRAC_PI_2),
}],
}],
};
let clips = vec![rest, turn];
let frame = crate::RenderFrame {
primary: crate::ClipSample { clip: 0, time_secs: 0.0 },
blend: Some((crate::ClipSample { clip: 1, time_secs: 0.0 }, 0.5)),
};
pose_from_render_frame(&mut s, &frame, &clips);
let mut m = Mesh::new();
m.vertices.push(Vertex::rigid(Point::new(10.0, 0.0), (0.0, 0.0), b));
let d = m.deform(&s);
// 10·(cos45, sin45) ≈ (7.071, 7.071).
let c = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
assert!((d[0].x - c).abs() < 1e-9 && (d[0].y - c).abs() < 1e-9, "fue {:?}", d[0]);
}
}
+9 -9
View File
@@ -5,9 +5,9 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "llimphi-compositor — el núcleo declarativo de Llimphi sin winit: el árbol `View<Msg>`, el mount sobre taffy, el paint a `vello::Scene` y el hit-test. No depende de llimphi-hal ni de una surface concreta, así que la misma composición sirve sobre winit (llimphi-ui) o, a futuro, sobre el framebuffer del kernel wawa. `wgpu` entra sólo por la firma de `GpuPaintFn` (tipos, no windowing)."
repository.workspace = true
[dependencies]
llimphi-layout = { path = "../llimphi-layout", version = "0.1.0" }
llimphi-text = { path = "../llimphi-text", version = "0.1.0" }
@@ -19,17 +19,17 @@ wgpu = { workspace = true }
[dev-dependencies]
# Volcado headless del paint a PNG (llvmpipe) para VER sombra/gradiente/borde
# sin levantar ventana. Sólo capas inferiores — no llimphi-ui (sin ciclo).
llimphi-hal = { path = "../llimphi-hal" }
llimphi-raster = { path = "../llimphi-raster" }
llimphi-theme = { path = "../llimphi-theme" }
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" }
llimphi-theme = { path = "../llimphi-theme", version = "0.1.0" }
png = { workspace = true }
pollster = { workspace = true }
# Widgets REALES para el showreel: el `View<Msg>` que devuelven es el mismo
# tipo (`llimphi_compositor::View`, re-exportado por llimphi-ui), así que
# montan con el mount/paint/measure de este mismo crate. Dev-only → sin ciclo
# en el grafo normal (llimphi-ui depende de este crate, no al revés).
llimphi-widget-switch = { path = "../widgets/switch" }
llimphi-widget-slider = { path = "../widgets/slider" }
llimphi-widget-progress = { path = "../widgets/progress" }
llimphi-widget-button = { path = "../widgets/button" }
llimphi-widget-segmented = { path = "../widgets/segmented" }
llimphi-widget-switch = { path = "../widgets/switch", version = "0.1.0" }
llimphi-widget-slider = { path = "../widgets/slider", version = "0.1.0" }
llimphi-widget-progress = { path = "../widgets/progress", version = "0.1.0" }
llimphi-widget-button = { path = "../widgets/button", version = "0.1.0" }
llimphi-widget-segmented = { path = "../widgets/segmented", version = "0.1.0" }
+23
View File
@@ -0,0 +1,23 @@
# llimphi-compositor
*Read this in English: [README.md](README.md).*
El núcleo declarativo de Llimphi, sin winit.
Aquí vive el árbol de vista `View<Msg>` (DSL declarativo), su instalación
sobre taffy (`mount`), el pintado a `vello::Scene` (`paint`/`paint_gpu`) y
el hit-test. Nada de esto necesita una ventana ni `llimphi-hal`: la
composición `view → layout → scene` es pura y reutilizable.
El runtime que la maneja vive aparte:
- `llimphi-ui` la corre sobre winit (`run<A: App>()`).
- a futuro, un runtime sobre el framebuffer del kernel `wawa` puede
reusar exactamente este compositor sin arrastrar winit.
`wgpu` entra sólo por la firma de `GpuPaintFn` (tipos de Device/Queue/
Encoder/TextureView); `wgpu` no depende de winit, así que el compositor
sigue libre de windowing.
---
Parte de **llimphi** — ver [llimphi](../LEEME.md).
+22
View File
@@ -0,0 +1,22 @@
# llimphi-compositor
Llimphi's declarative core, without winit.
Here live the `View<Msg>` view tree (the declarative DSL), its installation over
taffy (`mount`), the painting to a `vello::Scene` (`paint`/`paint_gpu`) and
hit-testing. None of this needs a window or `llimphi-hal`: the
`view → layout → scene` composition is pure and reusable.
The runtime that drives it lives apart:
- `llimphi-ui` runs it over winit (`run<A: App>()`).
- in the future, a runtime over the `wawa` kernel's framebuffer can reuse exactly
this compositor without dragging winit along.
`wgpu` enters only through the signature of `GpuPaintFn` (Device/Queue/Encoder/
TextureView types); `wgpu` does not depend on winit, so the compositor stays free
of windowing.
---
Part of **llimphi** — see [llimphi](../README.md).
@@ -7,7 +7,7 @@
//!
//! Verifica que el camino `reconcile_size_anim` parcha `style.size`
//! ANTES del mount/compute, así el layout cascade ve el tamaño
//! interpolado y los siblings reflowean (a el padre es un row con
//! interpolado y los siblings reflowean (aquí el padre es un row con
//! `gap`; el segundo hijo se va corriendo según crece el primero).
//!
//! `cargo run -p llimphi-compositor --example animated_size_demo -- [out.png]`
@@ -0,0 +1,210 @@
//! Pantallazo headless del `llimphi-widget-button` para VER el relieve nuevo
//! (sombra + borde hairline + sheen + specular) en varios themes claros y
//! oscuros, sin levantar ventana.
//!
//! `cargo run -p llimphi-compositor --example pantallazo_button --release -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_compositor::{measure_text_node, mount, paint, View};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy;
use llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, Typesetter};
use llimphi_widget_button::{button_styled, ButtonPalette};
const W: u32 = 1120;
const H: u32 = 620;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn mix(a: Color, b: Color, t: f32) -> Color {
let (ca, cb) = (a.components, b.components);
Color {
components: [
ca[0] + (cb[0] - ca[0]) * t,
ca[1] + (cb[1] - ca[1]) * t,
ca[2] + (cb[2] - ca[2]) * t,
ca[3],
],
..a
}
}
/// Un botón de ancho fijo dentro de una caja (button_view es 100% de ancho).
fn chip(label: &str, pal: &ButtonPalette) -> View<()> {
let inner = button_styled(
label,
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),
..Default::default()
},
Alignment::Center,
pal,
(),
);
View::<()>::new(Style {
size: Size { width: length(150.0_f32), height: length(34.0_f32) },
..Default::default()
})
.children(vec![inner])
}
/// Fila de una demo por theme: fondo bg_app + nombre + 3 botones
/// (default · hover-preview · primario accent).
fn fila_theme(nombre: &str) -> View<()> {
let theme = llimphi_theme::Theme::by_name(nombre).expect("theme");
let default_pal = ButtonPalette::from_theme(&theme);
// Preview del estado hover: una paleta cuyo bg es el bg_button_hover.
let mut hover_pal = default_pal;
hover_pal.bg = theme.bg_button_hover;
// Primario: fondo = accent, texto que contraste, radio un toque mayor.
let white = Color::from_rgba8(255, 255, 255, 255);
let black = Color::from_rgba8(0, 0, 0, 255);
let lum = {
let k = theme.accent.components;
0.2126 * k[0] + 0.7152 * k[1] + 0.0722 * k[2]
};
let primary_pal = ButtonPalette {
bg: theme.accent,
bg_hover: mix(theme.accent, white, 0.12),
fg: if lum < 0.55 { white } else { black },
radius: 7.0,
};
let etiqueta = View::<()>::new(Style {
size: Size { width: length(120.0_f32), height: length(34.0_f32) },
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(nombre.to_string(), 13.0, theme.fg_muted, Alignment::Start);
View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: length(72.0_f32) },
flex_direction: FlexDirection::Row,
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()
})
.fill(theme.bg_app)
.children(vec![
etiqueta,
chip("Guardar", &default_pal),
chip("Guardar", &hover_pal),
chip("Aceptar", &primary_pal),
])
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "pantallazo_button.png".to_string());
let temas = ["Dark", "Light", "Tawa", "macOS", "Sunset", "Breeze", "Print", "Aurora"];
let filas: Vec<View<()>> = temas.iter().map(|t| fila_theme(t)).collect();
let root = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_direction: FlexDirection::Column,
..Default::default()
})
.fill(Color::from_rgba8(20, 20, 24, 255))
.children(filas);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let mut ts = Typesetter::new();
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("pantallazo-button"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 24, 255))
.expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!("pantallazo_button: escrito {out} ({W}x{H})");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
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("readback"),
size: (padded * H 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(H),
},
},
wgpu::Extent3d { width: W, height: H, 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 pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+2 -2
View File
@@ -7,7 +7,7 @@
//!
//! Prueba el camino `View::ripple` → `RippleRegistry::trigger`/`paint` →
//! `node_rrect` (clip) → píxeles, sin runtime ni winit. El press real lo
//! sintetiza el runtime (`llimphi-ui`); a lo emulamos llamando `trigger`.
//! sintetiza el runtime (`llimphi-ui`); aquí lo emulamos llamando `trigger`.
//!
//! `cargo run -p llimphi-compositor --example ripple_demo -- [out.png]`
@@ -85,7 +85,7 @@ fn main() {
let mounted = mount(&mut layout, root);
let computed = layout.compute(mounted.root, (W as f32, H as f32)).expect("layout");
// Pintá los botones, luego superponé una salpicadura por columna observada
// Pinta los botones, luego superpón una salpicadura por columna observada
// a un progreso creciente (cada registro disparó en t0, se observa a
// t0 + paso·i). Todas escriben en la misma escena.
let mut ts = Typesetter::new();
+359 -29
View File
@@ -65,7 +65,7 @@ pub struct Anim {
pub easing: fn(f32) -> f32,
/// `true` si la **primera aparición** de la key debe animar la opacidad de
/// 0 hacia su valor (fade-in de entrada, estilo `AnimatedSwitcher`). Las
/// animaciones de props (fill/radius/alpha) no entran por a: sólo cambian
/// animaciones de props (fill/radius/alpha) no entran por aquí: sólo cambian
/// el arranque del primer frame. Sin él, la primera aparición se asienta
/// instantánea (default histórico de `View::animated`).
pub enter: bool,
@@ -90,6 +90,60 @@ pub struct Anim {
/// la combinación enter+exit de dos keys. Implica captura `live` por frame
/// (como `exit`). La primera aparición no cruza (sólo asienta la variante).
pub switch: Option<u64>,
/// Transformación afín hacia la que **sale el contenido saliente** mientras
/// se desvanece (fantasma): se interpola de identidad a esta afín, aplicada
/// alrededor del centro del rect capturado (misma convención que
/// `View::transform`). `Some(Affine::translate((-w, 0)))` = slide-out a la
/// izquierda; `Some(Affine::scale(1.05))` = zoom-out. Con `exit`, describe
/// cómo sale **este nodo** al desaparecer (capturada mientras vive). Con
/// `switch`, describe cómo sale el **contenido viejo** al cambiar la
/// variante — se lee del frame NUEVO (quien conmuta conoce la dirección:
/// push vs pop). `None` = fade en el lugar (comportamiento histórico).
pub exit_to_xf: Option<Affine>,
/// `false` = el contenido nuevo de un `switch` NO arranca transparente
/// (entra opaco — necesario para un push estilo UIKit donde la página
/// entrante tapa a la saliente). `true` = fade-in histórico.
pub enter_fade: bool,
/// `true` = el fantasma del contenido saliente se pinta **DEBAJO** del
/// contenido nuevo, en la posición z del nodo (vía
/// [`AnimRegistry::prepare_underlays`] + `MountedNode::underlay`), en vez
/// de encima de toda la escena. Es el orden natural de una transición de
/// páginas: lo nuevo cubre a lo viejo. Como `exit_to_xf`, en un `switch`
/// lo declara el frame NUEVO.
pub exit_under: bool,
/// `true` = el fantasma NO se desvanece: mantiene alpha 1 hasta que su
/// reloj expira (y ahí desaparece de golpe — para entonces el contenido
/// nuevo ya lo cubre). Para salidas opacas (Cover/push UIKit).
pub exit_hold: bool,
}
/// Estilo completo de una transición de `switch` direccional — agrupa los
/// cinco knobs que [`crate::View::animated_switch_styled`] pasa al [`Anim`].
/// `Default` = cross-fade puro (idéntico a `animated_switch`).
#[derive(Clone, Copy, Debug)]
pub struct SwitchStyle {
/// Afín desde la que entra el contenido nuevo (`None` = en el lugar).
pub enter_from_xf: Option<Affine>,
/// `false` = el contenido nuevo entra opaco (sin fade-in).
pub enter_fade: bool,
/// Afín hacia la que sale el contenido viejo (`None` = en el lugar).
pub exit_to_xf: Option<Affine>,
/// `true` = el fantasma va DEBAJO del contenido nuevo (posición z real).
pub exit_under: bool,
/// `true` = el fantasma no se desvanece (salida opaca).
pub exit_hold: bool,
}
impl Default for SwitchStyle {
fn default() -> Self {
Self {
enter_from_xf: None,
enter_fade: true,
exit_to_xf: None,
exit_under: false,
exit_hold: false,
}
}
}
/// Ease-out cúbico, el default razonable para transiciones implícitas
@@ -257,8 +311,8 @@ fn reconcile_size_anim_inner<Msg>(
.entry(sa.key)
.or_insert_with(|| SizeAnimEntry::settled(target, now, sa.duration, sa.easing));
if entry.to != target {
// Cambió el objetivo: congelá el valor actual como nuevo
// origen y rearrancá el reloj — mismo patrón que el
// Cambió el objetivo: congela el valor actual como nuevo
// origen y rearranca el reloj — mismo patrón que el
// `AnimRegistry` de props.
entry.from = entry.value(now);
entry.to = target;
@@ -421,26 +475,59 @@ struct LiveExit {
scene: Scene,
duration: Duration,
easing: fn(f32) -> f32,
/// Centro del rect del nodo al capturarse — origen de la afín de salida.
centro: (f32, f32),
/// Afín de salida declarada por el propio nodo (`Anim::exit_to_xf`).
to_xf: Option<Affine>,
/// El fantasma va debajo del contenido nuevo (`Anim::exit_under`).
under: bool,
/// El fantasma no se desvanece (`Anim::exit_hold`).
hold: bool,
}
/// Un nodo que ya salió del árbol y se está desvaneciendo: su subescena retenida
/// + el reloj de fade-out.
/// + el reloj de fade-out (y, opcionalmente, la afín hacia la que se desliza).
struct Ghost {
scene: Scene,
start: Instant,
duration: Duration,
easing: fn(f32) -> f32,
centro: (f32, f32),
to_xf: Option<Affine>,
under: bool,
hold: bool,
}
impl Ghost {
/// Opacidad actual del fantasma: `1 → 0` con easing aplicado.
/// Opacidad actual del fantasma: `1 → 0` con easing aplicado — salvo con
/// `hold`, que la mantiene en 1 hasta que el reloj expira (salida opaca).
fn alpha(&self, now: Instant) -> f32 {
if self.hold {
return if self.done(now) { 0.0 } else { 1.0 };
}
1.0 - self.t(now)
}
/// Progreso eased `0 → 1` del fantasma.
fn t(&self, now: Instant) -> f32 {
if self.duration.is_zero() {
return 0.0;
return 1.0;
}
let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0);
1.0 - (self.easing)(raw)
(self.easing)(raw)
}
/// Afín del frame: identidad → `to_xf`, conjugada alrededor del centro del
/// rect capturado (misma convención que `View::transform`, así un scale
/// "respira" desde su centro y no desde el origen de la ventana). `None`
/// si el fantasma sólo se desvanece en el lugar.
fn xf(&self, now: Instant) -> Option<Affine> {
let to = self.to_xf?;
let t = self.t(now);
let local = lerp_affine(Affine::IDENTITY, to, t);
let (cx, cy) = (self.centro.0 as f64, self.centro.1 as f64);
Some(Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy)))
}
fn done(&self, now: Instant) -> bool {
@@ -463,6 +550,10 @@ pub struct AnimRegistry {
/// Última variante vista por cada key con `switch` — para detectar el
/// cambio de contenido que dispara el cross-fade.
variants: HashMap<u64, u64>,
/// Keys colocadas como underlay ESTE frame (por
/// [`Self::prepare_underlays`]) — `replay_ghosts` las saltea para no
/// pintarlas dos veces. Se limpia al inicio de cada `prepare_underlays`.
under_placed: Vec<u64>,
}
impl AnimRegistry {
@@ -512,6 +603,10 @@ impl AnimRegistry {
if prev != variant {
switched = true;
if let Some(le) = self.live.remove(&anim.key) {
// La afín/placement/hold de salida del contenido
// VIEJO los declara el frame nuevo (`anim.exit_*`):
// sólo quien conmuta sabe hacia dónde debe irse lo
// anterior (push ≠ pop). El centro sí es el capturado.
self.ghosts.insert(
anim.key,
Ghost {
@@ -519,6 +614,10 @@ impl AnimRegistry {
start: now,
duration: le.duration,
easing: le.easing,
centro: le.centro,
to_xf: anim.exit_to_xf,
under: anim.exit_under,
hold: anim.exit_hold,
},
);
}
@@ -548,9 +647,13 @@ impl AnimRegistry {
});
if switched {
// Cross-fade: el contenido nuevo entra desde transparente
// (el viejo ya quedó como fantasma desvaneciéndose encima).
// (el viejo ya quedó como fantasma desvaneciéndose). Con
// `enter_fade: false` entra OPACO (push estilo UIKit: la
// página nueva tapa, no se funde). Si además declara
// `enter_from_xf`, entra desde esa afín (slide/zoom).
entry.from = AnimSnapshot {
alpha: Some(0.0),
alpha: if anim.enter_fade { Some(0.0) } else { target.alpha },
transform: anim.enter_from_xf.or(target.transform),
..target
};
entry.to = target;
@@ -558,8 +661,8 @@ impl AnimRegistry {
entry.duration = anim.duration;
entry.easing = anim.easing;
} else if entry.to != target {
// Cambió el objetivo: congelá el valor actual como nuevo origen
// y rearrancá el reloj hacia el objetivo nuevo.
// Cambió el objetivo: congela el valor actual como nuevo origen
// y rearranca el reloj hacia el objetivo nuevo.
entry.from = entry.value(now);
entry.to = target;
entry.start = now;
@@ -608,6 +711,10 @@ impl AnimRegistry {
start: now,
duration: le.duration,
easing: le.easing,
centro: le.centro,
to_xf: le.to_xf,
under: le.under,
hold: le.hold,
},
);
}
@@ -638,16 +745,61 @@ impl AnimRegistry {
}
/// Guarda (o refresca) la subescena retenida de un nodo `exit` presente. El
/// runtime la captura con [`crate::paint_range`] tras el paint. `duration` y
/// `easing` se heredan al fantasma cuando la key desaparezca.
pub fn store_live_exit(
&mut self,
key: u64,
scene: Scene,
duration: Duration,
easing: fn(f32) -> f32,
) {
self.live.insert(key, LiveExit { scene, duration, easing });
/// runtime la captura con [`crate::paint_range`] tras el paint. Del `anim`
/// del nodo se heredan al fantasma `duration`/`easing`/`exit_to_xf`/
/// `exit_under`/`exit_hold` cuando la key desaparezca (camino `exit`; en
/// un `switch` la salida la re-declara el frame nuevo). `centro` es el
/// centro del rect del nodo (origen de la afín de salida).
pub fn store_live_exit(&mut self, key: u64, scene: Scene, centro: (f32, f32), anim: &Anim) {
self.live.insert(
key,
LiveExit {
scene,
duration: anim.duration,
easing: anim.easing,
centro,
to_xf: anim.exit_to_xf,
under: anim.exit_under,
hold: anim.exit_hold,
},
);
}
/// **Scene-split de fantasmas**: escribe en `MountedNode::underlay` la
/// subescena (ya envuelta en su capa de alpha + afín de salida) de cada
/// fantasma `under` cuyo `switch` sigue montado — el paint la apendea
/// DEBAJO del contenido nuevo, en la posición z real del nodo (y dentro
/// del clip/transform de sus ancestros). Las keys colocadas se saltean en
/// [`Self::replay_ghosts`] este frame. Llamar DESPUÉS de [`Self::reconcile`]
/// y ANTES del paint.
pub fn prepare_underlays<Msg>(&mut self, mounted: &mut Mounted<Msg>, now: Instant) {
self.under_placed.clear();
if self.ghosts.is_empty() {
return;
}
for node in &mut mounted.nodes {
let Some(anim) = node.anim else { continue };
if anim.switch.is_none() {
continue;
}
let Some(g) = self.ghosts.get(&anim.key) else { continue };
if !g.under {
continue;
}
let a = g.alpha(now);
if a <= 0.0 {
continue;
}
// Envoltorio: capa de alpha (rect generoso — el clip real lo
// ponen los ancestros del nodo en el paint) + la afín de salida.
let mut wrap = Scene::new();
let big = Rect::new(-1e6, -1e6, 1e6, 1e6);
wrap.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &big);
wrap.append(&g.scene, g.xf(now));
wrap.pop_layer();
node.underlay = Some(wrap);
self.under_placed.push(anim.key);
}
}
/// Reproduce los fantasmas activos sobre `scene`, cada uno con su opacidad
@@ -659,13 +811,18 @@ impl AnimRegistry {
return false;
}
let clip = Rect::new(0.0, 0.0, w as f64, h as f64);
for g in self.ghosts.values() {
for (key, g) in &self.ghosts {
// Los colocados como underlay este frame ya se pintaron en su
// posición z (dentro del árbol) — no duplicarlos encima.
if self.under_placed.contains(key) {
continue;
}
let a = g.alpha(now);
if a <= 0.0 {
continue;
}
scene.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &clip);
scene.append(&g.scene, None);
scene.append(&g.scene, g.xf(now));
scene.pop_layer();
}
true
@@ -800,11 +957,11 @@ mod tests {
let mut reg = AnimRegistry::new();
let t0 = Instant::now();
// Frame 1: el nodo exit está presente. No anima por sí solo, y el
// runtime captura su subescena (a una vacía de prueba).
// runtime captura su subescena (aquí una vacía de prueba).
let mut m = one_exit();
let animating = reg.reconcile(&mut m, t0);
assert!(!animating, "presente y quieto no anima");
reg.store_live_exit(7, Scene::new(), Duration::from_millis(200), ease_out_cubic);
reg.store_live_exit(7, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
// Frame 2: la key desaparece → se promueve a fantasma y pide frames.
let mut m = empty();
let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
@@ -834,7 +991,7 @@ mod tests {
let mut m = one_switch(1);
assert!(!reg.reconcile(&mut m, t0), "primera aparición no cruza");
// El runtime captura su subescena (de prueba, vacía).
reg.store_live_exit(5, Scene::new(), Duration::from_millis(200), ease_out_cubic);
reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
// Frame 2: variante 2 → cross-fade. El contenido nuevo arranca casi
// transparente y hay un fantasma del contenido viejo desvaneciéndose.
let mut m = one_switch(2);
@@ -847,7 +1004,7 @@ mod tests {
"hay un fantasma del contenido viejo"
);
// Re-captura del frame 2 (lo haría el runtime tras el paint).
reg.store_live_exit(5, Scene::new(), Duration::from_millis(200), ease_out_cubic);
reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
// Frame 3: misma variante, pasada la duración → asentado y sin fantasma.
let mut m = one_switch(2);
let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
@@ -865,7 +1022,7 @@ mod tests {
let t0 = Instant::now();
let mut m = one_switch(1);
reg.reconcile(&mut m, t0);
reg.store_live_exit(5, Scene::new(), Duration::from_millis(200), ease_out_cubic);
reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
// Misma variante en el frame siguiente: ni fade-in ni fantasma.
let mut m = one_switch(1);
let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
@@ -880,7 +1037,7 @@ mod tests {
let t0 = Instant::now();
let mut m = one_exit();
reg.reconcile(&mut m, t0);
reg.store_live_exit(7, Scene::new(), Duration::from_millis(200), ease_out_cubic);
reg.store_live_exit(7, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
// Se va → fantasma.
let mut m = empty();
assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10)));
@@ -1093,4 +1250,177 @@ mod tests {
reconcile_size_anim(&mut v, &mut reg, now);
assert_eq!(reg.entries.len(), 0);
}
// ── Salida direccional (exit_to_xf) — base del router de páginas ────────
#[test]
fn fantasma_con_exit_to_xf_interpola_la_afin_alrededor_del_centro() {
let t0 = Instant::now();
let g = Ghost {
scene: Scene::new(),
start: t0,
duration: Duration::from_millis(200),
easing: |t| t, // lineal para aserciones exactas
centro: (100.0, 50.0),
to_xf: Some(Affine::translate((-200.0, 0.0))),
under: false,
hold: false,
};
// t=0: afín ≈ identidad (el fantasma arranca donde estaba).
let xf0 = g.xf(t0).expect("hay afín");
let c0 = xf0.as_coeffs();
assert!(c0[4].abs() < 1e-6 && c0[5].abs() < 1e-6, "t=0 → identidad: {c0:?}");
// t=0.5: mitad del desplazamiento. Una traslación conmuta con la
// conjugación por el centro, así que el coef es -100 exacto.
let xf = g.xf(t0 + Duration::from_millis(100)).expect("afín viva");
let c = xf.as_coeffs();
assert!((c[4] + 100.0).abs() < 1e-3, "mitad del slide: {c:?}");
// Sin to_xf → None (fade puro, camino histórico).
let g2 = Ghost { to_xf: None, scene: Scene::new(), ..g };
assert!(g2.xf(t0 + Duration::from_millis(100)).is_none());
}
#[test]
fn fantasma_con_scale_respira_desde_su_centro() {
let t0 = Instant::now();
let g = Ghost {
scene: Scene::new(),
start: t0,
duration: Duration::from_millis(100),
easing: |t| t,
centro: (100.0, 100.0),
to_xf: Some(Affine::scale(2.0)),
under: false,
hold: false,
};
// 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);
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);
assert!((q.x - 200.0).abs() < 1e-6, "escala desde el centro: {q:?}");
}
#[test]
fn switch_direccional_entra_desde_la_afin_y_el_fantasma_sale_hacia_la_nueva() {
let salida = Affine::translate((-300.0, 0.0));
let entrada = Affine::translate((300.0, 0.0));
let one = |variant: u64| -> Mounted<()> {
let v = View::<()>::new(Style::default())
.fill(rgba(10, 20, 30))
.animated_switch_from(5, variant, Duration::from_millis(200),
Some(entrada), Some(salida));
let mut layout = LayoutTree::new();
mount(&mut layout, v)
};
let mut reg = AnimRegistry::new();
let t0 = Instant::now();
// Frame 1: variante 1 asienta; el runtime captura (to_xf del frame 1,
// que NO es el que debe usarse al conmutar).
let mut m = one(1);
reg.reconcile(&mut m, t0);
let mut senuelo = m.nodes[0].anim.expect("anim");
senuelo.exit_to_xf = Some(Affine::scale(9.0)); // señuelo: NO debe usarse
reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &senuelo);
// Frame 2: variante 2 → el contenido nuevo arranca DESPLAZADO hacia
// `entrada` (además de transparente) y el fantasma lleva la `salida`
// declarada por el frame nuevo, no el señuelo capturado.
let mut m = one(2);
let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
assert!(animating);
let xf = m.nodes[0].transform.expect("transform de entrada");
let c = xf.as_coeffs();
assert!(c[4] > 200.0, "arranca cerca de +300 en x: {c:?}");
let g = reg.ghosts.get(&5).expect("fantasma del contenido viejo");
assert_eq!(g.to_xf, Some(salida), "la salida la declara el frame nuevo");
}
#[test]
fn hold_mantiene_el_alpha_hasta_expirar() {
let t0 = Instant::now();
let g = Ghost {
scene: Scene::new(),
start: t0,
duration: Duration::from_millis(200),
easing: |t| t,
centro: (0.0, 0.0),
to_xf: None,
under: false,
hold: true,
};
// A mitad del reloj sigue OPACO (salida sin desvanecer).
assert_eq!(g.alpha(t0 + Duration::from_millis(100)), 1.0);
// Expirado: desaparece de golpe.
assert_eq!(g.alpha(t0 + Duration::from_millis(250)), 0.0);
}
#[test]
fn switch_estilado_entra_opaco_y_coloca_underlay() {
use crate::SwitchStyle;
let style = SwitchStyle {
enter_from_xf: Some(Affine::translate((300.0, 0.0))),
enter_fade: false,
exit_to_xf: None,
exit_under: true,
exit_hold: true,
};
let one = |variant: u64| -> Mounted<()> {
let v = View::<()>::new(Style::default())
.fill(rgba(10, 20, 30))
.animated_switch_styled(5, variant, Duration::from_millis(200), style);
let mut layout = LayoutTree::new();
mount(&mut layout, v)
};
let mut reg = AnimRegistry::new();
let t0 = Instant::now();
let mut m = one(1);
reg.reconcile(&mut m, t0);
reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim"));
// Conmutar: el contenido nuevo entra desplazado pero SIN fade (alpha
// intacto — push opaco), y el fantasma queda marcado under+hold.
let mut m = one(2);
let t1 = t0 + Duration::from_millis(10);
assert!(reg.reconcile(&mut m, t1));
assert_eq!(m.nodes[0].alpha, None, "entra opaco (enter_fade=false)");
assert!(m.nodes[0].transform.is_some(), "pero sí entra deslizándose");
let g = reg.ghosts.get(&5).expect("fantasma");
assert!(g.under && g.hold, "placement y hold del frame nuevo");
// prepare_underlays hornea la subescena en el nodo y la marca para
// que replay_ghosts no la duplique encima.
reg.prepare_underlays(&mut m, t1);
assert!(m.nodes[0].underlay.is_some(), "underlay colocado");
assert_eq!(reg.under_placed, vec![5]);
// Un fantasma under colocado no se re-pinta arriba, pero la función
// sigue reportando fantasmas vivos (ticker).
assert!(reg.replay_ghosts(&mut Scene::new(), t1, 100.0, 100.0));
// Si la key deja de estar montada (nodo desapareció), el fantasma
// cae al camino histórico (encima): sin underlay que colocar.
let mut vacio = empty();
reg.prepare_underlays(&mut vacio, t1);
assert!(reg.under_placed.is_empty(), "sin nodo switch no hay underlay");
}
#[test]
fn exit_to_xf_se_hereda_al_fantasma_al_desaparecer() {
let to = Affine::translate((0.0, 40.0));
let v = View::<()>::new(Style::default())
.fill(rgba(9, 9, 9))
.animated_exit_to(7, Duration::from_millis(200), to);
let mut layout = LayoutTree::new();
let mut m = mount(&mut layout, v);
let mut reg = AnimRegistry::new();
let t0 = Instant::now();
reg.reconcile(&mut m, t0);
// El runtime captura con la to_xf del propio nodo (camino exit).
reg.store_live_exit(7, Scene::new(), (20.0, 20.0), &m.nodes[0].anim.expect("anim"));
// Desaparece → el fantasma hereda la afín capturada.
let mut m = empty();
assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10)));
let g = reg.ghosts.get(&7).expect("fantasma");
assert_eq!(g.to_xf, Some(to));
assert_eq!(g.centro, (20.0, 20.0));
}
}
+1 -1
View File
@@ -223,7 +223,7 @@ mod tests {
];
let expanded = expand_layout_builders(root, &cons);
// Ambos expandidos, en orden (verificamos vía el ancho del hijo producido
// que NO depende de la constraint a — sólo confirmamos que se invocaron
// 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());
+166 -3
View File
@@ -33,6 +33,7 @@ mod semantics;
mod view;
pub use anim::{
ease_out_cubic, reconcile_size_anim, Anim, AnimRegistry, SizeAnim, SizeAnimRegistry,
SwitchStyle,
};
pub use hero::{Hero, HeroRegistry};
pub use layout_builder::{collect_builder_constraints, expand_layout_builders, has_layout_builder};
@@ -119,6 +120,19 @@ pub struct TextSpec {
/// previo). Sólo el camino uniforme (`layout_clamped`); el de spans
/// (RichText) lo ignora en v1, igual que `no_wrap`/clamp.
pub overflow_wrap: bool,
/// `font-feature-settings` (+ `font-kerning` plegado como `kern`) en formato
/// CSS crudo (`"liga" 1, "kern" 0`). Vacío = default de la fuente. Afecta
/// shaping y medida (ligaduras/kerning cambian el ancho). Sólo el camino
/// uniforme (`layout_clamped`); el de spans (RichText) lo ignora en v1.
pub font_features: String,
/// `font-variation-settings` en formato CSS crudo (`"wght" 700`). Vacío =
/// ejes en su default. Mismo régimen que [`Self::font_features`].
pub font_variations: String,
/// `font-stretch`/`font-width` como **porcentaje** (100 = normal, <100
/// condensada, >100 expandida). Afecta shaping y medida (la face
/// condensada/expandida cambia el ancho). Sólo el camino uniforme
/// (`layout_clamped`); el de spans (RichText) lo ignora en v1.
pub font_width: f32,
}
/// Fase de un drag activo. `Move` se emite por cada `CursorMoved` con el
@@ -156,12 +170,21 @@ pub type ClickAtFn<Msg> = Arc<dyn Fn(f32, f32, f32, f32) -> Option<Msg> + Send +
/// líneas lógicas (misma normalización que `App::on_wheel`: `dy` positivo
/// = scroll hacia abajo). El runtime lo invoca cuando la rueda gira con el
/// cursor sobre este nodo, ANTES de caer al `App::on_wheel` global: si el
/// handler devuelve `Some(Msg)`, el evento se consume a. Permite áreas
/// handler devuelve `Some(Msg)`, el evento se consume aquí. Permite áreas
/// de scroll autocontenidas (el widget `scroll` lo usa) sin que cada app
/// rutee la rueda a mano por su `Model`. Devolver `None` deja pasar el
/// evento al `on_wheel` global.
pub type ScrollFn<Msg> = Arc<dyn Fn(f32, f32) -> Option<Msg> + Send + Sync>;
/// Handler de rueda con **reparto parcial** (scroll anidado real): recibe el
/// delta `(dx, dy)` en líneas y devuelve `(msg, rest_x, rest_y)` — el `Msg`
/// por lo que consumió (o `None` si no consumió nada) y el **sobrante** en la
/// misma unidad, que el runtime sigue repartiendo al siguiente scrollable de
/// la cadena (el ancestro). Un mismo evento puede así mover al hijo hasta su
/// tope Y al padre con el resto, en el mismo frame. Ver
/// [`View::on_scroll_take`] y [`consume_scroll_chain`].
pub type ScrollTakeFn<Msg> = Arc<dyn Fn(f32, f32) -> (Option<Msg>, f32, f32) + Send + Sync>;
/// Variante de [`DragFn`] que **conoce la posición inicial del press**
/// relativa al rect del nodo. Útil cuando el caller necesita identificar
/// qué entidad (Concepto, lemming, etc.) bajo el cursor agarró el drag.
@@ -240,6 +263,25 @@ pub struct Constraints {
/// [`View::layout_builder`].
pub type LayoutBuilderFn<Msg> = Arc<dyn Fn(Constraints) -> View<Msg> + Send + Sync>;
/// Constructor **diferido** del contenido de un [`View::memo`] (Bloque 25).
/// Produce el subárbol (un único `View`) que va DENTRO del nodo memo. El runtime
/// lo llama sólo cuando `deps` (o el tamaño del slot) cambió; si no, reusa el
/// `Mounted` cacheado sin reconstruir el `View`.
pub type MemoBuilderFn<Msg> = Arc<dyn Fn() -> View<Msg> + Send + Sync>;
/// Nodo **memoizado** (Bloque 25 de `PLAN-NUCLEO-RETENIDO.md`): un subárbol
/// cuyo `View` NO se reconstruye —ni se re-monta ni se re-layoutea— si sus
/// `deps` no cambiaron. El nodo memo lleva un `Style` con **tamaño definido**
/// (length/percent, no auto): el runtime lo monta como hoja de ese tamaño,
/// computa el subárbol en un `LayoutTree` aislado (que taffy cachea) y splica
/// su `Mounted` cacheado. `builder` = `Some` mientras está sin resolver;
/// `resolve_memos` lo extrae y el nodo queda como marcador. Ver [`View::memo`].
pub struct MemoNode<Msg> {
pub key: u64,
pub deps: u64,
pub builder: Option<MemoBuilderFn<Msg>>,
}
/// Rect absoluto del nodo (en coordenadas físicas del frame). Lo
/// recibe el callback de [`View::paint_with`] para que pueda
/// posicionar sus primitivas custom dentro del nodo.
@@ -503,6 +545,14 @@ pub struct View<Msg> {
/// [`OverPaintFn`]. Misma firma que [`PaintFn`] — sólo cambia
/// *cuándo* corre (post-GPU). `None` = sin over-layer (coste cero).
pub over_painter: Option<PaintFn>,
/// **Subárbol over** (ver [`View::over`]): este nodo y todos sus
/// descendientes se pintan en la pasada vello FINAL, después del pase
/// GPU directo — con toda la maquinaria normal (fills, texto, imágenes,
/// painters, hover). Es el primitivo para UI **encima** de contenido
/// `gpu_paint_with` (OSC de un reproductor, HUD sobre un canvas GPU).
/// El hit-test no cambia (usa el layout, no la pasada de pintura).
/// `false` = pinta en la pasada base, como siempre (coste cero).
pub over_layer: bool,
pub on_click: Option<Msg>,
/// Handler de click que recibe la posición **relativa al rect del
/// nodo** (esquina superior-izquierda del nodo = `(0, 0)`). Útil
@@ -510,6 +560,10 @@ pub struct View<Msg> {
/// de mundo. Si está presente, gana sobre `on_click`. Devolver
/// `None` no dispara update.
pub on_click_at: Option<ClickAtFn<Msg>>,
/// Gemelo de izquierda de [`Self::on_right_click_screen`]: click izquierdo con
/// coords **absolutas de ventana**, para anclar un popup (select/dropdown) en
/// la capa `view_overlay`.
pub on_click_screen: Option<ClickAtFn<Msg>>,
/// Equivalente a `on_click` pero para el botón derecho del ratón.
/// Pensado para menús contextuales: el nodo declara qué `Msg`
/// emitir cuando se le hace right-click, y la app abre el overlay
@@ -520,6 +574,13 @@ pub struct View<Msg> {
/// click derecho (la celda no es un nodo aparte, sino una región
/// dentro del nodo). Si está presente, gana sobre `on_right_click`.
pub on_right_click_at: Option<ClickAtFn<Msg>>,
/// Como [`Self::on_right_click_at`] pero recibe coords **absolutas de
/// ventana** (no locales al nodo). Para anclar menús contextuales en la
/// posición del puntero: `on_right_click_at` (local) sirve a grillas/canvas
/// que necesitan saber QUÉ región del rect se clickeó; éste sirve a popups
/// que quieren la posición en pantalla. Los backends le pasan el cursor
/// absoluto. Convive con `on_right_click_at` (un nodo puede tener ambos).
pub on_right_click_screen: Option<ClickAtFn<Msg>>,
/// Equivalente a `on_click` pero para el botón del medio del ratón
/// (rueda presionada). Pensado para abrir en pestaña nueva — los
/// browsers usan middle-click como atajo equivalente a Ctrl+Click.
@@ -601,6 +662,10 @@ pub struct View<Msg> {
/// `Some(Msg)` consume el evento. Base de las áreas de scroll
/// autocontenidas. Ver [`ScrollFn`].
pub on_scroll: Option<ScrollFn<Msg>>,
/// Variante con **reparto parcial** del delta (scroll anidado real):
/// consume lo que puede y devuelve el sobrante, que sigue al ancestro.
/// Tiene prioridad sobre `on_scroll` si ambos están. Ver [`ScrollTakeFn`].
pub on_scroll_take: Option<ScrollTakeFn<Msg>>,
/// Handler de gesto de **escala** (pinch-to-zoom). Si está presente y el
/// gesto cae sobre este nodo (Ctrl+rueda en desktop, pinch de trackpad en
/// macOS), el runtime lo invoca con el factor incremental + el punto focal
@@ -615,7 +680,7 @@ pub struct View<Msg> {
/// dentro de una ventana temporal corta y muy cerca). Es un evento
/// **aditivo**: si el nodo también tiene `on_click`, éste igual dispara en
/// cada press; el doble-tap llega además en el segundo. Para doble-tap
/// exclusivo, poné el handler en un nodo sin `on_click`. Ver
/// exclusivo, pon el handler en un nodo sin `on_click`. Ver
/// [`View::on_double_tap`].
pub on_double_tap: Option<Msg>,
/// Variante posicional de [`Self::on_double_tap`]: recibe la posición del
@@ -714,6 +779,18 @@ pub struct View<Msg> {
/// default CSS `50% 50%` (centro del rect) — el caso mayoritario. Ver
/// [`TransformPivot`] y [`View::transform_origin`].
pub transform_origin: Option<TransformPivot>,
/// `position: sticky` (CSS): insets `[top, right, bottom, left]` en px
/// relativos al **scrollport** (el ancestro que recorta, `clip==true`).
/// El nodo se pinta en su posición de flujo normal hasta que el scroll lo
/// lleva al umbral del inset; ahí se "pega" a esa distancia del borde del
/// scrollport, sin salir de él. Como el scroll ya está horneado en el
/// layout (el rect del nodo refleja el desplazamiento), `paint` resuelve el
/// enganche con pura aritmética de rects — no necesita conocer el offset de
/// scroll. `None` ⇒ nodo no-sticky (la abrumadora mayoría). Ver
/// [`View::sticky`]. Limitación v1: el clamp usa el scrollport como caja
/// contenedora (no el bloque contenedor CSS exacto); alcanza para el caso
/// canónico (header/columna sticky a nivel de página).
pub sticky: Option<[Option<f32>; 4]>,
/// Texto de **tooltip**: si está, el runtime/cliente puede mostrar un
/// rótulo flotante cuando el cursor se posa sobre este nodo. Llimphi sólo
/// transporta el dato hasta el [`MountedNode`]; *quién* lo pinta (un overlay
@@ -752,6 +829,21 @@ pub struct View<Msg> {
/// `None` = source-over normal (la abrumadora mayoría). Ver [`View::blend`].
/// Fase 7.1237.
pub blend: Option<BlendMode>,
/// **RepaintBoundary** (Bloque 23 de `PLAN-NUCLEO-RETENIDO.md`): marca este
/// subárbol como candidato a **cachear su rasterización** entre frames. Si
/// el subárbol es paint-puro (sin painter/gpu/over/anim/ripple/gradiente/
/// imagen/hover adentro) y está en una posición "limpia" (sin transform/clip/
/// alpha ancestral), el runtime rasteriza su sub-escena una vez y la **reusa**
/// (append trasladado) mientras su contenido no cambie — evitando re-pintarlo.
/// `key` estable entre rebuilds. `None` = nodo normal (la abrumadora mayoría).
/// Ver [`View::repaint_boundary`].
pub repaint_boundary: Option<u64>,
/// **Memo** (Bloque 25): si está presente, el contenido de este nodo se
/// construye vía el `builder` diferido y se **reusa entre frames** mientras
/// `deps` no cambie (no se reconstruye el `View` ni se re-monta/re-layoutea).
/// El nodo debe tener un `Style` con tamaño definido. `None` = nodo normal.
/// Ver [`View::memo`].
pub memo: Option<MemoNode<Msg>>,
pub children: Vec<View<Msg>>,
}
@@ -792,10 +884,13 @@ impl<Msg: 'static> View<Msg> {
painter,
gpu_painter,
over_painter,
over_layer,
on_click,
on_click_at,
on_click_screen,
on_right_click,
on_right_click_at,
on_right_click_screen,
on_middle_click,
drag,
drag_at,
@@ -813,6 +908,7 @@ impl<Msg: 'static> View<Msg> {
on_pointer_leave,
on_pointer_move_at,
on_scroll,
on_scroll_take,
on_scale,
on_rotate,
on_double_tap,
@@ -829,6 +925,7 @@ impl<Msg: 'static> View<Msg> {
transform,
transform_rel,
transform_origin,
sticky,
tooltip,
cursor,
ripple,
@@ -836,6 +933,8 @@ impl<Msg: 'static> View<Msg> {
backdrop_blur,
filter,
blend,
repaint_boundary,
memo,
children,
} = self;
// Wrappers: cada callback que produce `Option<Msg>` se reenvía y su
@@ -860,6 +959,7 @@ impl<Msg: 'static> View<Msg> {
painter,
gpu_painter,
over_painter,
over_layer,
drag_payload,
drop_hover_fill,
clip,
@@ -878,12 +978,24 @@ impl<Msg: 'static> View<Msg> {
transform,
transform_rel,
transform_origin,
sticky,
tooltip,
cursor,
ripple,
backdrop_blur,
filter,
blend,
repaint_boundary,
// El builder del memo produce `View<Msg>`; lo envolvemos para que
// produzca `View<Msg2>` mapeando su resultado con `f`.
memo: memo.map(|m| MemoNode {
key: m.key,
deps: m.deps,
builder: m.builder.map(|b| {
let f = f.clone();
Arc::new(move || b().map_shared(f.clone())) as MemoBuilderFn<Msg2>
}),
}),
// — Msg simples —
on_click: on_click.map(|m| f(m)),
on_right_click: on_right_click.map(|m| f(m)),
@@ -897,10 +1009,18 @@ impl<Msg: 'static> View<Msg> {
let f = f.clone();
Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
}),
on_click_screen: on_click_screen.map(|h| {
let f = f.clone();
Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
}),
on_right_click_at: on_right_click_at.map(|h| {
let f = f.clone();
Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
}),
on_right_click_screen: on_right_click_screen.map(|h| {
let f = f.clone();
Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
}),
on_pointer_move_at: on_pointer_move_at.map(|h| {
let f = f.clone();
Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn<Msg2>
@@ -936,6 +1056,13 @@ impl<Msg: 'static> View<Msg> {
let f = f.clone();
Arc::new(move |dx, dy| h(dx, dy).map(|m| f(m))) as ScrollFn<Msg2>
}),
on_scroll_take: on_scroll_take.map(|h| {
let f = f.clone();
Arc::new(move |dx, dy| {
let (msg, rx, ry) = h(dx, dy);
(msg.map(|m| f(m)), rx, ry)
}) as ScrollTakeFn<Msg2>
}),
on_scale: on_scale.map(|h| {
let f = f.clone();
Arc::new(move |ph, s, cx, cy| h(ph, s, cx, cy).map(|m| f(m))) as ScaleFn<Msg2>
@@ -944,7 +1071,7 @@ impl<Msg: 'static> View<Msg> {
let f = f.clone();
Arc::new(move |ph, r, cx, cy| h(ph, r, cx, cy).map(|m| f(m))) as RotateFn<Msg2>
}),
// — layout_builder produce un View<Msg>: recursá el map —
// — layout_builder produce un View<Msg>: recursa el map —
layout_builder: layout_builder.map(|h| {
let f = f.clone();
Arc::new(move |c| h(c).map_shared(f.clone())) as LayoutBuilderFn<Msg2>
@@ -1017,6 +1144,14 @@ pub struct TextMeasure {
/// palabra larga: con el flag, el ancho mínimo del bloque deja de estar
/// fijado por el token más ancho.
pub overflow_wrap: bool,
/// Idem [`TextSpec::font_features`]. Entra en la medida porque ligaduras/
/// kerning cambian el ancho del shaping (y la clave del caché).
pub font_features: String,
/// Idem [`TextSpec::font_variations`]. Mismo razonamiento que `font_features`.
pub font_variations: String,
/// Idem [`TextSpec::font_width`]. Entra en la medida porque la face
/// condensada/expandida cambia el ancho del shaping (y la clave del caché).
pub font_width: f32,
}
/// Cómo encajar una imagen en el rect del nodo (CSS `object-fit` /
@@ -1188,6 +1323,7 @@ pub enum Cursor {
ZoomOut,
}
#[derive(Clone)]
pub struct MountedNode<Msg> {
pub id: NodeId,
pub fill: Option<Color>,
@@ -1215,10 +1351,17 @@ pub struct MountedNode<Msg> {
pub painter: Option<PaintFn>,
pub gpu_painter: Option<GpuPaintFn>,
pub over_painter: Option<PaintFn>,
/// Subárbol over (ver [`View::over`]): este nodo y sus descendientes
/// pintan en la pasada vello final, después del pase GPU.
pub over_layer: bool,
pub on_click: Option<Msg>,
pub on_click_at: Option<ClickAtFn<Msg>>,
/// Gemelo de izquierda de [`Self::on_right_click_screen`]: click izquierdo con
/// coords **absolutas de ventana**, para anclar un popup (select/dropdown).
pub on_click_screen: Option<ClickAtFn<Msg>>,
pub on_right_click: Option<Msg>,
pub on_right_click_at: Option<ClickAtFn<Msg>>,
pub on_right_click_screen: Option<ClickAtFn<Msg>>,
pub on_middle_click: Option<Msg>,
pub drag: Option<DragFn<Msg>>,
pub drag_at: Option<DragAtFn<Msg>>,
@@ -1236,6 +1379,8 @@ pub struct MountedNode<Msg> {
pub on_pointer_leave: Option<Msg>,
pub on_pointer_move_at: Option<ClickAtFn<Msg>>,
pub on_scroll: Option<ScrollFn<Msg>>,
/// Handler de scroll con reparto parcial. Ver [`ScrollTakeFn`].
pub on_scroll_take: Option<ScrollTakeFn<Msg>>,
/// Handler de gesto de escala (pinch-to-zoom) de este nodo. Ver
/// [`View::on_scale`] y [`ScaleFn`].
pub on_scale: Option<ScaleFn<Msg>>,
@@ -1264,6 +1409,14 @@ pub struct MountedNode<Msg> {
/// en [`HeroRegistry::reconcile`] para enlazar identidad entre frames y
/// escribir `transform` con la afín "fly" cuando el rect cambia.
pub hero: Option<Hero>,
/// Subescena a pintar **DEBAJO** del contenido de este nodo, en su
/// posición z (y dentro del clip/transform de sus ancestros). La escribe
/// el runtime — [`AnimRegistry::prepare_underlays`]: el fantasma del
/// contenido viejo de un `switch` con `exit_under` — no el `View`. El
/// paint la apendea componiendo la afín acumulada de los ancestros,
/// ANTES del transform/alpha propios del nodo (que pertenecen al
/// contenido NUEVO).
pub underlay: Option<vello::Scene>,
/// Transformación afín 2D del nodo (alrededor del centro de su rect).
/// Ver [`View::transform`]. `paint` la compone con la del padre.
pub transform: Option<Affine>,
@@ -1273,6 +1426,10 @@ pub struct MountedNode<Msg> {
/// Pivote de `transform` (CSS `transform-origin`). `None` ⇒ centro. Ver
/// [`TransformPivot`] / [`View::transform_origin`].
pub transform_origin: Option<TransformPivot>,
/// `position: sticky`: insets `[top, right, bottom, left]` px relativos al
/// scrollport. Ver [`View::sticky`]. `paint` lo resuelve con los rects
/// computados (el scroll ya está en el layout). `None` ⇒ no-sticky.
pub sticky: Option<[Option<f32>; 4]>,
/// Texto de tooltip de este nodo (ver [`View::tooltip`]). El consumidor lo
/// lee tras un hit-test de hover para pintar el rótulo flotante.
pub tooltip: Option<String>,
@@ -1308,6 +1465,12 @@ pub struct MountedNode<Msg> {
/// fill + contenido + hijos y se cierra al fin del subárbol, mezclando el
/// resultado contra lo ya pintado. `None` = source-over. Fase 7.1237.
pub blend: Option<BlendMode>,
/// Key de [`View::repaint_boundary`] si este nodo abre un límite de
/// repintado cacheable. `None` = normal. Lo consume `paint_cached`.
pub repaint_boundary: Option<u64>,
/// Key de [`View::memo`] si este nodo es un marcador de subárbol memoizado
/// (Bloque 25). El runtime lo splica tras el layout. `None` = normal.
pub memo_ref: Option<u64>,
/// Índice (exclusivo) del fin del subárbol en `Mounted::nodes`. Los
/// descendientes ocupan `[idx + 1, subtree_end)`. Hace de "barrera" en
/// paint/hit_test para `pop_layer` y para saltar subárboles enteros.
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -133,13 +133,13 @@ impl RippleRegistry {
computed: &ComputedLayout,
now: Instant,
) -> bool {
// Descartá primero las agotadas (no dependen del nodo).
// Descarta primero las agotadas (no dependen del nodo).
self.splashes.retain(|s| !s.done(now));
if self.splashes.is_empty() {
return false;
}
for s in &self.splashes {
// Resolvé el nodo ripple de esta key (el primero que la declare).
// Resuelve el nodo ripple de esta key (el primero que la declare).
let Some(node) = mounted.nodes.iter().find(|n| {
n.ripple.map(|r| r.key) == Some(s.key)
}) else {
+3 -3
View File
@@ -6,7 +6,7 @@
//! Este módulo es **pura data**: define los tipos sin acoplarse al crate
//! `accesskit`. La conversión a `accesskit::Node` vive en `llimphi-ui::a11y`
//! (iter 2 del plan), donde el cableado del adapter winit ya importa la
//! librería. Tener a solo el modelo permite:
//! librería. Tener aquí solo el modelo permite:
//!
//! - Compilar el compositor con o sin la integración AccessKit habilitada.
//! - Testear semántica a nivel "qué declaran los widgets" sin levantar un
@@ -106,7 +106,7 @@ impl SemanticsFlags {
/// un `accesskit::Node` cada frame.
///
/// `label` es lo que el lector enuncia primero (el "nombre accesible"). Si el
/// nodo ya tiene un `text` visible y significativo, podés dejar `label = None`
/// nodo ya tiene un `text` visible y significativo, puedes dejar `label = None`
/// y el runtime usará ese texto como nombre — pero declararlo explícito es más
/// robusto (e.g. un botón con sólo un ícono necesita label porque no hay texto
/// visible).
@@ -115,7 +115,7 @@ impl SemanticsFlags {
/// suele leer label + value juntos: "Volumen, 70".
///
/// `description` es contexto adicional ("Disminuye el volumen del sistema").
/// Los lectores lo leen tras una pausa o con un atajo distinto; usalo para
/// Los lectores lo leen tras una pausa o con un atajo distinto; úsalo para
/// info que ayude PERO no sobreloadées (los usuarios de TTS perciben ruido
/// más que falta de info).
#[derive(Clone, Debug, Default, PartialEq)]
+323 -4
View File
@@ -20,13 +20,16 @@ impl<Msg> View<Msg> {
painter: None,
gpu_painter: None,
over_painter: None,
over_layer: false,
on_pointer_enter: None,
on_pointer_leave: None,
on_pointer_move_at: None,
on_click: None,
on_click_at: None,
on_click_screen: None,
on_right_click: None,
on_right_click_at: None,
on_right_click_screen: None,
on_middle_click: None,
drag: None,
drag_at: None,
@@ -41,6 +44,7 @@ impl<Msg> View<Msg> {
clip_path_svg: None,
clip_ref_inset: None,
on_scroll: None,
on_scroll_take: None,
on_scale: None,
on_rotate: None,
on_double_tap: None,
@@ -57,6 +61,7 @@ impl<Msg> View<Msg> {
transform: None,
transform_rel: None,
transform_origin: None,
sticky: None,
tooltip: None,
cursor: None,
ripple: None,
@@ -64,6 +69,8 @@ impl<Msg> View<Msg> {
backdrop_blur: None,
filter: Vec::new(),
blend: None,
repaint_boundary: None,
memo: None,
children: Vec::new(),
}
}
@@ -122,6 +129,43 @@ impl<Msg> View<Msg> {
self
}
/// Marca este nodo como un **límite de repintado** (`RepaintBoundary`,
/// Bloque 23): el runtime puede cachear la rasterización de su subárbol y
/// reusarla entre frames mientras el contenido no cambie, en vez de
/// re-pintarlo. Sólo surte efecto si el subárbol es *paint-puro* y está en
/// una posición sin transform/clip/alpha ancestral (si no, se pinta fresco
/// — nunca produce un frame incorrecto, sólo se saltea la optimización).
/// `key` debe ser **estable entre rebuilds** (identifica la entrada del
/// caché). Pensalo para subárboles grandes y estáticos: una página de
/// documento, un chart, un panel de chrome que no cambia. Ver
/// [`crate::RepaintCache`].
pub fn repaint_boundary(mut self, key: u64) -> Self {
self.repaint_boundary = Some(key);
self
}
/// Marca este nodo como **memoizado** (`memo`, Bloque 25): su contenido lo
/// produce `builder`, y el runtime lo **reusa entre frames** mientras `deps`
/// no cambie — sin reconstruir el `View`, sin re-montar ni re-layoutear el
/// subárbol. `builder` devuelve un único `View` (el contenido que va dentro
/// de este nodo). El nodo debe llevar un `Style` con **tamaño definido**
/// (length/percent): el runtime lo trata como una hoja de ese tamaño y
/// computa el subárbol aislado con ese slot. `deps` debe resumir TODO lo que
/// el contenido depende (si te olvidas una dep, el subárbol queda stale).
/// Pensalo para chrome grande y estable: un panel, un sidebar, un formulario
/// que no cambia salvo un valor. Los `children` estáticos se ignoran.
pub fn memo<F>(mut self, key: u64, deps: u64, builder: F) -> Self
where
F: Fn() -> View<Msg> + Send + Sync + 'static,
{
self.memo = Some(crate::MemoNode {
key,
deps,
builder: Some(std::sync::Arc::new(builder)),
});
self
}
/// Construye los hijos de este nodo **de forma diferida**, en función del
/// tamaño del slot que el layout le asigne (Flutter `LayoutBuilder`). El
/// runtime resuelve primero el rect del nodo (una pasada de layout con este
@@ -150,7 +194,7 @@ impl<Msg> View<Msg> {
/// punto presionado y se desvanece, recortado al contorno del nodo. `key`
/// debe ser **estable** entre rebuilds del `View` (índice/hash del item),
/// igual que la key de [`Self::animated`]. `color` es el tinte de la onda —
/// usá un color semitransparente (blanco a alpha ~0.25 sobre superficies
/// usa un color semitransparente (blanco a alpha ~0.25 sobre superficies
/// oscuras, negro a alpha ~0.12 sobre claras); su alpha se atenúa con el
/// fade. Es **aditivo**: convive con `on_click`/`drag` sin pisarlos. Duración
/// por defecto 450 ms; para otra usar [`Self::ripple_styled`].
@@ -190,7 +234,7 @@ impl<Msg> View<Msg> {
}
/// Declara la **semántica accesible** completa del nodo de una vez. Usar
/// cuando ya tenés un [`SemanticsSpec`] armado (p. ej. construido por un
/// cuando ya tienes un [`SemanticsSpec`] armado (p. ej. construido por un
/// widget); para los casos puntuales preferí los atajos
/// [`Self::role`]/[`Self::aria_label`]/etc.
pub fn semantics(mut self, spec: SemanticsSpec) -> Self {
@@ -302,6 +346,21 @@ impl<Msg> View<Msg> {
self
}
/// Como [`Self::on_scroll`] pero con **reparto parcial** del delta
/// (scroll anidado real): el handler devuelve `(msg, rest_x, rest_y)` —
/// el `Msg` por lo que consumió y el sobrante en la misma unidad
/// (líneas), que el runtime entrega al siguiente scrollable de la cadena.
/// Así una lista casi topada absorbe los píxeles que le quedan Y el
/// ancestro scrollea el resto en el **mismo evento** (antes: consume
/// todo o nada por eje). Si el nodo tiene ambos handlers, gana éste.
pub fn on_scroll_take<F>(mut self, handler: F) -> Self
where
F: Fn(f32, f32) -> (Option<Msg>, f32, f32) + Send + Sync + 'static,
{
self.on_scroll_take = Some(Arc::new(handler));
self
}
/// Registra un handler de **pinch-to-zoom** (gesto de escala). El runtime
/// lo invoca cuando el cursor está sobre este nodo y el usuario hace un
/// gesto de escala: **Ctrl + rueda** en cualquier desktop (camino
@@ -395,7 +454,7 @@ impl<Msg> View<Msg> {
/// editor: arrastrar sobre él resalta el rango y Ctrl/Cmd+C lo copia al
/// portapapeles. `key` debe ser **estable** entre rebuilds del `View`
/// (índice, hash del id) — la selección vive en el runtime anclada a esa
/// key, no al `NodeId` (que cambia cada frame). Pensá en labels, párrafos,
/// key, no al `NodeId` (que cambia cada frame). Piensa en labels, párrafos,
/// celdas de tabla, salidas de consola: cualquier texto que el usuario
/// querría copiar sin un editor. Sólo aplica a texto **uniforme** (el de
/// `.text(...)`/`.text_aligned(...)`); en nodos con `runs`/`spans` no tiene
@@ -456,6 +515,16 @@ impl<Msg> View<Msg> {
self
}
/// `position: sticky` (CSS): insets `[top, right, bottom, left]` en px
/// relativos al scrollport (ancestro que recorta). El nodo fluye normal
/// hasta el umbral y luego se pega a esa distancia del borde. Ver el campo
/// [`View::sticky`]. Ejemplo: un header `top:0` ⇒ `.sticky([Some(0.0),
/// None, None, None])`.
pub fn sticky(mut self, insets: [Option<f32>; 4]) -> Self {
self.sticky = Some(insets);
self
}
/// Punto de pivote de `transform` (CSS `transform-origin`). Sin setear ⇒
/// centro del rect (`50% 50%`). Ver [`TransformPivot`]. Sólo tiene efecto
/// junto con `transform`/`transform_rel`.
@@ -497,6 +566,10 @@ impl<Msg> View<Msg> {
exit: false,
enter_from_xf: None,
switch: None,
exit_to_xf: None,
enter_fade: true,
exit_under: false,
exit_hold: false,
});
self
}
@@ -552,6 +625,10 @@ impl<Msg> View<Msg> {
exit: false,
enter_from_xf: None,
switch: None,
exit_to_xf: None,
enter_fade: true,
exit_under: false,
exit_hold: false,
});
self
}
@@ -581,6 +658,10 @@ impl<Msg> View<Msg> {
exit: false,
enter_from_xf: Some(from_xf),
switch: None,
exit_to_xf: None,
enter_fade: true,
exit_under: false,
exit_hold: false,
});
self
}
@@ -606,6 +687,10 @@ impl<Msg> View<Msg> {
exit: true,
enter_from_xf: None,
switch: None,
exit_to_xf: None,
enter_fade: true,
exit_under: false,
exit_hold: false,
});
self
}
@@ -623,6 +708,10 @@ impl<Msg> View<Msg> {
exit: true,
enter_from_xf: None,
switch: None,
exit_to_xf: None,
enter_fade: true,
exit_under: false,
exit_hold: false,
});
self
}
@@ -645,6 +734,10 @@ impl<Msg> View<Msg> {
exit: true,
enter_from_xf: Some(from_xf),
switch: None,
exit_to_xf: None,
enter_fade: true,
exit_under: false,
exit_hold: false,
});
self
}
@@ -665,6 +758,10 @@ impl<Msg> View<Msg> {
exit: false,
enter_from_xf: None,
switch: None,
exit_to_xf: None,
enter_fade: true,
exit_under: false,
exit_hold: false,
});
self
}
@@ -695,6 +792,92 @@ impl<Msg> View<Msg> {
exit: false,
enter_from_xf: None,
switch: Some(variant),
exit_to_xf: None,
enter_fade: true,
exit_under: false,
exit_hold: false,
});
self
}
/// Como [`Self::animated_switch`] pero **direccional**: al cambiar la
/// variante, el contenido nuevo entra desde `enter_from_xf` (además del
/// fade-in) y el viejo sale hacia `exit_to_xf` (además del fade-out),
/// ambas afines aplicadas alrededor del centro del rect. Es la base de las
/// transiciones de página del router: push = entrar desde la derecha /
/// salir hacia la izquierda; pop = lo inverso. `None` en cualquiera de las
/// dos degrada a fade puro en ese lado.
pub fn animated_switch_from(
self,
key: u64,
variant: u64,
duration: std::time::Duration,
enter_from_xf: Option<Affine>,
exit_to_xf: Option<Affine>,
) -> Self {
self.animated_switch_styled(
key,
variant,
duration,
crate::SwitchStyle {
enter_from_xf,
exit_to_xf,
..Default::default()
},
)
}
/// La forma **completa** del switch direccional: [`crate::SwitchStyle`]
/// agrupa los cinco knobs — afín de entrada, fade de entrada, afín de
/// salida, placement del fantasma (encima vs **debajo** del contenido
/// nuevo, en su posición z real) y hold de opacidad (salida opaca, sin
/// desvanecer). Con `enter_fade: false` + `exit_under: true` sale el push
/// opaco estilo UIKit; el default reproduce `animated_switch`.
pub fn animated_switch_styled(
mut self,
key: u64,
variant: u64,
duration: std::time::Duration,
style: crate::SwitchStyle,
) -> Self {
self.anim = Some(Anim {
key,
duration,
easing: ease_out_cubic,
enter: false,
exit: false,
enter_from_xf: style.enter_from_xf,
switch: Some(variant),
exit_to_xf: style.exit_to_xf,
enter_fade: style.enter_fade,
exit_under: style.exit_under,
exit_hold: style.exit_hold,
});
self
}
/// Como [`Self::animated_exit`] pero la salida además se **desliza** hacia
/// `to_xf` mientras se desvanece (p. ej. un toast que se va hacia abajo:
/// `Affine::translate((0.0, 40.0))`). La afín se aplica alrededor del
/// centro del rect del nodo.
pub fn animated_exit_to(
mut self,
key: u64,
duration: std::time::Duration,
to_xf: Affine,
) -> Self {
self.anim = Some(Anim {
key,
duration,
easing: ease_out_cubic,
enter: false,
exit: true,
enter_from_xf: None,
switch: None,
exit_to_xf: Some(to_xf),
enter_fade: true,
exit_under: false,
exit_hold: false,
});
self
}
@@ -808,7 +991,7 @@ impl<Msg> View<Msg> {
/// Rellena el nodo con un **gradiente** en vez de un color sólido. El
/// gradiente se autorea en el **cuadrado unidad** `[0,1]²` y el runtime
/// lo mapea al rect del nodo (así no necesitás saber el tamaño al
/// lo mapea al rect del nodo (así no necesitas saber el tamaño al
/// construir el `View`) — igual que `Alignment` relativo de Flutter.
///
/// ```ignore
@@ -856,6 +1039,9 @@ impl<Msg> View<Msg> {
spans: None,
no_wrap: false,
overflow_wrap: false,
font_features: String::new(),
font_variations: String::new(),
font_width: 100.0,
});
self
}
@@ -886,6 +1072,9 @@ impl<Msg> View<Msg> {
spans: None,
no_wrap: false,
overflow_wrap: false,
font_features: String::new(),
font_variations: String::new(),
font_width: 100.0,
});
self
}
@@ -919,6 +1108,9 @@ impl<Msg> View<Msg> {
spans: None,
no_wrap: false,
overflow_wrap: false,
font_features: String::new(),
font_variations: String::new(),
font_width: 100.0,
});
self
}
@@ -954,6 +1146,9 @@ impl<Msg> View<Msg> {
spans: None,
no_wrap: false,
overflow_wrap: false,
font_features: String::new(),
font_variations: String::new(),
font_width: 100.0,
});
self
}
@@ -990,6 +1185,9 @@ impl<Msg> View<Msg> {
spans: None,
no_wrap: false,
overflow_wrap: false,
font_features: String::new(),
font_variations: String::new(),
font_width: 100.0,
});
self
}
@@ -1031,6 +1229,9 @@ impl<Msg> View<Msg> {
spans: Some(spans),
no_wrap: false,
overflow_wrap: false,
font_features: String::new(),
font_variations: String::new(),
font_width: 100.0,
});
self
}
@@ -1128,6 +1329,37 @@ impl<Msg> View<Msg> {
self
}
/// `font-feature-settings` (CSS): string crudo de features OpenType en
/// gramática CSS (`"liga" 1, "kern" 0`) — parley/swash lo parsean. Vacío =
/// default de la fuente. `font-kerning` se pliega aquí como el feature
/// `kern`. Afecta medida y pintado. No-op sin texto (o string vacío).
pub fn font_features(mut self, css: String) -> Self {
if let Some(t) = self.text.as_mut() {
t.font_features = css;
}
self
}
/// `font-variation-settings` (CSS): string crudo de ejes de fuente variable
/// en gramática CSS (`"wght" 700, "wdth" 85`). Vacío = ejes en su default.
/// Mismo régimen que [`Self::font_features`]. No-op sin texto.
pub fn font_variations(mut self, css: String) -> Self {
if let Some(t) = self.text.as_mut() {
t.font_variations = css;
}
self
}
/// `font-stretch`/`font-width` (CSS): porcentaje de ancho de la fuente
/// (100 = normal, <100 condensada, >100 expandida). Selecciona la face o el
/// eje `wdth` de una variable. Afecta medida y pintado. No-op sin texto.
pub fn font_width(mut self, pct: f32) -> Self {
if let Some(t) = self.text.as_mut() {
t.font_width = pct;
}
self
}
/// `white-space: nowrap`/`pre` (CSS): el texto **no envuelve** — se shapea
/// en una sola línea (`break_all_lines(None)`) sin importar el ancho de la
/// caja, y desborda (lo recorta `overflow: hidden` si lo hay). Afecta
@@ -1241,6 +1473,34 @@ impl<Msg> View<Msg> {
self
}
/// Como [`Self::on_right_click_at`] pero el handler recibe
/// `(abs_x, abs_y, rect_w, rect_h)` con las coords **absolutas de ventana**
/// del click (no locales al nodo). Es lo que quiere un menú contextual:
/// aparecer en la posición del puntero en pantalla. Los backends (winit /
/// layer-shell) le pasan el cursor absoluto. Úsalo en vez de
/// `on_right_click_at` cuando el nodo va a abrir un popup anclado al cursor.
pub fn on_right_click_screen<F>(mut self, handler: F) -> Self
where
F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
{
self.on_right_click_screen = Some(Arc::new(handler));
self
}
/// Como [`Self::on_click_at`] pero el handler recibe `(abs_x, abs_y, rect_w,
/// rect_h)` con las coords **absolutas de ventana** del click (no locales al
/// nodo) — el gemelo de izquierda de [`Self::on_right_click_screen`]. Es lo
/// que quiere un **select/dropdown**: al hacer click en el disparador, abrir
/// su menú anclado a la posición en pantalla (en la capa `view_overlay`,
/// encima de todo). Los backends le pasan el cursor absoluto.
pub fn on_click_screen<F>(mut self, handler: F) -> Self
where
F: Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync + 'static,
{
self.on_click_screen = Some(Arc::new(handler));
self
}
/// Declara el `Msg` a emitir cuando el usuario hace click con el
/// botón del medio (rueda presionada). Usado típicamente para abrir
/// links en pestaña nueva — igual que Ctrl+Click pero más rápido.
@@ -1364,6 +1624,26 @@ impl<Msg> View<Msg> {
self
}
/// Marca este nodo y **todo su subárbol** como capa "over": se pinta
/// en la pasada vello FINAL, después del pase GPU directo del frame,
/// con toda la maquinaria normal de Views (fills, texto, imágenes,
/// `paint_with`, hover). Es el primitivo para componer **UI encima de
/// contenido `gpu_paint_with`** — la barra de controles flotante de un
/// reproductor de video, un HUD sobre un canvas GPU — sin reescribir
/// esos widgets como closures de [`Self::paint_over`].
///
/// El orden total del frame no cambia: `[vello base] → [gpu_paint] →
/// [vello over (paint_over + subárboles over)] → [overlay/menús]`. El
/// hit-test es el de siempre (usa el layout, no la pasada de pintura),
/// así que los handlers del subárbol siguen funcionando idéntico.
/// Limitación (como `paint_over`): la capa over se compone con alpha
/// sobre la intermedia — clips/alpha de ancestros fuera del subárbol
/// no la afectan.
pub fn over(mut self, enabled: bool) -> Self {
self.over_layer = enabled;
self
}
/// Recorta los hijos al rect de este nodo (paint y hit-test). Útil
/// para paneles con contenido virtualizado que no debe sangrar a
/// vecinos (listas, scrollers, viewers).
@@ -1750,4 +2030,43 @@ mod semantics_tests {
let sin = View::<()>::new(Style::default()).overflow_wrap();
assert!(sin.text.is_none(), "overflow_wrap sin texto no crea TextSpec");
}
#[test]
fn font_features_y_variations_setean_campos_del_texto_fase_7_1256() {
// `.font_features()`/`.font_variations()` guardan el string CSS crudo en
// el TextSpec (parley/swash lo parsean al shapear). Ortogonales entre sí
// y al resto; default vacío. No-op si el nodo no tiene texto.
let v = View::<()>::new(Style::default())
.text("hola", 14.0, Color::BLACK)
.font_features("\"liga\" 0, \"kern\" 0".to_string())
.font_variations("\"wght\" 700".to_string());
let t = v.text.as_ref().expect("text");
assert_eq!(t.font_features, "\"liga\" 0, \"kern\" 0");
assert_eq!(t.font_variations, "\"wght\" 700");
// Default: sin override (strings vacíos).
let def = View::<()>::new(Style::default()).text("x", 14.0, Color::BLACK);
let dt = def.text.as_ref().unwrap();
assert!(dt.font_features.is_empty(), "default features vacío");
assert!(dt.font_variations.is_empty(), "default variations vacío");
// No-op sin texto: no panickea ni inventa un TextSpec.
let sin = View::<()>::new(Style::default()).font_features("\"liga\" 1".to_string());
assert!(sin.text.is_none(), "font_features sin texto no crea TextSpec");
}
#[test]
fn font_width_setea_campo_del_texto_fase_7_1257() {
// `.font_width()` guarda el porcentaje de ancho de la fuente en el
// TextSpec (100 = normal). Ortogonal al resto; default 100. No-op sin texto.
let v = View::<()>::new(Style::default())
.text("hola", 14.0, Color::BLACK)
.font_width(75.0);
let t = v.text.as_ref().expect("text");
assert_eq!(t.font_width, 75.0, "font_width=75 tras el builder");
// Default: 100 (normal).
let def = View::<()>::new(Style::default()).text("x", 14.0, Color::BLACK);
assert_eq!(def.text.as_ref().unwrap().font_width, 100.0, "default 100 (normal)");
// No-op sin texto.
let sin = View::<()>::new(Style::default()).font_width(50.0);
assert!(sin.text.is_none(), "font_width sin texto no crea TextSpec");
}
}
+9
View File
@@ -82,6 +82,9 @@ fn no_wrap_mide_una_sola_linea_fase_7_1253() {
word_spacing: 0.0,
no_wrap,
overflow_wrap: false,
font_features: String::new(),
font_variations: String::new(),
font_width: 100.0,
};
let mut ts = llimphi_text::Typesetter::new();
let known = TSize { width: None, height: None };
@@ -127,6 +130,9 @@ fn line_height_mayor_reserva_mas_alto() {
word_spacing: 0.0,
no_wrap: false,
overflow_wrap: false,
font_features: String::new(),
font_variations: String::new(),
font_width: 100.0,
};
let known = TSize { width: Some(180.0_f32), height: None };
let avail = TSize {
@@ -168,6 +174,9 @@ fn overflow_wrap_parte_la_palabra_larga_fase_7_1254() {
word_spacing: 0.0,
no_wrap: false,
overflow_wrap,
font_features: String::new(),
font_variations: String::new(),
font_width: 100.0,
};
let mut ts = llimphi_text::Typesetter::new();
let known = TSize { width: None, height: None };
+1 -2
View File
@@ -5,9 +5,9 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "llimphi-gallery — demo único que prueba el kit transversal de elegancia. Binario standalone; `cargo run -p llimphi-gallery --release`."
repository.workspace = true
[[bin]]
name = "llimphi-gallery"
path = "src/main.rs"
@@ -15,7 +15,6 @@ path = "src/main.rs"
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-motion = { workspace = true }
llimphi-icons = { workspace = true }
llimphi-widget-wawa-mark = { workspace = true }
llimphi-widget-tooltip = { workspace = true }
+28
View File
@@ -0,0 +1,28 @@
# llimphi-gallery
*Read this in English: [README.md](README.md).*
Demo único del kit transversal de elegancia.
Una sola ventana que muestra cómo se ven los widgets del kit
juntos sobre el theme dark. Útil para verificar paleta, escala,
cinética y consistencia visual de un vistazo.
`cargo run -p llimphi-gallery --release`
Controles:
- Click en switches/segments/breadcrumb: dispatchea Msg
- Click en "Mostrar toast": apila un toast en bottom-right
- Click en "Abrir modal": muestra el modal
- `?`: abre/cierra el overlay de atajos
- Esc: cierra overlay activo
## Uso
```sh
cargo run --release -p llimphi-gallery
```
---
Parte de **llimphi** — ver [llimphi](../LEEME.md).
+24
View File
@@ -0,0 +1,24 @@
# llimphi-gallery
The single demo of the cross-cutting elegance kit.
One window showing how the kit's widgets look together on the dark theme. Useful
to check palette, scale, kinetics and visual consistency at a glance.
## Use
```sh
cargo run --release -p llimphi-gallery
```
Controls:
- Click on switches/segments/breadcrumb: dispatches a `Msg`.
- Click on "show toast": stacks a toast in the bottom-right.
- Click on "open modal": shows the modal.
- `?`: opens/closes the shortcuts overlay.
- `Esc`: closes the active overlay.
---
Part of **llimphi** — see [llimphi](../README.md).
+2 -1
View File
@@ -317,6 +317,7 @@ impl App for Gallery {
viewport: model.viewport,
on_dismiss: Msg::CloseModal,
palette: ModalPalette::from_theme(&theme),
body_scroll: None,
}));
}
if model.shortcuts_open {
@@ -473,7 +474,7 @@ fn column_left(model: &Model, theme: &Theme) -> View<Msg> {
.children(vec![empty_view(
Icon::Folder,
"Sin documentos abiertos",
Some("Abrí uno con Ctrl+O o creá un nuevo lienzo para empezar."),
Some("Abrí uno con Ctrl+O o crea un nuevo lienzo para empezar."),
&EmptyPalette::from_theme(theme),
)]),
);
+1 -1
View File
@@ -5,9 +5,9 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "Binario standalone que valida el SDD §'GPU directo wgpu' en una máquina con GPU real: imprime info del adapter, corre vello vs GPU directo a varios N, evalúa el criterio (≥5× a 500K, ≥60 fps @ 1M) y exporta PNGs de verificación."
repository.workspace = true
[dependencies]
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" }
+35
View File
@@ -0,0 +1,35 @@
# llimphi-gpu-bench
*Read this in English: [README.md](README.md).*
Binario standalone para validar el SDD `02_ruway/llimphi/SDD.md` §"GPU directo wgpu" en una máquina con GPU real.
Hace cuatro cosas en orden y lo imprime todo a stdout en formato
markdown / tabla copy-paste friendly:
1. **Header del sistema** — versión, hora, OS, GPU detectado.
2. **Info del adapter wgpu** — backend (Vulkan/Metal/DX12/GL),
device name, vendor, limits relevantes.
3. **Spike vello vs GPU directo** — para N ∈ {25K, 50K, 100K, 200K,
500K, 1M}. Mide ms/frame de cada uno y el factor. Evalúa el
criterio del SDD: ≥5× a 500K → PASA; < → ABORTAR.
4. **Escalado GPU directo solo** — para N ∈ {100K, 500K, 1M, 2M,
5M, 10M}. Mide ms/frame, fps equivalente, Mprim/s. Evalúa el
objetivo de 60 fps @ 1M.
5. **PNGs de verificación visual** — exporta 2 archivos al cwd:
`bench_vello_100k.png` y `bench_directo_100k.png`. La forma del
cielo de puntos debe coincidir entre los dos (LCG determinista).
Pegar el output completo en chat para la verificación.
Corre con: `cargo run -p llimphi-gpu-bench --release`.
## Uso
```sh
cargo run --release -p llimphi-gpu-bench
```
---
Parte de **llimphi** — ver [llimphi](../LEEME.md).
+30
View File
@@ -0,0 +1,30 @@
# llimphi-gpu-bench
Standalone binary to validate the "direct wgpu GPU" section of
`02_ruway/llimphi/SDD.md` on a machine with a real GPU.
It does four things in order and prints everything to stdout in
copy-paste-friendly markdown/table form:
1. **System header** — version, time, OS, detected GPU.
2. **wgpu adapter info** — backend (Vulkan/Metal/DX12/GL), device name, vendor,
relevant limits.
3. **vello vs direct GPU spike** — for N ∈ {25K, 50K, 100K, 200K, 500K, 1M}. It
measures ms/frame for each and the factor, and evaluates the SDD's criterion:
≥5× at 500K → PASS; below → ABORT.
4. **Direct-GPU scaling alone** — for N ∈ {100K, 500K, 1M, 2M, 5M, 10M}. It
measures ms/frame, equivalent fps and Mprim/s, and evaluates the 60 fps @ 1M
goal.
5. **Visual verification PNGs** — it exports two files to the cwd:
`bench_vello_100k.png` and `bench_directo_100k.png`. The shape of the dot sky
must match between the two (deterministic LCG).
## Use
```sh
cargo run --release -p llimphi-gpu-bench
```
---
Part of **llimphi** — see [llimphi](../README.md).
+9 -8
View File
@@ -132,7 +132,7 @@ fn main() {
println!("## Escalado GPU directo");
println!();
println!("API real (`GpuPipelines` + `GpuBatch::add_rect`). Sólo se mide el lado GPU directo — vello no llega a.");
println!("API real (`GpuPipelines` + `GpuBatch::add_rect`). Sólo se mide el lado GPU directo — vello no llega aquí.");
println!();
println!("| N | ms / frame | fps (1000/ms) | Mprim/s |");
println!("|---:|---:|---:|---:|");
@@ -406,7 +406,7 @@ fn bench_vello(
},
)
.expect("vello render");
hal.device.poll(wgpu::Maintain::Wait);
hal.device.poll(wgpu::PollType::wait_indefinitely());
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP {
samples.push(dt);
@@ -448,7 +448,7 @@ fn bench_directo(
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
);
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::Maintain::Wait);
hal.device.poll(wgpu::PollType::wait_indefinitely());
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP {
samples.push(dt);
@@ -501,7 +501,7 @@ fn bench_vello_persistent(
},
)
.expect("vello render");
hal.device.poll(wgpu::Maintain::Wait);
hal.device.poll(wgpu::PollType::wait_indefinitely());
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP {
samples.push(dt);
@@ -569,7 +569,7 @@ fn bench_directo_persistent(
// 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::Maintain::Wait);
hal.device.poll(wgpu::PollType::wait_indefinitely());
let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
for frame in 0..(WARMUP + MEASURED) {
@@ -585,6 +585,7 @@ fn bench_directo_persistent(
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,
@@ -600,7 +601,7 @@ fn bench_directo_persistent(
pass.draw(0..6, 0..n);
}
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::Maintain::Wait);
hal.device.poll(wgpu::PollType::wait_indefinitely());
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP {
samples.push(dt);
@@ -865,7 +866,7 @@ fn export_directo_png(
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
);
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::Maintain::Wait);
hal.device.poll(wgpu::PollType::wait_indefinitely());
write_texture_png(hal, target, path)
}
@@ -916,7 +917,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::Maintain::Wait);
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().map_err(|e| e.to_string())?.map_err(|e| e.to_string())?;
let data = slice.get_mapped_range();
+2 -2
View File
@@ -1,13 +1,13 @@
[package]
name = "llimphi-hal"
description = "Surface/device HAL for the llimphi UI framework (wgpu) — one scene tree across Wayland/X11/Win32/Android/bare-metal."
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "Surface/device HAL for the llimphi UI framework (wgpu) — one scene tree across Wayland/X11/Win32/Android/bare-metal."
repository.workspace = true
[dependencies]
wgpu = { workspace = true }
raw-window-handle = { workspace = true }
+447 -30
View File
@@ -20,6 +20,23 @@ pub enum SurfaceError {
Outdated,
OutOfMemory,
Timeout,
/// La surface quedó **irrecuperable a nivel de dispositivo**: reconfigurar
/// y recrear la `wgpu::Surface` (reusando el mismo device/adapter) fallaron
/// ambos. El único remedio es reconstruir todo el stack GPU
/// (instance+adapter+device+queue+surface) y con él los recursos colgados
/// del device (renderer vello, compositores, texturas). El dueño del bucle
/// de render debe atrapar esta variante y rehacer su mundo GPU — ver
/// [`Hal::recreate_for_window`] (camino winit) y el runner de layer-shell de
/// `pata` (camino raw-surface). Distinta de [`SurfaceError::Lost`], que es
/// un hipo transitorio que la propia surface ya recuperó.
///
/// Se dispara en el modo de falla de Iris Xe bajo mirada-DRM: al resetear
/// el output, la surface Vulkan cae con «Surface does not support the
/// adapter's queue family» y recrearla contra el mismo adapter no ayuda.
/// Antes esto tumbaba/loopeaba a los clientes (greeter/pata/shuma) o exigía
/// forzar el backend GL; la recuperación de device lo resuelve de forma
/// general (no atada a esta GPU).
DeviceLost,
Other(String),
}
@@ -30,6 +47,7 @@ impl std::fmt::Display for SurfaceError {
Self::Outdated => write!(f, "surface outdated"),
Self::OutOfMemory => write!(f, "surface out of memory"),
Self::Timeout => write!(f, "surface timeout"),
Self::DeviceLost => write!(f, "device lost (full GPU-stack rebuild required)"),
Self::Other(s) => write!(f, "surface error: {s}"),
}
}
@@ -84,7 +102,7 @@ pub struct Frame {
intermediate_view: wgpu::TextureView,
/// Textura secundaria para la capa de overlay (menús/paleta/modal)
/// cuando hay contenido `gpu_paint` que la taparía. El overlay se
/// rasteriza a con fondo transparente y luego se compone con
/// rasteriza aquí con fondo transparente y luego se compone con
/// alpha SOBRE la intermedia (que ya tiene UI + video). Ver
/// [`OverlayCompositor`] y el eventloop de `llimphi-ui`.
overlay_view: wgpu::TextureView,
@@ -117,16 +135,157 @@ pub struct Hal {
pub queue: wgpu::Queue,
}
/// Override del backend wgpu por la env `LLIMPHI_WGPU_BACKEND`. Valores:
/// `gl`/`gles`/`opengl`/`egl` → GL; `vulkan`/`vk` → Vulkan; `primary` → PRIMARY;
/// `all` → todos. Sin la env (o valor no reconocido) devuelve `None` y el caller
/// usa su default (PRIMARY con fallback a todos).
///
/// **Para qué**: en Iris Xe bajo mirada-DRM, el backend Vulkan revienta al
/// reconfigurar la surface tras un reset de output («Surface does not support the
/// adapter's queue family»: falta `VK_EXT_physical_device_drm` y wgpu no puede
/// re-matchear el adapter a la surface recreada). Como recrear la surface reusa el
/// mismo device/adapter, el error persiste y los clientes layer (greeter/pata/
/// shuma) entran en crash-loop al boot y en cada cambio de VT. Forzar GL
/// (`LLIMPHI_WGPU_BACKEND=gl`) esquiva el problema —GL no tiene el concepto de
/// queue family— al costo del bug de teardown de GL (SIGSEGV al cerrar, tolerable
/// frente a un crash-loop que impide la sesión). Los clientes lo heredan del env
/// del compositor. Ver memoria `pata-layer-closed-arje-drm`.
fn backend_override() -> Option<wgpu::Backends> {
let v = std::env::var("LLIMPHI_WGPU_BACKEND").ok()?;
match v.trim().to_ascii_lowercase().as_str() {
"gl" | "gles" | "opengl" | "egl" => Some(wgpu::Backends::GL),
"vulkan" | "vk" => Some(wgpu::Backends::VULKAN),
"primary" => Some(wgpu::Backends::PRIMARY),
"all" => Some(wgpu::Backends::all()),
other => {
eprintln!("llimphi-hal: LLIMPHI_WGPU_BACKEND=«{other}» no reconocido; uso el default (PRIMARY)");
None
}
}
}
/// Pista de memoria explícita por la env `LLIMPHI_WGPU_MEM`. `low`/`min`/`ram`
/// → [`MemoryHints::MemoryUsage`] (el asignador de wgpu minimiza la huella:
/// bloques de suballocación chicos, menos reserva especulativa). `perf` →
/// [`MemoryHints::Performance`] (pools grandes, prioriza velocidad). `None` si
/// la env no está o no se reconoce → el caller **autodetecta** (ver
/// [`resolver_memory_hint`]). El manager de `pacha` la fija por contexto para
/// equipos chicos; sin ella, la app decide sola.
fn memory_hint_env() -> Option<wgpu::MemoryHints> {
match std::env::var("LLIMPHI_WGPU_MEM")
.map(|v| v.trim().to_ascii_lowercase())
.as_deref()
{
Ok("low") | Ok("min") | Ok("ram") | Ok("memoria") => Some(wgpu::MemoryHints::MemoryUsage),
Ok("perf") | Ok("performance") | Ok("velocidad") => Some(wgpu::MemoryHints::Performance),
Ok(other) if !other.is_empty() => {
eprintln!("llimphi-hal: LLIMPHI_WGPU_MEM=«{other}» no reconocido; autodetecto");
None
}
_ => None,
}
}
/// Resuelve la pista de memoria del device: la env manda; sin ella,
/// **autoadapta** a la máquina. Un adapter de tipo `Cpu` (raster por software,
/// llvmpipe / VM sin GPU) no tiene VRAM y compone contra la RAM del sistema, así
/// que ahí `MemoryUsage` (mínima huella) es la elección correcta por defecto. Con
/// una GPU real se mantiene `Performance` salvo que el env/pacha pida lo
/// contrario. Es la contraparte a nivel de render de `pacha_core::Rendimiento`.
fn resolver_memory_hint(adapter: &wgpu::Adapter) -> wgpu::MemoryHints {
if let Some(h) = memory_hint_env() {
return h;
}
match adapter.get_info().device_type {
wgpu::DeviceType::Cpu => wgpu::MemoryHints::MemoryUsage,
_ => wgpu::MemoryHints::Performance,
}
}
/// Preferencia de potencia por la env `LLIMPHI_WGPU_POWER`. `low`/`ahorro` →
/// [`PowerPreference::LowPower`] (elige la iGPU integrada en equipos híbridos:
/// menos consumo, menos RAM de driver, y de paso esquiva el problema de dmabuf
/// cruzando dispositivos en Optimus). Sin la env → `HighPerformance`, el default
/// de siempre. Una barra o un WM 2D nunca necesitan la dGPU discreta.
fn power_preference_override() -> wgpu::PowerPreference {
match std::env::var("LLIMPHI_WGPU_POWER")
.map(|v| v.trim().to_ascii_lowercase())
.as_deref()
{
Ok("low") | Ok("ahorro") | Ok("integrada") => wgpu::PowerPreference::LowPower,
Ok("high") | Ok("alto") | Ok("discreta") => wgpu::PowerPreference::HighPerformance,
Ok(other) if !other.is_empty() => {
eprintln!("llimphi-hal: LLIMPHI_WGPU_POWER=«{other}» no reconocido; uso HighPerformance");
wgpu::PowerPreference::HighPerformance
}
_ => wgpu::PowerPreference::HighPerformance,
}
}
/// Gancho de verificación: fuerza un [`SurfaceError::DeviceLost`] sintético cada
/// N cuadros para poder ejercitar la recuperación de device SIN esperar un reset
/// de output real (que en Iris Xe sólo pasa bajo mirada-DRM, no bajo KDE/kwin).
///
/// Activar con `LLIMPHI_SIMULATE_DEVICE_LOSS=N` (N≥1): cada N `acquire()`
/// exitosos, el N-ésimo devuelve `DeviceLost` en vez de un `Frame`. El dueño del
/// bucle debe reconstruir todo el stack GPU; si tras eso el render sigue vivo y
/// sin fugas, la recuperación quedó certificada como texto (no screenshot). Sin
/// la env, es un no-op de coste cero (un `load` atómico Relaxed). El contador es
/// global al proceso: con varias surfaces reparte los trips entre ellas, lo cual
/// es justo lo que queremos (probar que CUALQUIER surface dispara el rebuild).
fn simulate_device_loss_trip() -> bool {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
static PERIOD: OnceLock<Option<u64>> = OnceLock::new();
let period = *PERIOD.get_or_init(|| {
std::env::var("LLIMPHI_SIMULATE_DEVICE_LOSS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|n| *n >= 1)
});
let Some(period) = period else { return false };
static COUNT: AtomicU64 = AtomicU64::new(0);
// fetch_add devuelve el valor previo; el trip cae en el múltiplo de period.
(COUNT.fetch_add(1, Ordering::Relaxed) + 1) % period == 0
}
impl Hal {
/// Construye Hal pidiendo un adapter compatible con una surface dada
/// (recomendado: pasar `Some(&surface)` para garantizar que el adapter
/// elegido sabe presentar a esa surface).
pub async fn new(
compatible_surface: Option<&wgpu::Surface<'static>>,
) -> Result<Self, HalError> {
Self::new_inner(compatible_surface, false).await
}
/// Construye un `Hal` **headless** (sin surface) prefiriendo el adapter de
/// **software** (llvmpipe/lavapipe, `deviceType == CPU`) cuando
/// `prefer_software`. Es el modo de los tests golden de `llimphi-test`: el
/// rasterizador por software es DETERMINISTA e idéntico host-a-host y en CI,
/// así el golden no queda atado a la GPU de quien lo generó. Si la máquina no
/// expone un fallback adapter (no hay lavapipe instalado), cae al adapter
/// normal — en ese caso el golden se certifica con tolerancia perceptual, no
/// bit-exacta. Nunca pasa `compatible_surface`: renderiza a textura, no
/// presenta a ventana.
pub async fn new_headless(prefer_software: bool) -> Result<Self, HalError> {
if prefer_software {
if let Ok(hal) = Self::new_inner(None, true).await {
return Ok(hal);
}
// Sin fallback adapter disponible: seguimos con el adapter normal; la
// tolerancia perceptual del arnés absorbe la diferencia de vendor.
}
Self::new_inner(None, false).await
}
async fn new_inner(
compatible_surface: Option<&wgpu::Surface<'static>>,
force_fallback_adapter: bool,
) -> Result<Self, HalError> {
let opts = wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
power_preference: power_preference_override(),
force_fallback_adapter,
compatible_surface,
};
// Preferimos backends PRIMARY (Vulkan/Metal/DX12). El backend GL de
@@ -140,12 +299,16 @@ impl Hal {
// camino de escritorio `compatible_surface` es `None` (la surface se
// crea después contra esta misma instancia), así que cambiar de
// instancia aquí es seguro.
// Override por env (`LLIMPHI_WGPU_BACKEND`) o PRIMARY por default.
let forced = backend_override();
let primary = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
backends: forced.unwrap_or(wgpu::Backends::PRIMARY),
..Default::default()
});
let (instance, adapter) = match primary.request_adapter(&opts).await {
Ok(a) => (primary, a),
// Con override explícito NO caemos a otros backends: el operador eligió.
Err(_) if forced.is_some() => return Err(HalError::NoAdapter),
Err(_) => {
let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
let a = all
@@ -165,12 +328,13 @@ impl Hal {
label: Some("llimphi-hal-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: wgpu::MemoryHints::Performance,
memory_hints: resolver_memory_hint(&adapter),
experimental_features: wgpu::ExperimentalFeatures::default(),
trace: wgpu::Trace::Off,
})
.await
.map_err(|e| HalError::RequestDevice(e.to_string()))?;
instalar_handler_errores(&device);
Ok(Self {
instance,
adapter,
@@ -208,28 +372,32 @@ impl Hal {
) -> Result<(Self, RawSurface), HalError> {
// PRIMARY (Vulkan/Metal/DX12) primero; si no hay adaptador compatible, a
// todos los backends recreando instancia y surface.
// Override por env (`LLIMPHI_WGPU_BACKEND`) o PRIMARY por default.
let forced = backend_override();
let primary = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
backends: forced.unwrap_or(wgpu::Backends::PRIMARY),
..Default::default()
});
let prim_surface = unsafe { primary.create_surface_unsafe(make_target()) }
.map_err(|e| HalError::CreateSurface(e.to_string()))?;
let prim_adapter = primary
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
power_preference: power_preference_override(),
force_fallback_adapter: false,
compatible_surface: Some(&prim_surface),
})
.await;
let (instance, adapter, wgpu_surface) = match prim_adapter {
Ok(a) => (primary, a, prim_surface),
// Con override explícito NO caemos a otros backends: el operador eligió.
Err(_) if forced.is_some() => return Err(HalError::NoAdapter),
Err(_) => {
let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
let surface = unsafe { all.create_surface_unsafe(make_target()) }
.map_err(|e| HalError::CreateSurface(e.to_string()))?;
let a = all
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
power_preference: power_preference_override(),
force_fallback_adapter: false,
compatible_surface: Some(&surface),
})
@@ -244,19 +412,104 @@ impl Hal {
label: Some("llimphi-hal-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: wgpu::MemoryHints::Performance,
memory_hints: resolver_memory_hint(&adapter),
experimental_features: wgpu::ExperimentalFeatures::default(),
trace: wgpu::Trace::Off,
})
.await
.map_err(|e| HalError::RequestDevice(e.to_string()))?;
instalar_handler_errores(&device);
let hal = Self {
instance,
adapter,
device,
queue,
};
let surface = RawSurface::from_surface(&hal, wgpu_surface, width, height)?;
// Extraemos los raw handles del target para que la `RawSurface` pueda
// recrearse ante una pérdida (los `RawHandle` son `Copy`).
let (raw_display, raw_window) = match make_target() {
wgpu::SurfaceTargetUnsafe::RawHandle {
raw_display_handle,
raw_window_handle,
} => (raw_display_handle, raw_window_handle),
_ => {
return Err(HalError::CreateSurface(
"new_for_raw_surface requiere SurfaceTargetUnsafe::RawHandle".into(),
))
}
};
let surface =
RawSurface::from_surface(&hal, wgpu_surface, raw_display, raw_window, width, height)?;
Ok((hal, surface))
}
/// Reconstruye **todo el stack GPU** (instance+adapter+device+queue) y una
/// [`WinitSurface`] nueva sobre la MISMA ventana, eligiendo el adapter
/// compatible con la surface recreada. Es la recuperación real ante
/// [`SurfaceError::DeviceLost`] en el camino winit (apps `llimphi_ui::run`:
/// greeter/shuma/nada/cosmos…): reconfigurar o recrear sólo la surface no
/// alcanza cuando el adapter dejó de matchear (Iris Xe «queue family»); hay
/// que rehacer el device entero. Tras esto el caller debe reconstruir lo que
/// cuelga del device (renderer vello, compositores, texturas de la app)
/// contra el `Hal` devuelto.
///
/// Es **surface-first** (crea la surface antes de pedir el adapter con
/// `compatible_surface`), lo correcto para garantizar el match adapter↔surface
/// tras la pérdida. Bloquea internamente (`pollster`) porque la recuperación
/// ocurre dentro de un handler de redraw síncrono. Respeta el override de
/// backend (`LLIMPHI_WGPU_BACKEND`) igual que [`Hal::new`].
pub fn recreate_for_window(
window: Arc<Window>,
) -> Result<(Self, WinitSurface), HalError> {
let forced = backend_override();
let make_instance = |backends: wgpu::Backends| {
wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends,
..Default::default()
})
};
let request_adapter = |inst: &wgpu::Instance, surf: &wgpu::Surface<'static>| {
pollster::block_on(inst.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: power_preference_override(),
force_fallback_adapter: false,
compatible_surface: Some(surf),
}))
};
let primary = make_instance(forced.unwrap_or(wgpu::Backends::PRIMARY));
let prim_surface = primary
.create_surface(window.clone())
.map_err(|e| HalError::CreateSurface(e.to_string()))?;
let (instance, adapter, surface) = match request_adapter(&primary, &prim_surface) {
Ok(a) => (primary, a, prim_surface),
// Con override explícito NO caemos a otros backends: el operador eligió.
Err(_) if forced.is_some() => return Err(HalError::NoAdapter),
Err(_) => {
let all = make_instance(wgpu::Backends::all());
let surface = all
.create_surface(window.clone())
.map_err(|e| HalError::CreateSurface(e.to_string()))?;
let a = request_adapter(&all, &surface).map_err(|_| HalError::NoAdapter)?;
(all, a, surface)
}
};
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("llimphi-hal-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: resolver_memory_hint(&adapter),
experimental_features: wgpu::ExperimentalFeatures::default(),
trace: wgpu::Trace::Off,
}))
.map_err(|e| HalError::RequestDevice(e.to_string()))?;
instalar_handler_errores(&device);
let hal = Self {
instance,
adapter,
device,
queue,
};
let surface = WinitSurface::from_surface(&hal, window, surface)?;
Ok((hal, surface))
}
}
@@ -265,7 +518,7 @@ impl Hal {
/// intermedia `Rgba8Unorm` con storage binding (donde pinta vello) y
/// un `TextureBlitter` que la copia al swapchain al presentar.
pub struct WinitSurface {
_window: Arc<Window>,
window: Arc<Window>,
surface: wgpu::Surface<'static>,
config: wgpu::SurfaceConfiguration,
device: wgpu::Device,
@@ -275,6 +528,10 @@ pub struct WinitSurface {
overlay: wgpu::Texture,
overlay_view: wgpu::TextureView,
blitter: wgpu::util::TextureBlitter,
/// La instancia con que se creó la surface: guardada para poder RECREARLA
/// desde la ventana ante un hipo del compositor/GPU (ver
/// [`WinitSurface::recreate`]) antes de escalar a un rebuild de device.
instance: wgpu::Instance,
}
const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
@@ -334,14 +591,14 @@ impl WinitSurface {
alpha_mode: caps.alpha_modes[0],
view_formats: vec![],
};
surface.configure(&hal.device, &config);
configure_checked(&hal.device, &surface, &config).map_err(HalError::CreateSurface)?;
let (intermediate, intermediate_view) =
create_intermediate(&hal.device, config.width, config.height);
let (overlay, overlay_view) =
create_intermediate(&hal.device, config.width, config.height);
let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
Ok(Self {
_window: window,
window,
surface,
config,
device: hal.device.clone(),
@@ -350,12 +607,30 @@ impl WinitSurface {
overlay,
overlay_view,
blitter,
instance: hal.instance.clone(),
})
}
pub fn format(&self) -> wgpu::TextureFormat {
self.config.format
}
/// Recrea la `wgpu::Surface` desde la MISMA ventana winit y la reconfigura.
/// Paso intermedio de recuperación: barato frente a rehacer el device.
/// Lo llama [`WinitSurface::acquire`] cuando `get_current_texture` devuelve
/// `Outdated`/`Lost` y reconfigurar no bastó. Si tras esto la surface sigue
/// rota (el adapter dejó de matchear — Iris Xe «queue family»), `acquire`
/// escala a [`SurfaceError::DeviceLost`] y el dueño del bucle reconstruye el
/// `Hal` entero con [`Hal::recreate_for_window`].
fn recreate(&mut self) -> Result<(), String> {
let surface = self
.instance
.create_surface(self.window.clone())
.map_err(|e| format!("create_surface: {e}"))?;
configure_checked(&self.device, &surface, &self.config)?;
self.surface = surface;
Ok(())
}
}
/// Surface sobre una `wgpu::Surface` creada desde **handles raw** (sin
@@ -374,13 +649,25 @@ pub struct RawSurface {
overlay: wgpu::Texture,
overlay_view: wgpu::TextureView,
blitter: wgpu::util::TextureBlitter,
/// La instancia + los raw handles del `wl_surface`/`wl_display`, guardados para
/// **recrear** la `wgpu::Surface` cuando queda irrecuperable (ver
/// [`RawSurface::recreate`]). Los `RawHandle` son `Copy` y apuntan a objetos
/// que el caller mantiene vivos toda la vida de la `RawSurface`.
instance: wgpu::Instance,
raw_display: raw_window_handle::RawDisplayHandle,
raw_window: raw_window_handle::RawWindowHandle,
}
impl RawSurface {
/// Envuelve una `wgpu::Surface` ya creada, con el tamaño físico inicial.
/// Envuelve una `wgpu::Surface` ya creada, con el tamaño físico inicial. Los
/// `raw_display`/`raw_window` son los handles con que se creó la surface: se
/// guardan para poder RECREARLA si el compositor la invalida (Iris Xe), ya que
/// reconfigurar la misma surface no siempre recupera.
pub fn from_surface(
hal: &Hal,
surface: wgpu::Surface<'static>,
raw_display: raw_window_handle::RawDisplayHandle,
raw_window: raw_window_handle::RawWindowHandle,
width: u32,
height: u32,
) -> Result<Self, HalError> {
@@ -439,7 +726,7 @@ impl RawSurface {
alpha_mode,
view_formats: vec![],
};
surface.configure(&hal.device, &config);
configure_checked(&hal.device, &surface, &config).map_err(HalError::CreateSurface)?;
let (intermediate, intermediate_view) =
create_intermediate(&hal.device, config.width, config.height);
let (overlay, overlay_view) =
@@ -454,12 +741,36 @@ impl RawSurface {
overlay,
overlay_view,
blitter,
instance: hal.instance.clone(),
raw_display,
raw_window,
})
}
pub fn format(&self) -> wgpu::TextureFormat {
self.config.format
}
/// Recrea la `wgpu::Surface` desde el raw handle del `wl_surface` y la
/// reconfigura. Es la recuperación REAL cuando la surface quedó irrecuperable
/// —p. ej. `configure` falla con «Surface does not support the adapter's queue
/// family» tras un reset del compositor en Iris Xe—: reconfigurar la MISMA
/// surface no basta, hay que crear una nueva. Devuelve el error si falla (el
/// caller salta el frame y reintenta). Sin esto, la barra quedaba en un bucle
/// de «surface perdida» hasta que se caía la conexión (Broken pipe).
fn recreate(&mut self) -> Result<(), String> {
let target = wgpu::SurfaceTargetUnsafe::RawHandle {
raw_display_handle: self.raw_display,
raw_window_handle: self.raw_window,
};
// SAFETY: los handles apuntan a objetos Wayland que el caller mantiene
// vivos toda la vida de la `RawSurface` (mismo contrato que `from_surface`).
let surface = unsafe { self.instance.create_surface_unsafe(target) }
.map_err(|e| format!("create_surface_unsafe: {e}"))?;
configure_checked(&self.device, &surface, &self.config)?;
self.surface = surface;
Ok(())
}
}
impl Surface for RawSurface {
@@ -480,7 +791,16 @@ impl Surface for RawSurface {
}
self.config.width = w;
self.config.height = h;
self.surface.configure(&self.device, &self.config);
if let Err(e) = configure_checked(&self.device, &self.surface, &self.config) {
// Igual que en `acquire`: si reconfigurar no recupera, recreamos la
// surface desde el raw handle antes de rendirnos.
match self.recreate() {
Ok(()) => eprintln!("llimphi-hal: surface RECREADA en resize (reconfigure falló: {e})"),
Err(e2) => {
eprintln!("llimphi-hal: configure en resize falló ({e}); recrear también falló ({e2})")
}
}
}
let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height);
self.intermediate = tex;
self.intermediate_view = view;
@@ -491,6 +811,11 @@ impl Surface for RawSurface {
}
fn acquire(&mut self) -> Result<Frame, SurfaceError> {
// Gancho de verificación: DeviceLost sintético cada N cuadros
// (`LLIMPHI_SIMULATE_DEVICE_LOSS`). No-op sin la env.
if simulate_device_loss_trip() {
return Err(SurfaceError::DeviceLost);
}
let texture = match self.surface.get_current_texture() {
Ok(t) => t,
// El backend layer-shell no tiene un evento de resize que reconfigure
@@ -498,11 +823,32 @@ impl Surface for RawSurface {
// y reintentamos una vez. Sin esto el panel quedaría en negro para
// siempre tras el primer `Outdated`.
Err(e @ (wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost)) => {
self.surface.configure(&self.device, &self.config);
self.surface.get_current_texture().map_err(|_| match e {
wgpu::SurfaceError::Lost => SurfaceError::Lost,
_ => SurfaceError::Outdated,
})?
// Reconfigurar CAPTURANDO: si la surface está realmente perdida,
// `configure` emite un error de validación que, sin captura,
// mataría el proceso (era el crash de pata). Aquí lo devolvemos
// como `DeviceLost` y el caller reconstruye el stack GPU.
if let Err(msg) = configure_checked(&self.device, &self.surface, &self.config) {
// Reconfigurar no recuperó (surface realmente perdida): RECREAR
// desde el raw handle contra el MISMO device. Si eso también
// falla, el device quedó irrecuperable (el adapter dejó de
// matchear — Iris Xe «queue family») → DeviceLost: el runner
// de pata rehace `Hal` + todas las surfaces/renderers.
match self.recreate() {
Ok(()) => eprintln!(
"llimphi-hal: surface RECREADA tras {e:?} (reconfigure falló: {msg})"
),
Err(msg2) => {
eprintln!(
"llimphi-hal: reconfigurar tras {e:?} falló ({msg}); recrear también falló ({msg2}) → DeviceLost"
);
return Err(SurfaceError::DeviceLost);
}
}
}
// Sigue rota tras reconfigurar+recrear → device irrecuperable.
self.surface
.get_current_texture()
.map_err(|_| SurfaceError::DeviceLost)?
}
Err(wgpu::SurfaceError::OutOfMemory) => return Err(SurfaceError::OutOfMemory),
Err(wgpu::SurfaceError::Timeout) => return Err(SurfaceError::Timeout),
@@ -574,6 +920,41 @@ fn choose_present_mode(caps: &wgpu::SurfaceCapabilities) -> wgpu::PresentMode {
}
}
/// Instala en el device un handler de errores wgpu **no capturados** que LOGUEA
/// en vez de dejar que el default de wgpu («Handling wgpu errors as fatal by
/// default») paniquee el proceso entero. Un glitch de device —p. ej. la pérdida
/// transitoria en un flap de VT bajo arje— NO debe tumbar la app (greeter, pata…):
/// la recuperación de surface (`Outdated`/`Lost`) rearma en el próximo frame. Los
/// errores de validación puntuales se siguen capturando con error-scopes aparte.
fn instalar_handler_errores(device: &wgpu::Device) {
device.on_uncaptured_error(std::sync::Arc::new(|e| {
eprintln!("llimphi-hal: error wgpu no capturado (no fatal, ignorado): {e}");
}));
}
/// Configura el swapchain **capturando** los errores de validación de wgpu, en
/// vez de dejar que el handler por defecto («wgpu errors as fatal») paniquee el
/// proceso entero. Devuelve `Err(mensaje)` si la configuración falló — casi
/// siempre porque la surface se perdió (`SURFACE_LOST`, un hipo transitorio del
/// compositor/GPU): el caller decide (saltar el frame, marcar perdida) en vez de
/// morir. Robustez: una app no debe caerse por un glitch del surface (lo que
/// tumbaba a `pata` con «Surface does not support the adapter's queue family»).
///
/// Los errores de validación de wgpu son CPU-side: el scope los captura durante
/// la llamada, así que `pop_error_scope` resuelve sin bloquear contra la GPU.
fn configure_checked(
device: &wgpu::Device,
surface: &wgpu::Surface<'static>,
config: &wgpu::SurfaceConfiguration,
) -> Result<(), String> {
device.push_error_scope(wgpu::ErrorFilter::Validation);
surface.configure(device, config);
match pollster::block_on(device.pop_error_scope()) {
Some(e) => Err(e.to_string()),
None => Ok(()),
}
}
fn create_intermediate(
device: &wgpu::Device,
width: u32,
@@ -593,7 +974,7 @@ fn create_intermediate(
// STORAGE_BINDING: vello escribe via compute shader.
// TEXTURE_BINDING: el blitter la lee como sampler source.
// RENDER_ATTACHMENT: render passes con clear-only (sin vello)
// también escriben a — desktop drivers lo tolerían sin este
// también escriben aquí — desktop drivers lo tolerían sin este
// flag, Adreno con validación estricta rechaza el frame.
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
@@ -1525,7 +1906,9 @@ impl Surface for WinitSurface {
fn resize(&mut self, width: u32, height: u32) {
self.config.width = width.max(1);
self.config.height = height.max(1);
self.surface.configure(&self.device, &self.config);
if let Err(e) = configure_checked(&self.device, &self.surface, &self.config) {
eprintln!("llimphi-hal: configure en resize falló (surface perdida?): {e}");
}
let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height);
self.intermediate = tex;
self.intermediate_view = view;
@@ -1536,13 +1919,47 @@ impl Surface for WinitSurface {
}
fn acquire(&mut self) -> Result<Frame, SurfaceError> {
let texture = self.surface.get_current_texture().map_err(|e| match e {
wgpu::SurfaceError::Lost => SurfaceError::Lost,
wgpu::SurfaceError::Outdated => SurfaceError::Outdated,
wgpu::SurfaceError::OutOfMemory => SurfaceError::OutOfMemory,
wgpu::SurfaceError::Timeout => SurfaceError::Timeout,
other => SurfaceError::Other(format!("{other:?}")),
})?;
// Gancho de verificación: fuerza un DeviceLost sintético cada N cuadros
// (env `LLIMPHI_SIMULATE_DEVICE_LOSS`) para ejercitar el rebuild sin un
// reset de output real. No-op sin la env.
if simulate_device_loss_trip() {
return Err(SurfaceError::DeviceLost);
}
let texture = match self.surface.get_current_texture() {
Ok(t) => t,
// El camino winit tampoco tenía recuperación: un `Outdated`/`Lost`
// (hipo del compositor, cambio de VT) se propagaba y la app saltaba
// el frame para siempre en el peor caso. Reconfiguramos; si no basta,
// recreamos la surface desde la ventana; si ESO falla, el device
// quedó irrecuperable → DeviceLost (rebuild del stack GPU completo).
Err(e @ (wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost)) => {
if let Err(msg) = configure_checked(&self.device, &self.surface, &self.config) {
match self.recreate() {
Ok(()) => eprintln!(
"llimphi-hal: WinitSurface RECREADA tras {e:?} (reconfigure falló: {msg})"
),
Err(msg2) => {
eprintln!(
"llimphi-hal: reconfigurar tras {e:?} falló ({msg}); recrear también falló ({msg2}) → DeviceLost"
);
return Err(SurfaceError::DeviceLost);
}
}
}
self.surface.get_current_texture().map_err(|e2| match e2 {
// Sigue rota tras reconfigurar+recrear → device irrecuperable.
wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated => {
SurfaceError::DeviceLost
}
wgpu::SurfaceError::OutOfMemory => SurfaceError::OutOfMemory,
wgpu::SurfaceError::Timeout => SurfaceError::Timeout,
other => SurfaceError::Other(format!("{other:?}")),
})?
}
Err(wgpu::SurfaceError::OutOfMemory) => return Err(SurfaceError::OutOfMemory),
Err(wgpu::SurfaceError::Timeout) => return Err(SurfaceError::Timeout),
Err(other) => return Err(SurfaceError::Other(format!("{other:?}"))),
};
let surface_view = texture
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
+1 -1
View File
@@ -5,8 +5,8 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "llimphi-icons — set mínimo de iconos vectoriales (BezPath en grid 24×24) renderizables vía paint_with. Stroke-based, escalables. Cubre las acciones canónicas de cualquier UI tawasuyu."
repository.workspace = true
[dependencies]
llimphi-ui = { workspace = true }
+54
View File
@@ -0,0 +1,54 @@
# llimphi-icons
*Read this in English: [README.md](README.md).*
Set canónico de iconos vectoriales para apps tawasuyu.
Cada icono es una función pura que devuelve un `BezPath` definido en
un grid lógico de **24×24 unidades**. El renderer escala al rect que
reciba, así un mismo icono sirve para 12px (en una fila de lista) y
para 64px (en una hero card) sin pérdida de nitidez — es vector,
no bitmap.
## Diseño
- **Stroke-based, no fill**: los iconos son trazos de ancho 2 (en
unidades del grid) con joins/caps suaves. El stroke se renderiza
con el color que la app elija (típicamente `theme.fg_text` o
`theme.accent`).
- **Geometría minimal, no marca**: glifos genéricos universales,
no "marca registrada". Cada uno debe ser reconocible al primer
vistazo aún en 12×12.
- **Set acotado**: suficientes para cubrir el grueso de acciones y
tipos que aparecen en cualquier UI tawasuyu. Si una app necesita uno
más, lo agrega aquí (no en su propio crate) — la consistencia
visual importa más que el aislamiento.
## Catálogo
| Categoría | Iconos |
|--------------|-----------------------------------------------------|
| Documento | `file`, `folder`, `folder_open`, `save`, `open` |
| Edición | `plus`, `minus`, `x`, `check`, `edit`, `trash` |
| Navegación | `chevron_up`, `chevron_down`, `chevron_left`, `chevron_right`, `home`, `search` |
| Estado | `info`, `warning`, `error`, `bell` |
| Sistema | `settings`, `more` |
| Multimedia | `play`, `pause`, `stop`, `skip_*`, `volume*`, `repeat`, `shuffle`, `record`, `equalizer`, `camera`, `gauge` |
| Archivos | `image`, `music`, `film`, `archive`, `code`, `file_text`, `link`, `font` |
## Uso
```ignore
use llimphi_icons::{Icon, icon_view};
// Botón con icono "save":
let btn = View::new(style)
.fill(palette.bg_button)
.children(vec![icon_view(Icon::Save, palette.fg_text, 1.6)]);
```
El parámetro `stroke_width` (3er arg de `icon_view`) está en unidades
del grid (24×24). `1.6` es el default armonioso; `2.0` para énfasis;
`1.2` para iconos en tipografías pequeñas.
---
Parte de **llimphi** — ver [llimphi](../LEEME.md).
+50
View File
@@ -0,0 +1,50 @@
# llimphi-icons
The canonical set of vector icons for tawasuyu apps.
Every icon is a pure function returning a `BezPath` defined on a logical grid of
**24×24 units**. The renderer scales it to whatever rect it gets, so the same
icon serves at 12px (in a list row) and at 64px (in a hero card) with no loss of
sharpness — it is vector, not bitmap.
## Design
- **Stroke-based, not filled**: icons are strokes of width 2 (in grid units) with
soft joins and caps. The stroke is rendered in whatever colour the app chooses
(typically `theme.fg_text` or `theme.accent`).
- **Minimal geometry, not a logo**: universal generic glyphs, not "trademarks".
Each one must be recognizable at a glance even at 12×12.
- **A bounded set**: enough to cover the bulk of actions and types appearing in
any tawasuyu UI. If an app needs one more, it adds it here (not in its own
crate) — visual consistency matters more than isolation.
## Catalog
| Category | Icons |
|------------|-----------------------------------------------------|
| Document | `file`, `folder`, `folder_open`, `save`, `open` |
| Editing | `plus`, `minus`, `x`, `check`, `edit`, `trash` |
| Navigation | `chevron_up`, `chevron_down`, `chevron_left`, `chevron_right`, `home`, `search` |
| Status | `info`, `warning`, `error`, `bell` |
| System | `settings`, `more` |
| Multimedia | `play`, `pause`, `stop`, `skip_*`, `volume*`, `repeat`, `shuffle`, `record`, `equalizer`, `camera`, `gauge` |
| Files | `image`, `music`, `film`, `archive`, `code`, `file_text`, `link`, `font` |
## Use
```ignore
use llimphi_icons::{Icon, icon_view};
// A button with the "save" icon:
let btn = View::new(style)
.fill(palette.bg_button)
.children(vec![icon_view(Icon::Save, palette.fg_text, 1.6)]);
```
The `stroke_width` parameter (3rd argument of `icon_view`) is in grid units
(24×24). `1.6` is the harmonious default; `2.0` for emphasis; `1.2` for icons
next to small type.
---
Part of **llimphi** — see [llimphi](../README.md).
+91 -9
View File
@@ -1,7 +1,7 @@
//! `app_icons` — iconos de marca, uno por dominio/app de tawasuyu.
//!
//! A diferencia del set canónico de [`crate::Icon`] (glifos genéricos de
//! acción: file, save, search…), a vive **un glifo distintivo por app**.
//! acción: file, save, search…), aquí vive **un glifo distintivo por app**.
//! Cada app tiene su símbolo y su **color de marca** propios, pero todos
//! comparten el mismo lenguaje visual:
//!
@@ -49,7 +49,7 @@ pub enum AppIcon {
Nakui,
Tinkuy,
// --- 02_ruway · HACER ---
Ayni,
Suilad,
Cards,
Chasqui,
Llimphi,
@@ -62,6 +62,8 @@ pub enum AppIcon {
Takiy,
Tullpu,
Wawa,
Hapiy,
Raymi,
// --- 03_ukupacha · RAÍZ ---
Agora,
Arje,
@@ -71,7 +73,7 @@ pub enum AppIcon {
}
/// Las 29 apps, en orden de cuadrante. Útil para iterar (galerías, tests).
pub const ALL: [AppIcon; 29] = [
pub const ALL: [AppIcon; 31] = [
AppIcon::Chaka,
AppIcon::Khipu,
AppIcon::Pineal,
@@ -83,7 +85,7 @@ pub const ALL: [AppIcon; 29] = [
AppIcon::Iniy,
AppIcon::Nakui,
AppIcon::Tinkuy,
AppIcon::Ayni,
AppIcon::Suilad,
AppIcon::Cards,
AppIcon::Chasqui,
AppIcon::Llimphi,
@@ -96,6 +98,8 @@ pub const ALL: [AppIcon; 29] = [
AppIcon::Takiy,
AppIcon::Tullpu,
AppIcon::Wawa,
AppIcon::Hapiy,
AppIcon::Raymi,
AppIcon::Agora,
AppIcon::Arje,
AppIcon::Minga,
@@ -118,7 +122,7 @@ impl AppIcon {
AppIcon::Iniy => "iniy",
AppIcon::Nakui => "nakui",
AppIcon::Tinkuy => "tinkuy",
AppIcon::Ayni => "ayni",
AppIcon::Suilad => "suilad",
AppIcon::Cards => "cards",
AppIcon::Chasqui => "chasqui",
AppIcon::Llimphi => "llimphi",
@@ -131,6 +135,8 @@ impl AppIcon {
AppIcon::Takiy => "takiy",
AppIcon::Tullpu => "tullpu",
AppIcon::Wawa => "wawa",
AppIcon::Hapiy => "hapiy",
AppIcon::Raymi => "raymi",
AppIcon::Agora => "agora",
AppIcon::Arje => "arje",
AppIcon::Minga => "minga",
@@ -162,7 +168,7 @@ impl AppIcon {
AppIcon::Iniy => (124, 179, 66),
AppIcon::Nakui => (194, 84, 157),
AppIcon::Tinkuy => (217, 83, 79),
AppIcon::Ayni => (42, 168, 196),
AppIcon::Suilad => (42, 168, 196),
AppIcon::Cards => (142, 99, 206),
AppIcon::Chasqui => (52, 179, 106),
AppIcon::Llimphi => (229, 91, 122),
@@ -175,6 +181,8 @@ impl AppIcon {
AppIcon::Takiy => (229, 99, 155),
AppIcon::Tullpu => (224, 96, 58),
AppIcon::Wawa => (91, 141, 239),
AppIcon::Hapiy => (38, 184, 156),
AppIcon::Raymi => (216, 88, 120),
AppIcon::Agora => (47, 158, 143),
AppIcon::Arje => (176, 141, 87),
AppIcon::Minga => (224, 123, 57),
@@ -198,7 +206,7 @@ impl AppIcon {
AppIcon::Iniy => path_iniy(),
AppIcon::Nakui => path_nakui(),
AppIcon::Tinkuy => path_tinkuy(),
AppIcon::Ayni => path_ayni(),
AppIcon::Suilad => path_suilad(),
AppIcon::Cards => path_cards(),
AppIcon::Chasqui => path_chasqui(),
AppIcon::Llimphi => path_llimphi(),
@@ -211,6 +219,8 @@ impl AppIcon {
AppIcon::Takiy => path_takiy(),
AppIcon::Tullpu => path_tullpu(),
AppIcon::Wawa => path_wawa(),
AppIcon::Hapiy => path_hapiy(),
AppIcon::Raymi => path_raymi(),
AppIcon::Agora => path_agora(),
AppIcon::Arje => path_arje(),
AppIcon::Minga => path_minga(),
@@ -248,6 +258,31 @@ pub fn app_icon_view_colored<Msg: Clone + 'static>(
})
}
/// Exporta el icono de app como **SVG** (string), en su color de marca.
/// Mismo dibujo que [`app_icon_view`] pero en un formato de archivo: sirve para
/// 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).
pub fn app_icon_svg(icon: AppIcon, stroke_width: f32) -> String {
let d = icon.path().to_svg();
let [r, g, b, _] = icon.brand().components;
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let hex = format!("#{:02x}{:02x}{:02x}", q(r), q(g), q(b));
format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" \
fill=\"none\" stroke=\"{hex}\" stroke-width=\"{sw}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\
<path d=\"{d}\"/></svg>\n",
sw = fmt_num(stroke_width),
)
}
/// Formatea un f32 sin ceros de cola (`1.8` no `1.8000`).
fn fmt_num(v: f32) -> String {
let s = format!("{v:.3}");
let s = s.trim_end_matches('0').trim_end_matches('.');
s.to_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(
@@ -506,8 +541,8 @@ fn path_tinkuy() -> BezPath {
// --- 02_ruway · HACER ---
fn path_ayni() -> BezPath {
// ayni = reciprocidad: dos flechas curvas en ciclo.
fn path_suilad() -> BezPath {
// suilad = saludo mutuo (élfico); ciclo de reciprocidad: dos flechas curvas en ciclo.
let mut p = BezPath::new();
// Arco superior, flecha hacia la derecha-abajo.
p.move_to((6.0, 8.0));
@@ -774,6 +809,42 @@ fn path_sandokan() -> BezPath {
p
}
fn path_hapiy() -> BezPath {
// hapiy = asir/capturar: marco de cuatro esquinas (encuadre de captura).
let mut p = BezPath::new();
p.move_to((4.0, 8.0));
p.line_to((4.0, 4.0));
p.line_to((8.0, 4.0)); // sup-izq
p.move_to((16.0, 4.0));
p.line_to((20.0, 4.0));
p.line_to((20.0, 8.0)); // sup-der
p.move_to((20.0, 16.0));
p.line_to((20.0, 20.0));
p.line_to((16.0, 20.0)); // inf-der
p.move_to((8.0, 20.0));
p.line_to((4.0, 20.0));
p.line_to((4.0, 16.0)); // inf-izq
p
}
fn path_raymi() -> BezPath {
// raymi = fiesta/calendario: hoja con anillas, cabecera y día marcado.
let mut p = BezPath::new();
p.move_to((4.0, 6.0));
p.line_to((20.0, 6.0));
p.line_to((20.0, 20.0));
p.line_to((4.0, 20.0));
p.close_path();
p.move_to((4.0, 10.0));
p.line_to((20.0, 10.0)); // cabecera
p.move_to((8.0, 4.0));
p.line_to((8.0, 7.5)); // anilla izq
p.move_to((16.0, 4.0));
p.line_to((16.0, 7.5)); // anilla der
push_all(&mut p, circle(12.0, 15.0, 1.8, 12)); // día marcado
p
}
fn path_wawa_explorer() -> BezPath {
// wawa-explorer = launchpad: grilla 2×2.
let mut p = BezPath::new();
@@ -803,6 +874,17 @@ mod tests {
}
}
#[test]
fn svg_export_es_valido() {
for icon in ALL {
let svg = app_icon_svg(icon, 1.8);
assert!(svg.starts_with("<svg"), "{} svg sin cabecera", icon.name());
assert!(svg.contains("<path d=\"M"), "{} svg sin path", icon.name());
assert!(svg.contains("stroke=\"#"), "{} svg sin color de marca", icon.name());
assert!(svg.trim_end().ends_with("</svg>"), "{} svg sin cierre", icon.name());
}
}
#[test]
fn app_names_are_unique() {
let mut names: Vec<&str> = ALL.iter().map(|i| i.name()).collect();
+480
View File
@@ -124,6 +124,48 @@ pub enum Icon {
Table,
/// Dos paneles verticales lado a lado (modo dual).
Columns,
// --- Dominio: creador de mundos (llimphi-voxel-studio) ---
/// Globo terráqueo (Mundos).
Globe,
/// Cadena montañosa (Biomas).
Mountain,
/// Hoja con nervadura (Materiales).
Leaf,
/// Silueta de persona (Seres).
User,
/// Gota de agua (Leyes / físicas).
Droplet,
// --- Sistema / settings panels (sweep glifo→vector 2026-06-30) ---
/// Reloj con manecillas (hora, contextos, tiempo).
Clock,
/// Símbolo de encendido/apagado.
Power,
/// Sobre de correo.
Mail,
/// Teclado (atajos).
Keyboard,
/// Paleta de pintor (themes/apariencia).
Palette,
/// Candado (seguridad/privacidad).
Lock,
/// Llave.
Key,
/// Monitor/pantalla (sistema, acerca del equipo).
Monitor,
/// Destello de cuatro puntas (animaciones, efectos).
Sparkle,
/// Micrófono (voz, audio in).
Mic,
/// Ratón.
Mouse,
/// Nube (red/online).
Cloud,
/// Luna (modo oscuro, noche).
Moon,
/// Flecha circular (recargar/refrescar).
Refresh,
/// Pieza de rompecabezas (plugins/módulos).
Puzzle,
}
impl Icon {
@@ -182,6 +224,26 @@ impl Icon {
Icon::Rows => "rows",
Icon::Table => "table",
Icon::Columns => "columns",
Icon::Globe => "globe",
Icon::Mountain => "mountain",
Icon::Leaf => "leaf",
Icon::User => "user",
Icon::Droplet => "droplet",
Icon::Clock => "clock",
Icon::Power => "power",
Icon::Mail => "mail",
Icon::Keyboard => "keyboard",
Icon::Palette => "palette",
Icon::Lock => "lock",
Icon::Key => "key",
Icon::Monitor => "monitor",
Icon::Sparkle => "sparkle",
Icon::Mic => "mic",
Icon::Mouse => "mouse",
Icon::Cloud => "cloud",
Icon::Moon => "moon",
Icon::Refresh => "refresh",
Icon::Puzzle => "puzzle",
}
}
@@ -240,8 +302,122 @@ impl Icon {
Icon::Rows => path_rows(),
Icon::Table => path_table(),
Icon::Columns => path_columns(),
Icon::Globe => path_globe(),
Icon::Mountain => path_mountain(),
Icon::Leaf => path_leaf(),
Icon::User => path_user(),
Icon::Droplet => path_droplet(),
Icon::Clock => path_clock(),
Icon::Power => path_power(),
Icon::Mail => path_mail(),
Icon::Keyboard => path_keyboard(),
Icon::Palette => path_palette(),
Icon::Lock => path_lock(),
Icon::Key => path_key(),
Icon::Monitor => path_monitor(),
Icon::Sparkle => path_sparkle(),
Icon::Mic => path_mic(),
Icon::Mouse => path_mouse(),
Icon::Cloud => path_cloud(),
Icon::Moon => path_moon(),
Icon::Refresh => path_refresh(),
Icon::Puzzle => path_puzzle(),
}
}
/// Mapea un **glifo unicode** (el que muchas apps usaban como ícono de
/// texto) al `Icon` vectorial equivalente. Es el corazón del barrido
/// glifo→vector: un sitio de render hace `from_glyph(s)` y, si hay match,
/// pinta el vector (determinista en toda máquina) en vez del glifo de fuente
/// (que en hardware sin esa fuente sale notdef/tofu). `None` ⇒ el caller cae
/// a texto. Acepta el glifo con o sin variation-selector (`\u{FE0F}`).
pub fn from_glyph(glifo: &str) -> Option<Icon> {
let g = glifo.trim().trim_end_matches('\u{FE0F}');
let mut chars = g.chars();
let c = chars.next()?;
if chars.next().is_some() {
// Más de un code-point (sin el VS16): no es un glifo-ícono simple.
return None;
}
Some(match c {
// Multimedia / transporte
'▶' | '►' | '⏵' => Icon::Play,
'⏸' => Icon::Pause,
'⏹' | '■' => Icon::Stop,
'⏺' | '●' | '◉' | '⚫' => Icon::Record,
'⏮' | '⏪' => Icon::SkipBack,
'⏭' | '⏩' => Icon::SkipForward,
'🔀' => Icon::Shuffle,
'🔁' | '🔂' => Icon::Repeat,
'🔊' | '🔉' | '🔈' => Icon::Volume,
'🔇' => Icon::VolumeMute,
'♪' | '♫' | '🎵' | '🎶' => Icon::Music,
'🎙' | '🎤' => Icon::Mic,
'🎛' | '🎚' => Icon::Equalizer,
'📷' | '📸' => Icon::Camera,
'🎞' | '🎬' | '📽' => Icon::Film,
'🖼' => Icon::Image,
// Navegación / chevrons / flechas
'▲' | '△' => Icon::ChevronUp,
'▼' | '▽' => Icon::ChevronDown,
'◀' | '◁' | '' => Icon::ChevronLeft,
'▷' | '' | '' | '❭' | '⟩' => Icon::ChevronRight,
'⟳' | '🔃' | '🔄' | '↻' | '⥁' => Icon::Refresh,
// Sistema / settings
'⚙' | '🛠' | '🔧' => Icon::Settings,
'☰' | '≡' | '≣' | '𝍢' => Icon::Rows,
'▦' | '▤' | '⊞' | '𐩕' => Icon::Grid,
'⏻' | '⏼' | '⭘' => Icon::Power,
'🎨' => Icon::Palette,
'⌨' => Icon::Keyboard,
'🖥' | '💻' | '🖳' => Icon::Monitor,
'🖱' => Icon::Mouse,
'🔐' | '🔒' | '🔏' => Icon::Lock,
'🔑' | '🗝' => Icon::Key,
'✉' | '📧' | '📨' | '📩' => Icon::Mail,
'🧩' => Icon::Puzzle,
'☁' => Icon::Cloud,
'🌐' | '🌍' | '🌎' | '🌏' => Icon::Globe,
'🌙' | '🌚' | '☾' | '◐' => Icon::Moon,
'✨' | '✦' | '✶' | '✷' | '❇' | '❋' | '★' | '☆' | '✩' => Icon::Sparkle,
'◴' | '◷' | '◵' | '◶' | '🕐' | '🕒' | '⏰' | '⌚' => Icon::Clock,
// Documento / contenido
'📄' | '📃' | '📝' => Icon::FileText,
'📁' | '📂' => Icon::Folder,
'💾' => Icon::Save,
'🔍' | '🔎' => Icon::Search,
'🔔' => Icon::Bell,
'🗑' => Icon::Trash,
'👤' | '🧑' | '🙍' => Icon::User,
'' | 'ⓘ' => Icon::Info,
'⚠' => Icon::Warning,
'' | '+' => Icon::Plus,
'✕' | '✖' | '×' | '✗' => Icon::X,
'✓' | '✔' => Icon::Check,
_ => return None,
})
}
}
/// Vista de un glifo como **ícono vectorial** si [`Icon::from_glyph`] lo conoce,
/// o como texto (su fallback histórico) si no. Es el reemplazo directo de
/// `.text_aligned(glifo, …)` en los sitios de render que usaban íconos-glifo:
/// el resultado es determinista en toda máquina cuando hay match.
pub fn glyph_or_text_view<Msg: Clone + 'static>(
glifo: &str,
size: f32,
color: Color,
stroke_width: f32,
) -> View<Msg> {
match Icon::from_glyph(glifo) {
Some(icon) => icon_view(icon, color, stroke_width),
None => View::new(Style {
position: Position::Absolute,
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),
}
}
/// Construye un `View` que pinta el icono ocupando todo el rect del
@@ -635,6 +811,72 @@ fn path_circle(cx: f64, cy: f64, r: f64, segments: usize) -> BezPath {
p
}
// ---------------------------------------------------------------------
// Dominio — creador de mundos (llimphi-voxel-studio)
// ---------------------------------------------------------------------
fn path_globe() -> BezPath {
// Esfera + un meridiano (elipse angosta) + dos paralelos.
let mut p = path_circle(12.0, 12.0, 9.0, 32);
p.move_to((12.0, 3.0));
p.curve_to((6.5, 7.0), (6.5, 17.0), (12.0, 21.0));
p.move_to((12.0, 3.0));
p.curve_to((17.5, 7.0), (17.5, 17.0), (12.0, 21.0));
p.move_to((3.6, 9.3));
p.line_to((20.4, 9.3));
p.move_to((3.6, 14.7));
p.line_to((20.4, 14.7));
p
}
fn path_mountain() -> BezPath {
// Cordillera de dos picos + nieve en el mayor.
let mut p = BezPath::new();
p.move_to((2.0, 20.0));
p.line_to((8.5, 8.0));
p.line_to((13.0, 15.0));
p.line_to((16.0, 11.0));
p.line_to((22.0, 20.0));
p.close_path();
// Nieve del pico mayor.
p.move_to((6.6, 11.5));
p.line_to((8.5, 8.0));
p.line_to((10.3, 11.0));
p
}
fn path_leaf() -> BezPath {
// Hoja: contorno por dos curvas + nervadura central.
let mut p = BezPath::new();
p.move_to((5.0, 19.0));
p.curve_to((5.0, 9.0), (12.0, 4.0), (19.0, 5.0));
p.curve_to((20.0, 12.0), (15.0, 19.0), (5.0, 19.0));
p.close_path();
p.move_to((7.5, 16.5));
p.line_to((16.5, 7.5));
p
}
fn path_user() -> BezPath {
// Persona: cabeza (círculo) + hombros (arco).
let mut p = path_circle(12.0, 8.0, 4.0, 24);
p.move_to((4.5, 20.0));
p.curve_to((4.5, 14.5), (19.5, 14.5), (19.5, 20.0));
p
}
fn path_droplet() -> BezPath {
// Gota: punta arriba, panza redonda abajo.
let mut p = BezPath::new();
p.move_to((12.0, 3.0));
p.curve_to((12.0, 3.0), (18.5, 11.0), (18.5, 15.5));
p.curve_to((18.5, 19.5), (15.5, 21.5), (12.0, 21.5));
p.curve_to((8.5, 21.5), (5.5, 19.5), (5.5, 15.5));
p.curve_to((5.5, 11.0), (12.0, 3.0), (12.0, 3.0));
p.close_path();
p
}
// ---------------------------------------------------------------------
// Multimedia — transporte de reproductor (media-app y demás)
// ---------------------------------------------------------------------
@@ -1025,6 +1267,214 @@ fn path_columns() -> BezPath {
p
}
// ---- Sweep glifo→vector: íconos de sistema/settings -----------------------
fn path_clock() -> BezPath {
let mut p = path_circle(12.0, 12.0, 8.5, 28);
p.move_to((12.0, 12.0));
p.line_to((12.0, 6.5)); // manecilla de hora
p.move_to((12.0, 12.0));
p.line_to((16.0, 13.5)); // minutero
p
}
fn path_power() -> BezPath {
use std::f64::consts::{FRAC_PI_2, TAU};
let mut p = BezPath::new();
let (cx, cy, r) = (12.0, 13.0, 7.0);
let segs = 28;
// Anillo con un hueco arriba (clásico símbolo de encendido).
let start = -FRAC_PI_2 + 0.55;
let end = -FRAC_PI_2 - 0.55 + TAU;
for i in 0..=segs {
let t = start + (end - start) * (i as f64) / (segs as f64);
let (x, y) = (cx + r * t.cos(), cy + r * t.sin());
if i == 0 {
p.move_to((x, y));
} else {
p.line_to((x, y));
}
}
p.move_to((12.0, 4.0));
p.line_to((12.0, 12.0)); // barra vertical
p
}
fn path_mail() -> BezPath {
let mut p = BezPath::new();
p.move_to((3.5, 6.0));
p.line_to((20.5, 6.0));
p.line_to((20.5, 18.0));
p.line_to((3.5, 18.0));
p.close_path();
p.move_to((3.5, 6.5));
p.line_to((12.0, 13.0));
p.line_to((20.5, 6.5)); // solapa
p
}
fn path_keyboard() -> BezPath {
let mut p = BezPath::new();
p.move_to((2.5, 7.0));
p.line_to((21.5, 7.0));
p.line_to((21.5, 17.0));
p.line_to((2.5, 17.0));
p.close_path();
for (x, y) in [(5.5, 10.0), (9.0, 10.0), (12.5, 10.0), (16.0, 10.0)] {
p.move_to((x, y));
p.line_to((x + 1.6, y));
}
p.move_to((8.0, 13.8));
p.line_to((16.0, 13.8)); // barra espaciadora
p
}
fn path_palette() -> BezPath {
let mut p = path_circle(12.0, 12.0, 8.5, 28);
let hueco = path_circle(12.0, 16.5, 1.8, 12); // agujero del pulgar
for el in hueco.elements() {
p.push(*el);
}
for (cx, cy) in [(8.3, 9.0), (12.0, 7.4), (15.7, 9.4)] {
let muestra = path_circle(cx, cy, 1.0, 10);
for el in muestra.elements() {
p.push(*el);
}
}
p
}
fn path_lock() -> BezPath {
let mut p = BezPath::new();
p.move_to((6.0, 11.0));
p.line_to((18.0, 11.0));
p.line_to((18.0, 20.0));
p.line_to((6.0, 20.0));
p.close_path();
p.move_to((8.5, 11.0));
p.line_to((8.5, 8.0));
p.curve_to((8.5, 5.0), (15.5, 5.0), (15.5, 8.0));
p.line_to((15.5, 11.0)); // arco
p
}
fn path_key() -> BezPath {
let mut p = path_circle(8.0, 8.0, 3.5, 18); // ojo
p.move_to((10.5, 10.5));
p.line_to((19.0, 19.0)); // caña
p.move_to((16.5, 16.5));
p.line_to((19.0, 14.0)); // diente
p
}
fn path_monitor() -> BezPath {
let mut p = BezPath::new();
p.move_to((3.0, 5.0));
p.line_to((21.0, 5.0));
p.line_to((21.0, 16.0));
p.line_to((3.0, 16.0));
p.close_path();
p.move_to((9.5, 19.0));
p.line_to((14.5, 19.0)); // base
p.move_to((12.0, 16.0));
p.line_to((12.0, 19.0)); // cuello
p
}
fn path_sparkle() -> BezPath {
let mut p = BezPath::new();
p.move_to((12.0, 3.0));
p.line_to((13.7, 10.3));
p.line_to((21.0, 12.0));
p.line_to((13.7, 13.7));
p.line_to((12.0, 21.0));
p.line_to((10.3, 13.7));
p.line_to((3.0, 12.0));
p.line_to((10.3, 10.3));
p.close_path();
p
}
fn path_mic() -> BezPath {
let mut p = BezPath::new();
p.move_to((9.0, 6.0));
p.curve_to((9.0, 3.5), (15.0, 3.5), (15.0, 6.0));
p.line_to((15.0, 11.0));
p.curve_to((15.0, 13.5), (9.0, 13.5), (9.0, 11.0));
p.close_path();
p.move_to((6.5, 11.0));
p.curve_to((6.5, 16.0), (17.5, 16.0), (17.5, 11.0)); // soporte
p.move_to((12.0, 16.0));
p.line_to((12.0, 20.0));
p.move_to((8.5, 20.0));
p.line_to((15.5, 20.0));
p
}
fn path_mouse() -> BezPath {
let mut p = BezPath::new();
p.move_to((7.0, 8.0));
p.curve_to((7.0, 4.0), (17.0, 4.0), (17.0, 8.0));
p.line_to((17.0, 16.0));
p.curve_to((17.0, 20.0), (7.0, 20.0), (7.0, 16.0));
p.close_path();
p.move_to((12.0, 5.0));
p.line_to((12.0, 9.0)); // rueda
p
}
fn path_cloud() -> BezPath {
let mut p = BezPath::new();
p.move_to((7.0, 17.0));
p.curve_to((3.5, 17.0), (3.5, 12.0), (7.0, 12.0));
p.curve_to((7.0, 7.5), (13.5, 7.0), (14.5, 11.0));
p.curve_to((18.5, 10.5), (20.5, 16.0), (17.0, 17.0));
p.close_path();
p
}
fn path_moon() -> BezPath {
let mut p = BezPath::new();
p.move_to((15.0, 4.5));
p.curve_to((9.0, 6.0), (9.0, 18.0), (15.0, 19.5));
p.curve_to((10.0, 17.0), (10.0, 7.0), (15.0, 4.5));
p.close_path();
p
}
fn path_refresh() -> BezPath {
let mut p = BezPath::new();
let (cx, cy, r) = (12.0, 12.0, 7.0);
let segs = 24;
let (start, end) = (-2.2_f64, 2.2_f64);
for i in 0..=segs {
let t = start + (end - start) * (i as f64) / (segs as f64);
let (x, y) = (cx + r * t.cos(), cy + r * t.sin());
if i == 0 {
p.move_to((x, y));
} else {
p.line_to((x, y));
}
}
let (ex, ey) = (cx + r * end.cos(), cy + r * end.sin());
p.move_to((ex - 1.6, ey - 1.9));
p.line_to((ex, ey));
p.line_to((ex + 2.2, ey - 0.7)); // cabeza de flecha
p
}
fn path_puzzle() -> BezPath {
let mut p = BezPath::new();
p.move_to((5.0, 9.0));
p.line_to((9.5, 9.0));
p.curve_to((9.5, 6.0), (14.5, 6.0), (14.5, 9.0));
p.line_to((19.0, 9.0));
p.line_to((19.0, 19.0));
p.line_to((5.0, 19.0));
p.close_path();
p
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1075,4 +1525,34 @@ mod tests {
names.dedup();
assert_eq!(names.len(), n, "nombres duplicados en Icon::name()");
}
#[test]
fn iconos_nuevos_tienen_path() {
let nuevos = [
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,
];
for icon in nuevos {
assert!(!icon.path().elements().is_empty(), "{} sin path", icon.name());
}
}
#[test]
fn from_glyph_mapea_los_comunes() {
// Casos que cubrían el bug del wawa-panel y de los menús.
assert_eq!(Icon::from_glyph(""), Some(Icon::Settings));
assert_eq!(Icon::from_glyph("🎨"), Some(Icon::Palette));
assert_eq!(Icon::from_glyph(""), Some(Icon::Keyboard));
assert_eq!(Icon::from_glyph(""), Some(Icon::Power));
assert_eq!(Icon::from_glyph(""), Some(Icon::Mail));
assert_eq!(Icon::from_glyph("🔊"), Some(Icon::Volume));
assert_eq!(Icon::from_glyph(""), Some(Icon::Rows));
assert_eq!(Icon::from_glyph(""), Some(Icon::Play));
// Variation selector (emoji presentation) se tolera.
assert_eq!(Icon::from_glyph("🖥\u{FE0F}"), Some(Icon::Monitor));
// Texto real (no un glifo-ícono) no matchea → caería a texto.
assert_eq!(Icon::from_glyph("Archivo"), None);
assert_eq!(Icon::from_glyph(""), None);
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "llimphi-image"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-image — helper para decodificar bytes/path a `peniko::Image` listo para `View::image()`. Reemplaza el patrón duplicado (image::ImageReader + to_rgba8 + Blob + Image::new) que hoy aparece en cada app que muestra imágenes."
[features]
# Descarga + caché de imágenes por URL (ureq síncrono). Opt-in para que el
# crate base siga siendo sólo-decode, sin dependencia de red.
net = ["dep:ureq"]
[dependencies]
llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" }
image = { workspace = true }
ureq = { workspace = true, optional = true }
+41
View File
@@ -0,0 +1,41 @@
# llimphi-image
*Read this in English: [README.md](README.md).*
Decode pipeline para `peniko::Image`.
Hoy el caller que quiere mostrar una imagen con `View::image()` hace:
```ignore
let reader = ImageReader::open(path)?.with_guessed_format()?;
let img = reader.decode()?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width(), rgba.height());
let blob = Blob::from(rgba.into_raw());
let pen = Image::new(blob, ImageFormat::Rgba8, w, h);
```
Cinco líneas + dos llamadas a `?` por cada caller (nahual viewer,
mirada wallpaper, viewer de gallería, etc.). Este crate las
encapsula en dos helpers:
- `decode_bytes` — toma `&[u8]` y devuelve `Result<peniko::Image>`.
- `load_path` — toma `&Path` + `max_bytes` cap y devuelve
`Result<peniko::Image>`, con guardia de tamaño en disco (las apps
no quieren leer un .iso de 4 GB pensando que es una imagen).
Con la feature `net` (opt-in, ureq síncrono) se suman descarga + caché por
URL: `fetch_bytes`/`load_url` (bloqueantes, para correr en un worker) y
`ImageCache` (`Clone + Send + Sync`) que la `view` consulta en el hilo UI
mientras un `Handle::spawn` la puebla. El crate base queda sin dep de red.
`image` (del crate `image`) se construye con `to_rgba8` siempre — la
conversión necesaria para `peniko::ImageFormat::Rgba8`. Para imagen
ya en `rgba8` en memoria (sin necesidad de decodificación), ver
`from_rgba8`.
Formatos: los que active la feature del crate `image` upstream (en
el workspace, hoy: PNG, JPEG, WEBP). Otros se pueden habilitar
agregando la feature; el helper no lo limita.
---
Parte de **llimphi** — ver [llimphi](../LEEME.md).
+39
View File
@@ -0,0 +1,39 @@
# llimphi-image
Decode pipeline for `peniko::Image`.
Today a caller wanting to show an image with `View::image()` writes:
```ignore
let reader = ImageReader::open(path)?.with_guessed_format()?;
let img = reader.decode()?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width(), rgba.height());
let blob = Blob::from(rgba.into_raw());
let pen = Image::new(blob, ImageFormat::Rgba8, w, h);
```
Five lines plus two `?` per caller (the nahual viewer, the mirada wallpaper, the
gallery viewer, and so on). This crate encapsulates them in two helpers:
- `decode_bytes` — takes `&[u8]` and returns `Result<peniko::Image>`.
- `load_path` — takes a `&Path` plus a `max_bytes` cap and returns
`Result<peniko::Image>`, with an on-disk size guard (apps do not want to read a
4 GB `.iso` thinking it is an image).
With the `net` feature (opt-in, synchronous ureq) it adds download plus per-URL
caching: `fetch_bytes`/`load_url` (blocking, to run in a worker) and `ImageCache`
(`Clone + Send + Sync`) that the `view` consults on the UI thread while a
`Handle::spawn` populates it. The base crate keeps no network dependency.
The `image` (from the `image` crate) is always built with `to_rgba8` — the
conversion `peniko::ImageFormat::Rgba8` requires. For an image already in
`rgba8` in memory (no decoding needed) see `from_rgba8`.
Formats: whatever the upstream `image` crate's features enable (in this
workspace, today: PNG, JPEG, WEBP). Others can be turned on by adding the
feature; the helper does not limit them.
---
Part of **llimphi** — see [llimphi](../README.md).
+449
View File
@@ -0,0 +1,449 @@
//! `llimphi-image` — decode pipeline para `peniko::Image`.
//!
//! Hoy el caller que quiere mostrar una imagen con `View::image()` hace:
//!
//! ```ignore
//! let reader = ImageReader::open(path)?.with_guessed_format()?;
//! let img = reader.decode()?;
//! let rgba = img.to_rgba8();
//! let (w, h) = (rgba.width(), rgba.height());
//! let blob = Blob::from(rgba.into_raw());
//! let pen = Image::new(blob, ImageFormat::Rgba8, w, h);
//! ```
//!
//! Cinco líneas + dos llamadas a `?` por cada caller (nahual viewer,
//! mirada wallpaper, viewer de gallería, etc.). Este crate las
//! encapsula en dos helpers:
//!
//! - [`decode_bytes`] — toma `&[u8]` y devuelve `Result<peniko::Image>`.
//! - [`load_path`] — toma `&Path` + `max_bytes` cap y devuelve
//! `Result<peniko::Image>`, con guardia de tamaño en disco (las apps
//! no quieren leer un .iso de 4 GB pensando que es una imagen).
//!
//! Con la feature `net` (opt-in, ureq síncrono) se suman descarga + caché por
//! URL: [`fetch_bytes`]/[`load_url`] (bloqueantes, para correr en un worker) y
//! [`ImageCache`] (`Clone + Send + Sync`) que la `view` consulta en el hilo UI
//! mientras un `Handle::spawn` la puebla. El crate base queda sin dep de red.
//!
//! `image` (del crate `image`) se construye con `to_rgba8` siempre — la
//! conversión necesaria para `peniko::ImageFormat::Rgba8`. Para imagen
//! ya en `rgba8` en memoria (sin necesidad de decodificación), ver
//! [`from_rgba8`].
//!
//! Formatos: los que active la feature del crate `image` upstream (en
//! el workspace, hoy: PNG, JPEG, WEBP). Otros se pueden habilitar
//! agregando la feature; el helper no lo limita.
#![forbid(unsafe_code)]
use std::path::Path;
use std::sync::Arc;
pub use llimphi_raster::peniko::{Blob, ImageAlphaType, ImageBrush as Image, ImageData, ImageFormat};
/// Errores que puede devolver el decode pipeline. Mantenemos el detalle
/// upstream (`String` con el mensaje del crate `image` o de IO) para no
/// perder información al cruzar el seam, pero clasificado para que el
/// caller decida si mostrar diferenciado (`TooBig` ≠ `Decode`).
#[derive(Debug)]
pub enum DecodeError {
/// IO error leyendo el path (no existe, sin permisos, etc.).
Io(std::io::Error),
/// El archivo supera el cap `max_bytes` pasado a [`load_path`].
TooBig {
size_bytes: u64,
max_bytes: u64,
},
/// El reader no reconoce el formato (extensión + magic bytes no
/// matchean ninguno activo).
UnsupportedFormat,
/// El decoder falló (archivo corrupto, formato malo).
Decode(String),
}
impl std::fmt::Display for DecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DecodeError::Io(e) => write!(f, "IO: {e}"),
DecodeError::TooBig { size_bytes, max_bytes } => {
write!(f, "archivo demasiado grande: {size_bytes} bytes (cap: {max_bytes})")
}
DecodeError::UnsupportedFormat => f.write_str("formato no soportado"),
DecodeError::Decode(s) => write!(f, "decode: {s}"),
}
}
}
impl std::error::Error for DecodeError {}
impl From<std::io::Error> for DecodeError {
fn from(e: std::io::Error) -> Self {
DecodeError::Io(e)
}
}
/// Construye un `peniko::Image` directamente desde bytes RGBA8 ya
/// decodificados. No invoca el crate `image`. Útil para imágenes
/// sintéticas o pre-decodificadas.
///
/// 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 fn from_rgba8(rgba: Vec<u8>, w: u32, h: u32) -> Image {
let blob = Blob::new(Arc::new(rgba));
Image::new(ImageData {
data: blob,
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width: w,
height: h,
})
}
/// 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> {
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))
}
/// Lee un path, valida tamaño en disco, decodifica. El cap se compara
/// contra el tamaño del archivo (NO la imagen decodificada, que en
/// 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> {
if max_bytes > 0 {
let meta = std::fs::metadata(path).map_err(DecodeError::Io)?;
if meta.len() > max_bytes {
return Err(DecodeError::TooBig {
size_bytes: meta.len(),
max_bytes,
});
}
}
let reader = image::ImageReader::open(path)
.map_err(DecodeError::Io)?
.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))
}
/// Descarga + caché de imágenes por URL (feature `net`). Síncrono (ureq):
/// la idea es que la app lo llame desde un worker (`Handle::spawn` /
/// `std::thread`), NO en el hilo de UI, y despache un `Msg` con la imagen
/// decodificada. La [`ImageCache`] es `Clone + Send + Sync` (interna
/// `Arc<Mutex<…>>`) para compartirse entre el hilo UI (lecturas) y los
/// workers (descargas).
#[cfg(feature = "net")]
mod net {
use super::{decode_bytes, DecodeError, Image};
use std::collections::HashMap;
use std::io::Read;
use std::sync::{Arc, Mutex};
/// Errores de [`fetch_bytes`]/[`load_url`].
#[derive(Debug)]
pub enum FetchError {
/// La capa de red/HTTP falló (DNS, TLS, status no-2xx, timeout…).
Network(String),
/// El cuerpo descargado superó `max_bytes`.
TooBig { max_bytes: u64 },
/// Se descargó pero no se pudo decodificar como imagen.
Decode(DecodeError),
}
impl std::fmt::Display for FetchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FetchError::Network(s) => write!(f, "red: {s}"),
FetchError::TooBig { max_bytes } => {
write!(f, "descarga supera el cap de {max_bytes} bytes")
}
FetchError::Decode(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for FetchError {}
impl From<DecodeError> for FetchError {
fn from(e: DecodeError) -> Self {
FetchError::Decode(e)
}
}
/// Descarga los bytes crudos de una URL (bloqueante). `max_bytes = 0`
/// deshabilita el cap; si no, corta la lectura apenas se excede (no
/// bufferiza un cuerpo gigante antes de rechazarlo).
pub fn fetch_bytes(url: &str, max_bytes: u64) -> Result<Vec<u8>, FetchError> {
let resp = ureq::get(url)
.call()
.map_err(|e| FetchError::Network(e.to_string()))?;
let mut reader = resp.into_reader();
let mut buf = Vec::new();
if max_bytes > 0 {
// Leemos hasta max_bytes+1: si llega a +1 sabemos que se pasó.
reader
.by_ref()
.take(max_bytes + 1)
.read_to_end(&mut buf)
.map_err(|e| FetchError::Network(e.to_string()))?;
if buf.len() as u64 > max_bytes {
return Err(FetchError::TooBig { max_bytes });
}
} else {
reader
.read_to_end(&mut buf)
.map_err(|e| FetchError::Network(e.to_string()))?;
}
Ok(buf)
}
/// Descarga + decodifica una URL a `peniko::Image` (bloqueante). Sin
/// caché — para eso usar [`ImageCache::get_or_fetch`].
pub fn load_url(url: &str, max_bytes: u64) -> Result<Image, FetchError> {
let bytes = fetch_bytes(url, max_bytes)?;
Ok(decode_bytes(&bytes)?)
}
/// Techo de imágenes vivas en la caché. Sin cota, `get_or_fetch` acumulaba
/// toda `Image` descargada durante la sesión (p. ej. navegar resultados de
/// media-tube) sin evictar nunca — fuga monótona de RAM. `get` re-descarga
/// si una URL fue evictada, así que evictar la más vieja es transparente.
pub(crate) const CACHE_CAP: usize = 256;
/// Caché de imágenes por URL, compartible entre hilos. La `peniko::Image`
/// es barata de clonar (su `Blob` es `Arc`-backed), así que `get` devuelve
/// una copia lista para `View::image()`. Evicción FIFO al superar
/// `CACHE_CAP`: `order` lleva el orden de inserción de claves nuevas, en
/// paso con `inner`.
#[derive(Clone, Default)]
pub struct ImageCache {
inner: Arc<Mutex<HashMap<String, Image>>>,
order: Arc<Mutex<std::collections::VecDeque<String>>>,
}
impl ImageCache {
pub fn new() -> Self {
Self::default()
}
/// Imagen cacheada para esa URL, si ya se descargó. Barato — esto es
/// lo que la `view` consulta en el hilo UI cada frame.
pub fn get(&self, url: &str) -> Option<Image> {
self.inner.lock().ok()?.get(url).cloned()
}
/// `true` si la URL ya está en caché (sin clonar la imagen).
pub fn contains(&self, url: &str) -> bool {
self.inner
.lock()
.map(|m| m.contains_key(url))
.unwrap_or(false)
}
/// Inserta (o reemplaza) la imagen de una URL. Lo llama el worker tras
/// decodificar, o la app si ya tiene la imagen por otra vía. Evicta la
/// más vieja (FIFO) al superar `CACHE_CAP`.
pub fn insert(&self, url: impl Into<String>, img: Image) {
let url = url.into();
if let Ok(mut m) = self.inner.lock() {
let es_nueva = !m.contains_key(&url);
m.insert(url.clone(), img);
if es_nueva {
if let Ok(mut order) = self.order.lock() {
order.push_back(url);
while m.len() > CACHE_CAP {
match order.pop_front() {
Some(vieja) => {
m.remove(&vieja);
}
None => break,
}
}
}
}
}
}
/// Vacía la caché (p. ej. al cambiar de sesión/usuario).
pub fn clear(&self) {
if let Ok(mut m) = self.inner.lock() {
m.clear();
}
if let Ok(mut order) = self.order.lock() {
order.clear();
}
}
/// Cantidad de imágenes cacheadas.
pub fn len(&self) -> usize {
self.inner.lock().map(|m| m.len()).unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Devuelve la imagen cacheada o, si falta, la descarga+decodifica y la
/// cachea (bloqueante). **Llamar desde un worker**, no en el hilo UI.
/// El patrón típico: la `view` hace `cache.get(url)`; si es `None`,
/// dispara `Handle::spawn` que llama `get_or_fetch` y al volver
/// despacha un `Msg` para repintar.
pub fn get_or_fetch(&self, url: &str, max_bytes: u64) -> Result<Image, FetchError> {
if let Some(img) = self.get(url) {
return Ok(img);
}
let img = load_url(url, max_bytes)?;
self.insert(url.to_string(), img.clone());
Ok(img)
}
}
}
#[cfg(feature = "net")]
pub use net::{fetch_bytes, load_url, FetchError, ImageCache};
#[cfg(test)]
mod tests {
use super::*;
/// Bytes PNG válido de 2×2 píxeles: rojo, verde, azul, blanco.
/// Generado con `image::RgbaImage` + encode a PNG.
fn png_2x2_bytes() -> Vec<u8> {
let mut buf = Vec::new();
let mut rgba = image::RgbaImage::new(2, 2);
rgba.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
rgba.put_pixel(1, 0, image::Rgba([0, 255, 0, 255]));
rgba.put_pixel(0, 1, image::Rgba([0, 0, 255, 255]));
rgba.put_pixel(1, 1, image::Rgba([255, 255, 255, 255]));
image::DynamicImage::ImageRgba8(rgba)
.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.expect("encode png");
buf
}
#[cfg(feature = "net")]
#[test]
fn image_cache_evicta_fifo_al_superar_el_cap() {
// Inserta más del cap: las más viejas se evictan, las últimas viven.
let cache = ImageCache::new();
let total = net::CACHE_CAP + 10;
for i in 0..total {
cache.insert(format!("u{i}"), from_rgba8(vec![0u8; 4], 1, 1));
}
assert_eq!(cache.len(), net::CACHE_CAP, "la caché queda acotada al cap");
// Las primeras 10 (las más viejas) fueron evictadas.
assert!(cache.get("u0").is_none());
assert!(cache.get("u9").is_none());
// Las últimas siguen presentes.
assert!(cache.get(&format!("u{}", total - 1)).is_some());
// Reinsertar una clave existente no reordena ni evicta de más.
cache.insert(format!("u{}", total - 1), from_rgba8(vec![0u8; 4], 1, 1));
assert_eq!(cache.len(), net::CACHE_CAP);
}
#[test]
fn from_rgba8_arma_image_con_dimensiones_correctas() {
let img = from_rgba8(vec![0u8; 16], 2, 2); // 2x2x4 = 16 bytes
assert_eq!(img.image.width, 2);
assert_eq!(img.image.height, 2);
assert!(matches!(img.image.format, ImageFormat::Rgba8));
}
#[test]
fn decode_bytes_png_basico() {
let bytes = png_2x2_bytes();
let img = decode_bytes(&bytes).expect("decode ok");
assert_eq!(img.image.width, 2);
assert_eq!(img.image.height, 2);
}
#[test]
fn decode_bytes_invalido_devuelve_error() {
let bad = vec![0u8, 1, 2, 3, 4, 5];
let r = decode_bytes(&bad);
assert!(r.is_err());
// Magic no matchea ningún formato conocido → UnsupportedFormat.
match r.unwrap_err() {
DecodeError::UnsupportedFormat => {}
other => panic!("esperaba UnsupportedFormat, recibí: {other:?}"),
}
}
#[test]
fn load_path_respeta_cap_de_tamano() {
// Escribimos un PNG válido y lo cargamos con cap muy bajo.
let dir = std::env::temp_dir();
let path = dir.join("llimphi_image_test_cap.png");
let bytes = png_2x2_bytes();
std::fs::write(&path, &bytes).expect("write tmp png");
// Cap de 1 byte → demasiado grande.
let r = load_path(&path, 1);
match r {
Err(DecodeError::TooBig { size_bytes, max_bytes }) => {
assert!(size_bytes > 1);
assert_eq!(max_bytes, 1);
}
other => panic!("esperaba TooBig, recibí: {other:?}"),
}
// Cap 0 → deshabilitado, decodifica OK.
let img = load_path(&path, 0).expect("decode ok sin cap");
assert_eq!(img.image.width, 2);
assert_eq!(img.image.height, 2);
std::fs::remove_file(&path).ok();
}
#[test]
fn load_path_io_error_cuando_no_existe() {
let r = load_path(std::path::Path::new("/no/existe/probablemente.png"), 0);
match r {
Err(DecodeError::Io(_)) => {}
other => panic!("esperaba Io, recibí: {other:?}"),
}
}
/// La lógica de caché (sin red): insert / get / contains / clear sobre una
/// imagen sintética. La descarga real no se testea (requiere red).
#[cfg(feature = "net")]
#[test]
fn image_cache_insert_get_contains_clear() {
let cache = ImageCache::new();
assert!(cache.is_empty());
assert!(!cache.contains("http://x/a.png"));
assert!(cache.get("http://x/a.png").is_none());
let img = from_rgba8(vec![0u8; 16], 2, 2);
cache.insert("http://x/a.png", img);
assert!(cache.contains("http://x/a.png"));
assert_eq!(cache.len(), 1);
let got = cache.get("http://x/a.png").expect("cacheada");
assert_eq!(got.image.width, 2);
// Una segunda URL no colisiona.
cache.insert("http://x/b.png".to_string(), from_rgba8(vec![0u8; 4], 1, 1));
assert_eq!(cache.len(), 2);
cache.clear();
assert!(cache.is_empty());
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "llimphi-layer"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Runner wlr-layer-shell para Llimphi: corre cualquier `App` como una superficie de capa anclada a un borde (barra/panel), no como ventana. Extrae la plumbing sctk + wgpu que pata probó en producción, parametrizada sobre el trait `App`. Sirve para que apps (p. ej. shuma) se dockeen al borde de un compositor wlroots como una barra estilo eww/waybar."
[lib]
path = "src/lib.rs"
[dependencies]
llimphi-ui = { workspace = true }
smithay-client-toolkit = "0.19"
wayland-client = "0.31"
raw-window-handle = { workspace = true }
pollster = { workspace = true }
+27
View File
@@ -0,0 +1,27 @@
# llimphi-layer
*Read this in English: [README.md](README.md).*
Corre un [`App`] de Llimphi como una **superficie wlr-layer-shell** (una barra anclada a un borde), no como ventana.
Hace que una app se siente al nivel de eww/waybar en cualquier compositor wlroots (mirada, Hyprland, Sway, river…): se ancla a un borde, declara una *exclusive zone* opcional —el compositor le reserva la franja y tesela el resto— y se pinta reusando el pipeline de Llimphi (`mount → compute → paint → render`).
Es la plumbing `smithay-client-toolkit` + `wgpu` que `pata` probó en
producción, extraída a un runner **genérico** sobre el trait `App`. A
diferencia de pata (N superficies + tray/dock/sidebar/…), este corre **una**
superficie y nada de dominio: la app provee `view`/`update`/`on_key` y este
crate le presta el borde. Útil, p. ej., para que `shuma` se dockee como barra.
```ignore
struct MiBarra;
impl llimphi_ui::App for MiBarra { /* … */ }
llimphi_layer::run::<MiBarra>(llimphi_layer::LayerConfig {
edge: llimphi_layer::Edge::Bottom,
thickness: 40,
keyboard: llimphi_layer::Keyboard::OnDemand,
..Default::default()
})?;
```
---
Parte de **llimphi** — ver [llimphi](../LEEME.md).
+30
View File
@@ -0,0 +1,30 @@
# llimphi-layer
Runs a Llimphi `App` as a **wlr-layer-shell surface** (a bar anchored to an edge)
rather than as a window.
It lets an app sit at the level of eww/waybar on any wlroots compositor (mirada,
Hyprland, Sway, river…): it anchors to an edge, declares an optional *exclusive
zone* — the compositor reserves the strip and tiles the rest — and paints itself
reusing Llimphi's pipeline (`mount → compute → paint → render`).
It is the `smithay-client-toolkit` + `wgpu` plumbing that `pata` proved in
production, extracted into a **generic** runner over the `App` trait. Unlike pata
(N surfaces + tray/dock/sidebar/…), this runs **one** surface and no domain
logic: the app provides `view`/`update`/`on_key` and this crate lends it the
edge. Useful, for instance, to dock `shuma` as a bar.
```ignore
struct MyBar;
impl llimphi_ui::App for MyBar { /* … */ }
llimphi_layer::run::<MyBar>(llimphi_layer::LayerConfig {
edge: llimphi_layer::Edge::Bottom,
thickness: 40,
keyboard: llimphi_layer::Keyboard::OnDemand,
..Default::default()
})?;
```
---
Part of **llimphi** — see [llimphi](../README.md).
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,19 +1,19 @@
[package]
name = "llimphi-layout"
description = "Layout engine for llimphi (Flexbox + CSS Grid via taffy)."
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "Layout engine for llimphi (Flexbox + CSS Grid via taffy)."
repository.workspace = true
[dependencies]
taffy = { workspace = true }
[dev-dependencies]
llimphi-hal = { path = "../llimphi-hal" }
llimphi-raster = { path = "../llimphi-raster" }
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" }
pollster = { workspace = true }
[[example]]
+63
View File
@@ -153,6 +153,69 @@ impl LayoutTree {
}
}
#[cfg(test)]
mod cache_probe {
//! Bloque 24 (probe): ¿taffy saltea subárboles limpios al recomputar sobre
//! un árbol persistente (sin `clear()`)? De la respuesta depende todo el
//! enfoque de layout retenido. Se mide contando llamadas a `measure`.
use super::*;
use std::cell::Cell;
#[test]
fn taffy_cachea_entre_computes() {
let mut lt = LayoutTree::new();
let mut leaves = Vec::new();
for _ in 0..100 {
leaves.push(
lt.leaf(Style {
size: Size { width: auto(), height: auto() },
..Default::default()
})
.unwrap(),
);
}
let root = lt
.node(
Style {
flex_direction: FlexDirection::Column,
size: Size { width: length(200.0), height: length(4000.0) },
..Default::default()
},
&leaves,
)
.unwrap();
let count = Cell::new(0u32);
let mut measure = |_nid: NodeId,
_k: taffy::Size<Option<f32>>,
_a: taffy::Size<AvailableSpace>| {
count.set(count.get() + 1);
taffy::Size { width: 10.0, height: 10.0 }
};
lt.compute_with_measure(root, (200.0, 4000.0), &mut measure).unwrap();
let first = count.get();
// 2ª pasada SIN mutar ni clear.
count.set(0);
lt.compute_with_measure(root, (200.0, 4000.0), &mut measure).unwrap();
let second = count.get();
// 3ª: una sola hoja sucia.
lt.inner_mut().mark_dirty(leaves[0]).unwrap();
count.set(0);
lt.compute_with_measure(root, (200.0, 4000.0), &mut measure).unwrap();
let third = count.get();
println!("[probe] measures: 1ª={first} 2ª(sin mutar)={second} 3ª(1 hoja sucia)={third}");
assert!(first >= 100, "la 1ª pasada mide las hojas");
// Premisa del Bloque 24: la 2ª pasada cachea (mide << first) y una hoja
// sucia sólo re-mide su cadena (third << first).
assert!(second < first, "2ª pasada debe cachear (second={second} < first={first})");
assert!(third < first, "1 hoja sucia re-mide poco (third={third} < first={first})");
}
}
fn flatten(
tree: &TaffyTree<()>,
node: NodeId,
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "llimphi-lottie"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-lottie — puente fino velato (Lottie) → Llimphi. Carga un .json de Lottie una vez (LottieAsset) y lo stampea en cualquier View vía paint_with a un frame/instante dado, escalado al rect. Gemelo de llimphi-svg + eje de tiempo."
[dependencies]
llimphi-ui = { workspace = true }
# Fork vendorizado de velato (no paniquea ante features no soportadas).
foreign-lottie = { workspace = true }
# Máquina de estados de animación (clip-agnóstica) que este crate renderiza.
llimphi-anim = { workspace = true }
+57
View File
@@ -0,0 +1,57 @@
# llimphi-lottie
*Read this in English: [README.md](README.md).*
Puente fino entre `velato` (reproductor de Lottie de Linebender) y Llimphi.
`llimphi-svg` cubre el vector *estático* arbitrario (íconos `.desktop`,
logos). `llimphi-motion` cubre el movimiento *generado por código* (tweens
sobre el bucle Elm). Este crate cubre el hueco del medio: **animación
vectorial autorada por fuera** — los `.json` de Lottie que exportan los
diseñadores desde After Effects / lottiefiles (íconos animados,
ilustraciones, estados vacíos, onboarding).
Es el gemelo de `llimphi-svg`, con un eje de tiempo: `velato` parsea el JSON
a una `Composition` y, para un `frame` dado, emite a la **misma**
`vello::Scene` que usa `llimphi-raster`.
## Uso
```ignore
use llimphi_lottie::LottieAsset;
use std::time::Duration;
// Parsea UNA vez (al cargar la app):
let anim = LottieAsset::from_str(include_str!("spinner.json")).expect("lottie válido");
// En el estado de la app guardas el tiempo transcurrido y lo avanzas con el
// bucle Elm — exactamente el patrón de los workers de simulación:
// handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
// y en update: self.t += 1.0 / 60.0;
// Pintalo a su instante actual (escala + centra al rect, en loop):
View::new(style).children(vec![anim.view_at_time::<Msg>(self.t)])
```
El parse cuesta (corre el importador serde de Lottie); el `paint`/`view` no.
`LottieAsset` es `Clone` barato (`Arc` internamente) — el mismo asset en
varios nodos no replica memoria.
## Por qué un `Renderer` nuevo por paint
`velato::Renderer` es `Default` y sólo guarda Vecs de scratch que se limpian
en cada `append`. No tiene recursos de GPU (eso es trabajo de `llimphi-hal`).
Crear uno por frame es despreciable frente a empujar la geometría — que se
hace igual — y nos deja un `LottieAsset` `Send + Sync` sin `Mutex`.
## Cobertura
`velato` 0.9 implementa shapes, transforms animados, gradientes, trim paths,
máscaras y precomposiciones — el grueso de los Lottie reales de íconos e
ilustración. **No** implementa todavía capas de **texto**, **effects**
(blur/drop-shadow) ni **expresiones** (declarado en su propio
`schema/mod.rs`). Un asset que use esas features se pinta omitiéndolas, no
rompe. Cuando un `.json` concreto las necesite, el camino es vendorizar
`velato` como `shared/foreign-lottie` y completar el `todo` puntual.
---
Parte de **llimphi** — ver [llimphi](../LEEME.md).
+31
View File
@@ -0,0 +1,31 @@
# llimphi-lottie
A thin bridge between `velato` (Linebender's Lottie player) and Llimphi.
`llimphi-svg` covers arbitrary *static* vector art (`.desktop` icons, logos).
`llimphi-motion` covers motion *generated by code* (tweens over the Elm loop).
This crate covers the gap in between: **vector animation authored elsewhere**
the Lottie `.json` files designers export from After Effects / lottiefiles
(animated icons, illustrations, empty states, onboarding).
It is the twin of `llimphi-svg` with a time axis: `velato` parses the JSON into a
`Composition` and, for a given `frame`, emits into the **same** `vello::Scene`
that `llimphi-raster` uses.
## Use
```ignore
use llimphi_lottie::LottieAsset;
use std::time::Duration;
// Parse ONCE (when loading the app):
let anim = LottieAsset::from_str(include_str!("spinner.json")).expect("valid lottie");
// The app keeps the elapsed time in its state and advances it with the Elm
// loop — exactly the pattern of the simulation workers:
// handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
```
---
Part of **llimphi** — see [llimphi](../README.md).
@@ -0,0 +1,202 @@
//! Demo del Tier 3: **listeners de puntero** en la máquina de estados.
//!
//! El hit-testing vive **en el motor** (`llimphi-anim`), no en la app: la app
//! sólo reenvía el puntero crudo (`pointer_move`), y los `Listener` del estado
//! deciden qué input tocar. Aquí, dos listeners sobre toda la animación —
//! `Enter → moving=true`, `Exit → moving=false` — hacen que **pasar el puntero
//! por encima** dispare el crossfade idle→walk, y sacarlo lo revierta. Sin
//! teclado: el puntero es la interacción.
//!
//! Corre con:
//! `cargo run -p llimphi-lottie --example pointer_listeners_demo --release`
use std::time::Duration;
use llimphi_anim::{Action, Area, Condition, PointerTrigger, StateMachine};
use llimphi_lottie::{state_machine_view, LottieAsset};
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{App, Handle, View};
const IDLE: u32 = 0;
const WALK: u32 = 1;
/// idle: círculo azul cuya opacidad late (100→35→100) en 2 s.
const IDLE_LOTTIE: &str = r#"{
"v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
"layers":[{"ty":4,"ip":0,"op":60,"st":0,"sr":1,
"ks":{"o":{"a":1,"k":[
{"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[100]},
{"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":30,"s":[35]},
{"t":60,"s":[100]}]},
"r":{"a":0,"k":0},"p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},
"shapes":[{"ty":"gr","it":[
{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[72,72]}},
{"ty":"fl","c":{"a":0,"k":[0.30,0.55,0.95]},"o":{"a":0,"k":100}},
{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}]}]}"#;
/// walk: cuadrado naranja redondeado que gira 360° en 2 s.
const WALK_LOTTIE: &str = r#"{
"v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
"layers":[{"ty":4,"ip":0,"op":60,"st":0,"sr":1,
"ks":{"o":{"a":0,"k":100},
"r":{"a":1,"k":[
{"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[0]},
{"t":60,"s":[360]}]},
"p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},
"shapes":[{"ty":"gr","it":[
{"ty":"rc","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[60,60]},"r":{"a":0,"k":10}},
{"ty":"fl","c":{"a":0,"k":[0.95,0.55,0.15]},"o":{"a":0,"k":100}},
{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}]}]}"#;
#[derive(Clone)]
enum Msg {
Tick,
/// Puntero sobre la animación, en coords normalizadas `0..1` (`None` = salió).
Pointer(Option<(f64, f64)>),
}
struct Model {
inst: llimphi_anim::Instance,
clips: Vec<LottieAsset>,
}
struct Demo;
const TICK: Duration = Duration::from_millis(16);
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · pointer listeners"
}
fn initial_size() -> (u32, u32) {
(420, 500)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
let idle = LottieAsset::from_str(IDLE_LOTTIE).expect("idle lottie");
let walk = LottieAsset::from_str(WALK_LOTTIE).expect("walk lottie");
let mut sm = StateMachine::new();
let s_idle = sm.add_state("idle", IDLE, 1.0, true);
let s_walk = sm.add_state("walk", WALK, 1.0, true);
sm.set_entry(s_idle);
sm.transition(s_idle, s_walk, vec![Condition::bool("moving", true)], 0.3);
sm.transition(s_walk, s_idle, vec![Condition::bool("moving", false)], 0.3);
// Tier 3: el hover sobre toda la animación maneja el input `moving`.
sm.listener(Area::All, PointerTrigger::Enter, Action::set_bool("moving", true));
sm.listener(Area::All, PointerTrigger::Exit, Action::set_bool("moving", false));
handle.spawn_periodic(TICK, || Msg::Tick);
Model {
inst: sm.instance(),
clips: vec![idle, walk],
}
}
fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::Tick => model.inst.advance(TICK.as_secs_f64()),
// La app sólo reenvía el puntero crudo; los listeners del motor
// deciden qué input tocar.
Msg::Pointer(p) => model.inst.pointer_move(p),
}
model
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let stage = View::new(Style {
size: Size {
width: length(280.0_f32),
height: length(280.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.radius(16.0)
.fill(Color::from_rgba8(26, 30, 40, 255))
.children(vec![state_machine_view::<Msg>(
model.inst.render_frame(),
model.clips.clone(),
)])
// Cada movimiento del cursor sobre el stage → posición normalizada.
.on_pointer_move_at(|lx, ly, w, h| {
if w > 0.0 && h > 0.0 {
Some(Msg::Pointer(Some((lx as f64 / w as f64, ly as f64 / h as f64))))
} else {
None
}
})
// El cursor salió del stage → fuera de toda área.
.on_pointer_leave(Msg::Pointer(None));
let estado = if model.inst.is_transitioning() {
"· · · crossfade · · ·".to_string()
} else {
format!("estado: {}", model.inst.current_state())
};
let status = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(26.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text(estado, 17.0, Color::from_rgba8(180, 200, 230, 255));
let hint = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(22.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text(
"pasa el puntero por encima".to_string(),
14.0,
Color::from_rgba8(120, 135, 160, 255),
);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(16.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
padding: Rect {
left: length(24.0_f32),
right: length(24.0_f32),
top: length(24.0_f32),
bottom: length(24.0_f32),
},
..Default::default()
})
.fill(Color::from_rgba8(18, 22, 30, 255))
.children(vec![stage, status, hint])
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,216 @@
//! Demo interactiva del Tier 1+2: máquina de estados de animación (`llimphi-anim`)
//! manejando dos clips Lottie con **crossfade**, manejada por un input vivo.
//!
//! Dos estados: `idle` (círculo azul que late) ⇄ `walk` (cuadrado naranja que
//! gira), con un blend de 0.35 s entre ellos. El input booleano `moving` dispara
//! las transiciones en ambos sentidos. **Espacio** o el botón lo togglean —
//! ves el crossfade disolver una animación en la otra.
//!
//! Esto es el cableado canónico inputs↔app (Tier 2): el `Model` tiene una
//! `Instance`, un `spawn_periodic` la avanza por frame (`advance(dt)`), los
//! eventos setean inputs (`set_bool`), y `view` pinta `render_frame()` vía
//! `state_machine_view`. El núcleo de estados no sabe de Lottie ni de vello.
//!
//! Corre con:
//! `cargo run -p llimphi-lottie --example state_machine_demo --release`
use std::time::Duration;
use llimphi_anim::{Condition, StateMachine};
use llimphi_lottie::{state_machine_view, LottieAsset};
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
// ClipId 0 = idle, 1 = walk.
const IDLE: u32 = 0;
const WALK: u32 = 1;
/// idle: círculo azul cuya opacidad late (100→35→100) en 2 s.
const IDLE_LOTTIE: &str = r#"{
"v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
"layers":[{"ty":4,"ip":0,"op":60,"st":0,"sr":1,
"ks":{"o":{"a":1,"k":[
{"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[100]},
{"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":30,"s":[35]},
{"t":60,"s":[100]}]},
"r":{"a":0,"k":0},"p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},
"shapes":[{"ty":"gr","it":[
{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[72,72]}},
{"ty":"fl","c":{"a":0,"k":[0.30,0.55,0.95]},"o":{"a":0,"k":100}},
{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}]}]}"#;
/// walk: cuadrado naranja redondeado que gira 360° en 2 s.
const WALK_LOTTIE: &str = r#"{
"v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
"layers":[{"ty":4,"ip":0,"op":60,"st":0,"sr":1,
"ks":{"o":{"a":0,"k":100},
"r":{"a":1,"k":[
{"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[0]},
{"t":60,"s":[360]}]},
"p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},
"shapes":[{"ty":"gr","it":[
{"ty":"rc","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[60,60]},"r":{"a":0,"k":10}},
{"ty":"fl","c":{"a":0,"k":[0.95,0.55,0.15]},"o":{"a":0,"k":100}},
{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}]}]}"#;
#[derive(Clone)]
enum Msg {
/// Tick de animación: avanza la máquina por `dt`.
Tick,
/// Togglea el input `moving` (Espacio o el botón).
ToggleMoving,
}
struct Model {
inst: llimphi_anim::Instance,
clips: Vec<LottieAsset>,
moving: bool,
}
struct Demo;
/// Período del tick → también el `dt` que le pasamos a `advance` (fijo, alcanza
/// para una demo; una app real mediría el dt real).
const TICK: Duration = Duration::from_millis(16);
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · state machine + crossfade"
}
fn initial_size() -> (u32, u32) {
(420, 520)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
let idle = LottieAsset::from_str(IDLE_LOTTIE).expect("idle lottie");
let walk = LottieAsset::from_str(WALK_LOTTIE).expect("walk lottie");
let mut sm = StateMachine::new();
let s_idle = sm.add_state("idle", IDLE, 1.0, true);
let s_walk = sm.add_state("walk", WALK, 1.0, true);
sm.set_entry(s_idle);
// El mismo input `moving` rige ambos sentidos, con blend de 0.35 s.
sm.transition(s_idle, s_walk, vec![Condition::bool("moving", true)], 0.35);
sm.transition(s_walk, s_idle, vec![Condition::bool("moving", false)], 0.35);
// Corre el reloj de animación (~60 fps).
handle.spawn_periodic(TICK, || Msg::Tick);
Model {
inst: sm.instance(),
clips: vec![idle, walk],
moving: false,
}
}
fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::Tick => model.inst.advance(TICK.as_secs_f64()),
Msg::ToggleMoving => {
model.moving = !model.moving;
model.inst.set_bool("moving", model.moving);
}
}
model
}
fn on_key(_: &Self::Model, e: &KeyEvent) -> Option<Self::Msg> {
if e.state == KeyState::Pressed && e.key == Key::Named(NamedKey::Space) {
Some(Msg::ToggleMoving)
} else {
None
}
}
fn view(model: &Self::Model) -> View<Self::Msg> {
// Caja cuadrada que aloja la animación (state_machine_view es absolute-fill).
let stage = View::new(Style {
size: Size {
width: length(280.0_f32),
height: length(280.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.children(vec![state_machine_view::<Msg>(
model.inst.render_frame(),
model.clips.clone(),
)]);
let estado = if model.inst.is_transitioning() {
"· · · crossfade · · ·".to_string()
} else {
format!("estado: {}", model.inst.current_state())
};
let status = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text(estado, 18.0, Color::from_rgba8(180, 200, 230, 255));
let label = if model.moving {
"moving = true (Espacio para parar)"
} else {
"moving = false (Espacio para mover)"
};
let boton = View::new(Style {
size: Size {
width: length(280.0_f32),
height: length(52.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(if model.moving {
Color::from_rgba8(200, 120, 50, 255)
} else {
Color::from_rgba8(50, 110, 190, 255)
})
.radius(12.0)
.text(label.to_string(), 16.0, Color::from_rgba8(245, 245, 250, 255))
.on_click(Msg::ToggleMoving);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(20.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
padding: Rect {
left: length(24.0_f32),
right: length(24.0_f32),
top: length(24.0_f32),
bottom: length(24.0_f32),
},
..Default::default()
})
.fill(Color::from_rgba8(18, 22, 30, 255))
.children(vec![stage, status, boton])
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
+461
View File
@@ -0,0 +1,461 @@
//! `llimphi-lottie` — puente fino entre `velato` (reproductor de Lottie de
//! Linebender) y Llimphi.
//!
//! `llimphi-svg` cubre el vector *estático* arbitrario (íconos `.desktop`,
//! logos). `llimphi-motion` cubre el movimiento *generado por código* (tweens
//! sobre el bucle Elm). Este crate cubre el hueco del medio: **animación
//! vectorial autorada por fuera** — los `.json` de Lottie que exportan los
//! diseñadores desde After Effects / lottiefiles (íconos animados,
//! ilustraciones, estados vacíos, onboarding).
//!
//! Es el gemelo de `llimphi-svg`, con un eje de tiempo: `velato` parsea el JSON
//! a una `Composition` y, para un `frame` dado, emite a la **misma**
//! `vello::Scene` que usa `llimphi-raster`.
//!
//! ## Uso
//!
//! ```ignore
//! use llimphi_lottie::LottieAsset;
//! use std::time::Duration;
//!
//! // Parsea UNA vez (al cargar la app):
//! let anim = LottieAsset::from_str(include_str!("spinner.json")).expect("lottie válido");
//!
//! // En el estado de la app guardas el tiempo transcurrido y lo avanzas con el
//! // bucle Elm — exactamente el patrón de los workers de simulación:
//! // handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
//! // y en update: self.t += 1.0 / 60.0;
//!
//! // Pintalo a su instante actual (escala + centra al rect, en loop):
//! View::new(style).children(vec![anim.view_at_time::<Msg>(self.t)])
//! ```
//!
//! El parse cuesta (corre el importador serde de Lottie); el `paint`/`view` no.
//! `LottieAsset` es `Clone` barato (`Arc` internamente) — el mismo asset en
//! varios nodos no replica memoria.
//!
//! ## Por qué un `Renderer` nuevo por paint
//!
//! `velato::Renderer` es `Default` y sólo guarda Vecs de scratch que se limpian
//! en cada `append`. No tiene recursos de GPU (eso es trabajo de `llimphi-hal`).
//! Crear uno por frame es despreciable frente a empujar la geometría — que se
//! hace igual — y nos deja un `LottieAsset` `Send + Sync` sin `Mutex`.
//!
//! ## Cobertura
//!
//! `velato` 0.9 implementa shapes, transforms animados, gradientes, trim paths,
//! máscaras y precomposiciones — el grueso de los Lottie reales de íconos e
//! ilustración. **No** implementa todavía capas de **texto**, **effects**
//! (blur/drop-shadow) ni **expresiones** (declarado en su propio
//! `schema/mod.rs`). Un asset que use esas features se pinta omitiéndolas, no
//! rompe. Cuando un `.json` concreto las necesite, el camino es vendorizar
//! `velato` como `shared/foreign-lottie` y completar el `todo` puntual.
#![forbid(unsafe_code)]
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_layout::taffy::Position;
use llimphi_ui::llimphi_raster::kurbo::Affine;
use llimphi_ui::llimphi_raster::vello::Scene;
use llimphi_ui::{PaintRect, View};
// El motor es nuestro fork vendorizado de velato; lo aliasamos a `velato` para
// que el resto del crate lea igual que la documentación upstream.
use foreign_lottie as velato;
use velato::Composition;
/// Animación Lottie parseada y lista para stampear a cualquier `frame`.
/// Internamente guarda la `Composition` de `velato` (el modelo del `.json`) +
/// su geometría temporal. Cloneable barato (`Arc`) — un mismo asset en varios
/// nodos no replica memoria. `Send + Sync`.
#[derive(Clone)]
pub struct LottieAsset {
inner: Arc<Composition>,
}
/// Error al parsear un Lottie. Hoy es un wrap del texto del error de `velato` —
/// las apps típicas lo tratan como "fallback" (no animan) y no inspeccionan la
/// variante.
#[derive(Debug)]
pub struct LottieError(pub String);
impl std::fmt::Display for LottieError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "lottie: {}", self.0)
}
}
impl std::error::Error for LottieError {}
impl LottieAsset {
/// Parsea un Lottie desde su JSON como string. Devuelve un asset inmutable +
/// cloneable. **Hace el parseo completo** (serde + importación al modelo
/// runtime de `velato`); pensado para llamarse UNA vez por asset, no por
/// frame.
pub fn from_str(json: &str) -> Result<Self, LottieError> {
Self::from_bytes(json.as_bytes())
}
/// Parsea un Lottie desde sus bytes UTF-8 crudos (lo que devuelve
/// `include_bytes!("…json")` o `std::fs::read`).
///
/// **Blindaje contra panics.** Nuestro fork `foreign-lottie` ya eliminó los
/// `todo!()`/`unimplemented!()` del importador de velato 0.9 (split
/// rotation/position, blends `Add`/`HardMix`, assets desconocidos, transform
/// sin rotación) — degradan con gracia en vez de paniquear. El `catch_unwind`
/// queda como red de seguridad secundaria: por si un `.json` raro alcanza
/// algún `panic!`/`unwrap` residual del deserializador, el asset cae al
/// fallback en vez de tumbar el hilo de UI. El render (`append`) es seguro,
/// así que no lo envolvemos por-frame.
pub fn from_bytes(json: &[u8]) -> Result<Self, LottieError> {
let parsed = std::panic::catch_unwind(|| Composition::from_slice(json));
let comp = match parsed {
Ok(Ok(comp)) => comp,
Ok(Err(e)) => return Err(LottieError(e.to_string())),
Err(_) => {
return Err(LottieError(
"velato paniqueó al importar (feature no soportada: split \
rotation/position, asset o blend sin implementar)"
.to_string(),
))
}
};
Ok(Self {
inner: Arc::new(comp),
})
}
/// Tamaño nominal de la animación en px (el `w`/`h` del Lottie). Útil para
/// dimensionar el rect destino preservando aspect ratio.
pub fn size(&self) -> (f64, f64) {
(self.inner.width as f64, self.inner.height as f64)
}
/// Cuadros por segundo declarados en el `.json` (`fr`).
pub fn frame_rate(&self) -> f64 {
self.inner.frame_rate
}
/// Rango de frames activos `[ip, op)` del Lottie. `paint` espera un `frame`
/// dentro de este rango; fuera de él, lo clampa a los extremos.
pub fn frames(&self) -> std::ops::Range<f64> {
self.inner.frames.clone()
}
/// Duración de una pasada completa, en segundos. `0.0` si el `.json` no
/// declara `fr` o no tiene frames.
pub fn duration_secs(&self) -> f64 {
let span = self.inner.frames.end - self.inner.frames.start;
if self.inner.frame_rate > 0.0 && span > 0.0 {
span / self.inner.frame_rate
} else {
0.0
}
}
/// Convierte un instante en segundos a un `frame` dentro del rango activo,
/// **en loop** (módulo la duración). Es el mapeo que usa `view_at_time` /
/// `paint_at_time`. Si el asset no tiene duración válida, devuelve el primer
/// frame.
pub fn frame_at_time(&self, t_secs: f64) -> f64 {
let start = self.inner.frames.start;
let span = self.inner.frames.end - start;
if span <= 0.0 || self.inner.frame_rate <= 0.0 {
return start;
}
let frames_elapsed = (t_secs.max(0.0) * self.inner.frame_rate) % span;
start + frames_elapsed
}
/// Pinta la animación al `frame` indicado sobre `scene`, ajustada al `rect`.
/// Escala uniforme al mínimo lado y **centra** dentro del rect (preserva
/// aspect ratio). `frame` se clampa al rango activo del Lottie. Útil cuando
/// el caller compone varios assets en un `paint_with` propio sin pasar por
/// `view()`.
pub fn paint(&self, scene: &mut Scene, rect: PaintRect, frame: f64) {
self.paint_alpha(scene, rect, frame, 1.0);
}
/// Como `paint`, pero con una opacidad global `alpha` (0..1) aplicada a toda
/// la composición. Es lo que habilita el **crossfade** entre clips de una
/// máquina de estados (pintar el saliente a `1-mix` y el entrante a `mix`).
/// `velato` lo soporta nativo: el `alpha` va al `append`.
pub fn paint_alpha(&self, scene: &mut Scene, rect: PaintRect, frame: f64, alpha: f64) {
let (vb_w, vb_h) = self.size();
let side_w = rect.w as f64;
let side_h = rect.h as f64;
if side_w <= 0.0 || side_h <= 0.0 || vb_w <= 0.0 || vb_h <= 0.0 || alpha <= 0.0 {
return;
}
let s = (side_w / vb_w).min(side_h / vb_h);
let used_w = vb_w * s;
let used_h = vb_h * s;
let tx = rect.x as f64 + (side_w - used_w) * 0.5;
let ty = rect.y as f64 + (side_h - used_h) * 0.5;
let xform = Affine::translate((tx, ty)) * Affine::scale(s);
let frame = frame.clamp(self.inner.frames.start, self.inner.frames.end);
// Renderer nuevo por paint: es `Default`, sólo scratch Vecs, sin GPU.
let mut renderer = velato::Renderer::new();
renderer.append(&self.inner, frame, xform, alpha.clamp(0.0, 1.0), scene);
}
/// Como `paint`, pero recibe el instante en **segundos** y lo mapea a frame
/// en loop vía `frame_at_time`. Es la forma esperada de animar desde el
/// estado de la app (acumulas `t` en segundos en `update`).
pub fn paint_at_time(&self, scene: &mut Scene, rect: PaintRect, t_secs: f64) {
self.paint(scene, rect, self.frame_at_time(t_secs));
}
/// Construye un `View` posicionado en absoluto que ocupa todo el rect del
/// padre y pinta la animación al `frame` indicado, centrada + escalada al
/// mínimo lado. Gemelo de `SvgAsset::view`, con frame. Genérico sobre `Msg`
/// igual que los widgets — el `View` no tiene handlers; la app los pone en
/// el padre.
pub fn view<Msg>(&self, frame: f64) -> View<Msg> {
let asset = self.clone();
View::new(absolute_fill())
.paint_with(move |scene, _ts, rect| asset.paint(scene, rect, frame))
}
/// Como `view`, pero recibe el instante en segundos (loop vía
/// `frame_at_time`). Es la variante que usas con un `t` acumulado en el
/// estado de la app.
pub fn view_at_time<Msg>(&self, t_secs: f64) -> View<Msg> {
let asset = self.clone();
View::new(absolute_fill())
.paint_with(move |scene, _ts, rect| asset.paint_at_time(scene, rect, t_secs))
}
}
/// Pinta un [`RenderFrame`] de una máquina de estados [`llimphi_anim`] usando
/// `clips` indexados por `ClipId` (= índice en el slice). Pinta el clip primario
/// y, si hay una transición en curso, hace **crossfade**: saliente a `1-mix`,
/// entrante a `mix`. Los `ClipId` fuera de rango se omiten (no rompe).
///
/// `ClipSample::time_secs` se mapea a frame con loop vía `frame_at_time`, así
/// que cada clip respeta su propio fps y duración.
///
/// [`RenderFrame`]: llimphi_anim::RenderFrame
pub fn paint_render_frame(
scene: &mut Scene,
rect: PaintRect,
frame: &llimphi_anim::RenderFrame,
clips: &[LottieAsset],
) {
let sample = |s: llimphi_anim::ClipSample, alpha: f64, scene: &mut Scene| {
if let Some(asset) = clips.get(s.clip as usize) {
let f = asset.frame_at_time(s.time_secs);
asset.paint_alpha(scene, rect, f, alpha);
}
};
match frame.blend {
None => sample(frame.primary, 1.0, scene),
Some((incoming, mix)) => {
let mix = mix as f64;
// Dissolve: el saliente se desvanece mientras el entrante aparece.
sample(frame.primary, 1.0 - mix, scene);
sample(incoming, mix, scene);
}
}
}
/// Construye un `View` absoluto que ocupa el rect del padre y pinta el
/// [`RenderFrame`] actual de una máquina de estados (con crossfade). El caller
/// llama `instance.render_frame()` cada frame y le pasa el resultado + sus clips
/// (cloneados; baratos por `Arc`). Es el gemelo de [`LottieAsset::view`] para
/// animación dirigida por estado en vez de por un solo clip.
///
/// [`RenderFrame`]: llimphi_anim::RenderFrame
pub fn state_machine_view<Msg>(
frame: llimphi_anim::RenderFrame,
clips: Vec<LottieAsset>,
) -> View<Msg> {
View::new(absolute_fill())
.paint_with(move |scene, _ts, rect| paint_render_frame(scene, rect, &frame, &clips))
}
/// Estilo "ocupa todo el rect del padre, en absoluto" — compartido por las dos
/// variantes de `view`.
fn absolute_fill() -> Style {
Style {
position: Position::Absolute,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Lottie mínimo válido: 60 frames a 30 fps, lienzo 100×100, sin capas.
/// (Suficiente para ejercitar parse + geometría temporal; el render de un
/// shape real lo cubren los assets de ejemplo, no un unit test.)
const LOTTIE_OK: &str =
r#"{"v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,"layers":[]}"#;
#[test]
fn from_str_parsea_ok() {
let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea");
let (w, h) = a.size();
assert_eq!((w, h), (100.0, 100.0));
assert_eq!(a.frame_rate(), 30.0);
}
#[test]
fn duracion_y_rango() {
let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea");
assert_eq!(a.frames(), 0.0..60.0);
// 60 frames / 30 fps = 2 s.
assert!((a.duration_secs() - 2.0).abs() < 1e-9);
}
#[test]
fn frame_at_time_hace_loop() {
let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea");
// En t=0 → frame 0.
assert!((a.frame_at_time(0.0) - 0.0).abs() < 1e-9);
// En t=1 s (mitad) → frame 30.
assert!((a.frame_at_time(1.0) - 30.0).abs() < 1e-9);
// En t=2 s (una pasada exacta) → vuelve a 0 por el módulo.
assert!((a.frame_at_time(2.0) - 0.0).abs() < 1e-9);
// En t=2.5 s → frame 15 (loop).
assert!((a.frame_at_time(2.5) - 15.0).abs() < 1e-9);
}
#[test]
fn json_inválido_da_error() {
assert!(LottieAsset::from_str("{no es lottie}").is_err());
}
#[test]
fn asset_es_cloneable_barato() {
let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea");
let b = a.clone();
assert_eq!(a.size(), b.size());
}
#[test]
fn paint_no_panica_con_rect_cero() {
let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea");
let mut s = Scene::new();
a.paint(&mut s, PaintRect { x: 0.0, y: 0.0, w: 0.0, h: 0.0 }, 0.0);
a.paint_at_time(&mut s, PaintRect { x: 0.0, y: 0.0, w: 10.0, h: 0.0 }, 0.5);
}
/// `Send + Sync` es parte del contrato (igual que `SvgAsset`): el asset se
/// mueve a closures de paint que pueden cruzar threads.
#[test]
fn asset_es_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<LottieAsset>();
}
/// Lottie con un shape real: un rectángulo rojo de 80×80 centrado en un
/// lienzo 100×100, estático 60 frames. Sirve para certificar — con
/// evidencia textual, no PNG — que la geometría llega a la `vello::Scene`.
const LOTTIE_RECT: &str = r#"{
"v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
"layers":[{
"ty":4,"ip":0,"op":60,"st":0,"sr":1,
"ks":{"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0}},
"shapes":[
{"ty":"rc","p":{"a":0,"k":[50,50]},"s":{"a":0,"k":[80,80]},"r":{"a":0,"k":0}},
{"ty":"fl","c":{"a":0,"k":[0.8,0,0,1]},"o":{"a":0,"k":100}}
]
}]
}"#;
#[test]
fn shape_real_emite_geometria_a_la_scene() {
let a = LottieAsset::from_str(LOTTIE_RECT).expect("parsea");
let mut scene = Scene::new();
// Antes de pintar: la Scene está vacía.
assert!(scene.encoding().is_empty(), "scene recién creada debe estar vacía");
a.paint(&mut scene, PaintRect { x: 0.0, y: 0.0, w: 200.0, h: 200.0 }, 0.0);
// Después: velato empujó el rect → encoding con contenido.
assert!(
!scene.encoding().is_empty(),
"tras paint de un shape real, la Scene debe tener geometría"
);
}
/// Mismo layer pero con el transform SIN campo de rotación (`r`). En velato
/// 0.9 upstream esto **paniquea** (`todo!("split rotation")`); nuestro fork
/// `foreign-lottie` lo trata como rotación 0 y **parsea bien**. Aquí
/// certificamos el resultado del fork: parse Ok, sin panic.
const LOTTIE_SIN_ROTACION: &str = r#"{
"v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
"layers":[{
"ty":4,"ip":0,"op":60,"st":0,"sr":1,
"ks":{"p":{"a":0,"k":[0,0]}},
"shapes":[]
}]
}"#;
#[test]
fn lottie_sin_rotacion_parsea_con_el_fork() {
// En velato upstream esto era un panic; el fork lo importa como rot=0.
let a = LottieAsset::from_str(LOTTIE_SIN_ROTACION)
.expect("el fork parsea un transform sin rotación");
assert_eq!(a.size(), (100.0, 100.0));
}
/// Segundo clip distinto al rect: un círculo, para tener dos animaciones que
/// mezclar en la máquina de estados.
const LOTTIE_CIRC: &str = r#"{
"v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
"layers":[{
"ty":4,"ip":0,"op":60,"st":0,"sr":1,
"ks":{"p":{"a":0,"k":[50,50]},"r":{"a":0,"k":0}},
"shapes":[
{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[70,70]}},
{"ty":"fl","c":{"a":0,"k":[0,0.4,0.9,1]},"o":{"a":0,"k":100}}
]
}]
}"#;
/// E2E del Tier 1: máquina de estados (llimphi-anim) + dos clips Lottie +
/// `paint_render_frame`. Verifica que tanto el estado simple como el
/// crossfade emiten geometría a la `Scene` (evidencia textual, sin PNG).
#[test]
fn state_machine_pinta_clips_y_crossfade() {
use llimphi_anim::{Condition, StateMachine};
let rect = LottieAsset::from_str(LOTTIE_RECT).expect("rect");
let circ = LottieAsset::from_str(LOTTIE_CIRC).expect("circ");
let clips = vec![rect, circ]; // ClipId 0 = rect, 1 = circ
let mut sm = StateMachine::new();
let idle = sm.add_state("idle", 0, 1.0, true);
let walk = sm.add_state("walk", 1, 1.0, true);
sm.set_entry(idle);
sm.transition(idle, walk, vec![Condition::bool("moving", true)], 0.4);
let mut inst = sm.instance();
let big = PaintRect { x: 0.0, y: 0.0, w: 200.0, h: 200.0 };
// Estado simple: pinta sólo el clip primario (rect).
let mut s0 = Scene::new();
paint_render_frame(&mut s0, big, &inst.render_frame(), &clips);
assert!(!s0.encoding().is_empty(), "estado simple debe pintar geometría");
// Arranca la transición y cae a mitad del blend.
inst.set_bool("moving", true);
inst.advance(0.2); // 0.2/0.4 = mix 0.5
let rf = inst.render_frame();
assert!(rf.blend.is_some(), "debería estar en crossfade");
let mut s1 = Scene::new();
paint_render_frame(&mut s1, big, &rf, &clips);
assert!(!s1.encoding().is_empty(), "el crossfade debe pintar geometría");
// El view helper compila y produce un View sin panic.
let _v = state_machine_view::<()>(rf, clips);
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "llimphi-mesh"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-mesh — render de mallas deformables (llimphi-anim::skel::Mesh) a vello Scene. Ruta vectorial (fill por triángulo) y texturizada (clip + draw_image piecewise-affine), validadas contra vello 0.7. La matemática de huesos/skinning vive en llimphi-anim; este crate sólo pinta."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-anim = { workspace = true }
[dev-dependencies]
# Sólo para el ejemplo combinado lottie+rive (Lottie + esqueleto/IK en una app).
llimphi-lottie = { workspace = true }
+27
View File
@@ -0,0 +1,27 @@
# llimphi-mesh
*Read this in English: [README.md](README.md).*
Render de mallas deformables a una `vello::Scene`.
La matemática de huesos y skinning vive en `llimphi_anim::skel`; este crate
sólo **pinta** la malla ya deformada. Recibe la malla (`Mesh`) + las
posiciones deformadas de sus vértices (lo que devuelve `Mesh::deform`) y un
`xform` model→pantalla, y ofrece las dos rutas que el spike validó contra
vello 0.7:
- `paint_solid` — rellena cada triángulo deformado con un color (malla
vectorial / debug). Trivial.
- `paint_textured` — malla **texturizada**: por triángulo, recorta al
triángulo deformado y dibuja la imagen con el afín que mapea sus UV a la
posición deformada (warp piecewise-affine). Costo = un clip-layer por
triángulo; cuidado con mallas de miles de triángulos.
- `paint_wireframe` — traza los bordes de los triángulos (ver la
deformación; ideal para demos/debug).
Helpers de encuadre: `rest_bounds` (bbox de la malla en reposo) +
`fit_transform` (afín que encaja esos bounds, centrados, en un `PaintRect`).
---
Parte de **llimphi** — ver [llimphi](../LEEME.md).
+25
View File
@@ -0,0 +1,25 @@
# llimphi-mesh
Rendering of deformable meshes to a `vello::Scene`.
The bone and skinning maths live in `llimphi_anim::skel`; this crate only
**paints** the already-deformed mesh. It receives the mesh (`Mesh`) plus the
deformed positions of its vertices (what `Mesh::deform` returns) and a
model→screen `xform`, and offers the two routes the spike validated against
vello 0.7:
- `paint_solid` — fills each deformed triangle with a colour (vector mesh /
debug). Trivial.
- `paint_textured` — a **textured** mesh: per triangle, it clips to the deformed
triangle and draws the image with the affine mapping its UVs to the deformed
position (piecewise-affine warp). Cost = one clip layer per triangle; careful
with meshes of thousands of triangles.
- `paint_wireframe` — strokes the triangle edges (to see the deformation; ideal
for demos and debugging).
Framing helpers: `rest_bounds` (the mesh's bbox at rest) and `fit_transform` (the
affine fitting those bounds, centred, into a `PaintRect`).
---
Part of **llimphi** — see [llimphi](../README.md).
+221
View File
@@ -0,0 +1,221 @@
//! Demo del Tier 4: una tira **texturizada deformada por una cadena de huesos**
//! que ondula como una bandera/tentáculo.
//!
//! La cadena de huesos (`llimphi_anim::skel`) se re-posa cada frame con una onda
//! senoidal de fase creciente; el skinning (LBS) deforma los vértices; y
//! `llimphi-mesh` pinta la malla texturizada (warp piecewise-affine) con un
//! wireframe encima para ver la deformación. **Espacio** alterna el wireframe.
//!
//! Todo Rust nativo, sobre vello 0.7 — cero C++.
//!
//! Corre con:
//! `cargo run -p llimphi-mesh --example bones_demo --release`
use std::sync::Arc;
use std::time::Duration;
use llimphi_anim::skel::{Mesh, Pose, Skeleton, Vertex};
use llimphi_mesh::{fit_transform, paint_textured, paint_wireframe, rest_bounds};
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::kurbo::{Point, Vec2};
use llimphi_ui::llimphi_raster::peniko::{
Blob, Color, ImageAlphaType, ImageBrush, ImageData, ImageFormat,
};
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, PaintRect, View};
const SEG: usize = 8; // segmentos de la tira (= huesos de la cadena, +1 raíz)
const STEP: f64 = 30.0; // largo de cada hueso/segmento
const STRIP_H: f64 = 72.0; // alto de la tira
/// Construye la cadena de huesos (raíz + SEG eslabones) y la malla-tira atada a
/// ella (rigid por columna), con UVs de 0..1 a lo ancho/alto.
fn build() -> (Skeleton, Mesh) {
let mut s = Skeleton::new();
// Raíz anclada a la izquierda; cada eslabón se traslada STEP en x respecto
// al padre. Bone i tiene id i (orden de inserción).
s.add_bone(None, Pose::translate(Vec2::new(0.0, STRIP_H / 2.0)));
for _ in 1..=SEG {
s.add_bone(Some(s.len() - 1), Pose::translate(Vec2::new(STEP, 0.0)));
}
s.bind();
let mut m = Mesh::new();
for i in 0..=SEG {
let x = i as f64 * STEP;
let u = i as f64 / SEG as f64;
m.vertices.push(Vertex::rigid(Point::new(x, 0.0), (u, 0.0), i)); // borde sup
m.vertices.push(Vertex::rigid(Point::new(x, STRIP_H), (u, 1.0), i)); // borde inf
}
for i in 0..SEG {
let (t0, t1) = ((2 * i) as u32, (2 * (i + 1)) as u32);
let (b0, b1) = ((2 * i + 1) as u32, (2 * (i + 1) + 1) as u32);
m.triangles.push([t0, t1, b1]);
m.triangles.push([t0, b1, b0]);
}
(s, m)
}
/// Textura procedural: tablero de ajedrez (para ver el warp).
fn checker(n: u32, sq: u32) -> ImageBrush {
let mut px = Vec::with_capacity((n * n * 4) as usize);
for y in 0..n {
for x in 0..n {
let on = ((x / sq + y / sq) % 2) == 0;
let (r, g, b) = if on { (70, 130, 205) } else { (240, 180, 70) };
px.extend_from_slice(&[r, g, b, 255]);
}
}
ImageBrush::new(ImageData {
data: Blob::new(Arc::new(px)),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width: n,
height: n,
})
}
#[derive(Clone)]
enum Msg {
Tick,
ToggleWire,
}
struct Model {
skel: Skeleton,
mesh: Mesh,
image: ImageBrush,
t: f64,
wireframe: bool,
}
struct Demo;
const TICK: Duration = Duration::from_millis(16);
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · malla deformada por huesos"
}
fn initial_size() -> (u32, u32) {
(520, 420)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
let (skel, mesh) = build();
handle.spawn_periodic(TICK, || Msg::Tick);
Model {
skel,
mesh,
image: checker(8, 1),
t: 0.0,
wireframe: true,
}
}
fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::Tick => {
model.t += TICK.as_secs_f64();
// Onda viajera: cada hueso oscila con fase creciente → la tira
// ondula desde el ancla hacia la punta.
for i in 1..=SEG {
let r = 0.38 * (model.t * 3.0 - i as f64 * 0.6).sin();
model
.skel
.set_pose(i, Pose::new(Vec2::new(STEP, 0.0), r, Vec2::new(1.0, 1.0)));
}
model.skel.update();
}
Msg::ToggleWire => model.wireframe = !model.wireframe,
}
model
}
fn on_key(_: &Self::Model, e: &KeyEvent) -> Option<Self::Msg> {
if e.state == KeyState::Pressed && e.key == Key::Named(NamedKey::Space) {
Some(Msg::ToggleWire)
} else {
None
}
}
fn view(model: &Self::Model) -> View<Self::Msg> {
// Deformar aquí (lectura) y mover los datos al closure de pintura.
let positions = model.mesh.deform(&model.skel);
let mesh = model.mesh.clone();
let image = model.image.clone();
let wire = model.wireframe;
let stage = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
// Margen para que la onda no se recorte contra el borde.
let inner = PaintRect {
x: rect.x + 50.0,
y: rect.y + 50.0,
w: (rect.w - 100.0).max(1.0),
h: (rect.h - 100.0).max(1.0),
};
let xf = fit_transform(rest_bounds(&mesh), inner);
paint_textured(scene, &mesh, &positions, xf, &image);
if wire {
paint_wireframe(
scene,
&mesh,
&positions,
xf,
Color::from_rgba8(255, 255, 255, 120),
1.2,
);
}
});
let hint = 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),
..Default::default()
})
.text(
"tira texturizada deformada por una cadena de huesos · Espacio: wireframe".to_string(),
13.0,
Color::from_rgba8(140, 155, 180, 255),
);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
},
..Default::default()
})
.fill(Color::from_rgba8(18, 22, 30, 255))
.children(vec![stage, hint])
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,248 @@
//! Demo que **cierra el círculo** del motor: máquina de estados → animación de
//! huesos → skinning → malla deformada.
//!
//! Dos clips son `BoneAnimation`s (animaciones esqueletales keyframeadas):
//! `rest` (tira recta) y `wave` (onda viajera). Una máquina de estados
//! (`llimphi-anim`) transiciona entre ellos por el input `active`, y
//! `pose_from_render_frame` posa el esqueleto **blendeando poses** durante el
//! crossfade — así la tira se *ease-in* a la onda en vez de saltar. El skinning
//! deforma la malla texturizada (`llimphi-mesh`).
//!
//! **Espacio** alterna `active` (rest ⇄ wave). Todo Rust nativo sobre vello 0.7.
//!
//! Corre con:
//! `cargo run -p llimphi-mesh --example clips_to_bones_demo --release`
use std::f64::consts::PI;
use std::sync::Arc;
use std::time::Duration;
use llimphi_anim::skel::{
pose_from_render_frame, BoneAnimation, BoneTrack, Mesh, Pose, PoseKey, Skeleton, Vertex,
};
use llimphi_anim::{Condition, Instance, StateMachine};
use llimphi_mesh::{fit_transform, paint_textured, paint_wireframe, rest_bounds};
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::kurbo::{Point, Vec2};
use llimphi_ui::llimphi_raster::peniko::{
Blob, Color, ImageAlphaType, ImageBrush, ImageData, ImageFormat,
};
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, PaintRect, View};
const SEG: usize = 8;
const STEP: f64 = 30.0;
const STRIP_H: f64 = 72.0;
/// Cadena de huesos (raíz anclada + SEG eslabones) + malla-tira atada rigid.
fn build_rig() -> (Skeleton, Mesh) {
let mut s = Skeleton::new();
s.add_bone(None, Pose::translate(Vec2::new(0.0, STRIP_H / 2.0)));
for _ in 1..=SEG {
s.add_bone(Some(s.len() - 1), Pose::translate(Vec2::new(STEP, 0.0)));
}
s.bind();
let mut m = Mesh::new();
for i in 0..=SEG {
let x = i as f64 * STEP;
let u = i as f64 / SEG as f64;
m.vertices.push(Vertex::rigid(Point::new(x, 0.0), (u, 0.0), i));
m.vertices.push(Vertex::rigid(Point::new(x, STRIP_H), (u, 1.0), i));
}
for i in 0..SEG {
let (t0, t1) = ((2 * i) as u32, (2 * (i + 1)) as u32);
let (b0, b1) = ((2 * i + 1) as u32, (2 * (i + 1) + 1) as u32);
m.triangles.push([t0, t1, b1]);
m.triangles.push([t0, b1, b0]);
}
(s, m)
}
/// Pose de un eslabón: mantiene la translación STEP (estructura de la cadena) y
/// anima sólo la rotación.
fn link_pose(rot: f64) -> Pose {
Pose::new(Vec2::new(STEP, 0.0), rot, Vec2::new(1.0, 1.0))
}
/// Clip "recto": todos los eslabones a rotación 0 (un keyframe).
fn straight_anim() -> BoneAnimation {
let tracks = (1..=SEG)
.map(|i| BoneTrack {
bone: i,
keys: vec![PoseKey { t: 0.0, pose: link_pose(0.0) }],
})
.collect();
BoneAnimation { duration: 1.0, looping: true, tracks }
}
/// Clip "onda": cada eslabón keyframeado con una senoidal de fase creciente.
fn wave_anim(amp: f64, period: f64, nkeys: usize) -> BoneAnimation {
let tracks = (1..=SEG)
.map(|i| {
let keys = (0..=nkeys)
.map(|k| {
let t = period * k as f64 / nkeys as f64;
let r = amp * (2.0 * PI * t / period - i as f64 * 0.6).sin();
PoseKey { t, pose: link_pose(r) }
})
.collect();
BoneTrack { bone: i, keys }
})
.collect();
BoneAnimation { duration: period, looping: true, tracks }
}
fn checker(n: u32, sq: u32) -> ImageBrush {
let mut px = Vec::with_capacity((n * n * 4) as usize);
for y in 0..n {
for x in 0..n {
let on = ((x / sq + y / sq) % 2) == 0;
let (r, g, b) = if on { (70, 130, 205) } else { (240, 180, 70) };
px.extend_from_slice(&[r, g, b, 255]);
}
}
ImageBrush::new(ImageData {
data: Blob::new(Arc::new(px)),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width: n,
height: n,
})
}
#[derive(Clone)]
enum Msg {
Tick,
Toggle,
}
struct Model {
sm: Instance,
skel: Skeleton,
mesh: Mesh,
clips: Vec<BoneAnimation>,
image: ImageBrush,
active: bool,
}
struct Demo;
const TICK: Duration = Duration::from_millis(16);
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · clips → huesos → malla"
}
fn initial_size() -> (u32, u32) {
(560, 420)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
let (skel, mesh) = build_rig();
let clips = vec![straight_anim(), wave_anim(0.42, 2.2, 16)];
let mut sm = StateMachine::new();
let rest = sm.add_state("rest", 0, 1.0, true);
let wave = sm.add_state("wave", 1, 1.0, true);
sm.set_entry(rest);
sm.transition(rest, wave, vec![Condition::bool("active", true)], 0.5);
sm.transition(wave, rest, vec![Condition::bool("active", false)], 0.5);
handle.spawn_periodic(TICK, || Msg::Tick);
Model {
sm: sm.instance(),
skel,
mesh,
clips,
image: checker(8, 1),
active: false,
}
}
fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::Tick => {
model.sm.advance(TICK.as_secs_f64());
// El estado/transición de la máquina posa el esqueleto (blend de
// poses durante el crossfade). pose_from_render_frame llama
// skel.update() internamente.
let frame = model.sm.render_frame();
pose_from_render_frame(&mut model.skel, &frame, &model.clips);
}
Msg::Toggle => {
model.active = !model.active;
model.sm.set_bool("active", model.active);
}
}
model
}
fn on_key(_: &Self::Model, e: &KeyEvent) -> Option<Self::Msg> {
if e.state == KeyState::Pressed && e.key == Key::Named(NamedKey::Space) {
Some(Msg::Toggle)
} else {
None
}
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let positions = model.mesh.deform(&model.skel);
let mesh = model.mesh.clone();
let image = model.image.clone();
let stage = View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_grow: 1.0,
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
let inner = PaintRect {
x: rect.x + 50.0,
y: rect.y + 50.0,
w: (rect.w - 100.0).max(1.0),
h: (rect.h - 100.0).max(1.0),
};
let xf = fit_transform(rest_bounds(&mesh), inner);
paint_textured(scene, &mesh, &positions, xf, &image);
paint_wireframe(scene, &mesh, &positions, xf, Color::from_rgba8(255, 255, 255, 110), 1.2);
});
let label = if model.sm.is_transitioning() {
"· · · blend de poses · · ·".to_string()
} else {
format!("estado: {} (Espacio: rest ⇄ wave)", model.sm.current_state())
};
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),
..Default::default()
})
.text(label, 14.0, Color::from_rgba8(150, 165, 190, 255));
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
},
..Default::default()
})
.fill(Color::from_rgba8(18, 22, 30, 255))
.children(vec![stage, status])
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
+241
View File
@@ -0,0 +1,241 @@
//! Demo **lottie + rive** en una sola app: los dos paradigmas de animación del
//! motor nativo trabajando juntos.
//!
//! - **rive (esqueletal + IK)**: un brazo de 2 huesos con una malla texturizada
//! skinneada, que **persigue el cursor** resolviendo IK de 2 huesos cada frame
//! (`llimphi_anim::constraint::solve_two_bone_ik`).
//! - **lottie**: un pulso vectorial (`llimphi-lottie` sobre el fork de velato)
//! reproducido en la **punta del brazo** y otro marcando el **objetivo** bajo
//! el cursor.
//!
//! Mové el mouse: el brazo alcanza el cursor (IK), el Lottie late en la punta y
//! en el blanco. **F** alterna el codo (flip de la solución IK). Todo Rust
//! nativo sobre vello 0.7, cero C++.
//!
//! Corre con:
//! `cargo run -p llimphi-mesh --example lottie_rive_demo --release`
use std::sync::Arc;
use std::time::Duration;
use llimphi_anim::constraint::solve_two_bone_ik;
use llimphi_anim::skel::{BoneId, Mesh, Pose, Skeleton, Vertex, Weight};
use llimphi_lottie::LottieAsset;
use llimphi_mesh::{paint_textured, paint_wireframe};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, Vec2};
use llimphi_ui::llimphi_raster::peniko::{
Blob, Color, ImageAlphaType, ImageBrush, ImageData, ImageFormat,
};
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, PaintRect, View};
// Brazo en coordenadas locales del lienzo (px). Ancla a la izquierda-centro.
const ANCHOR: (f64, f64) = (110.0, 200.0);
const L1: f64 = 95.0; // hueso superior
const L2: f64 = 95.0; // hueso inferior
const ARM_H: f64 = 40.0; // grosor del brazo
const COLS: usize = 6; // columnas de la malla a lo largo del brazo
/// Pulso azul: círculo cuya opacidad late en 1.5 s.
const PULSE_LOTTIE: &str = r#"{
"v":"5.5.2","fr":30,"ip":0,"op":45,"w":100,"h":100,
"layers":[{"ty":4,"ip":0,"op":45,"st":0,"sr":1,
"ks":{"o":{"a":1,"k":[
{"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[100]},
{"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":22,"s":[25]},
{"t":45,"s":[100]}]},
"r":{"a":0,"k":0},"p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},
"shapes":[{"ty":"gr","it":[
{"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[60,60]}},
{"ty":"fl","c":{"a":0,"k":[0.45,0.75,1.0]},"o":{"a":0,"k":100}},
{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}]}]}"#;
/// Brazo de 2 huesos + malla-tira skinneada (peso suave alrededor del codo).
fn build_arm() -> (Skeleton, Mesh, BoneId, BoneId) {
let mut s = Skeleton::new();
let a = s.add_bone(None, Pose::translate(Vec2::new(ANCHOR.0, ANCHOR.1)));
let b = s.add_bone(Some(a), Pose::translate(Vec2::new(L1, 0.0)));
s.bind();
s.update();
let total = L1 + L2;
let blend = total * 0.16; // ancho del blend de peso en el codo
let mut m = Mesh::new();
for i in 0..=COLS {
let p = total * i as f64 / COLS as f64; // posición a lo largo del brazo
// Peso suave: 1→A antes del codo, 0→A (todo B) después.
let wa = (1.0 - (p - (L1 - blend)) / (2.0 * blend)).clamp(0.0, 1.0);
let weights = vec![
Weight { bone: a, weight: wa },
Weight { bone: b, weight: 1.0 - wa },
];
let u = i as f64 / COLS as f64;
let x = ANCHOR.0 + p;
m.vertices.push(Vertex {
rest: Point::new(x, ANCHOR.1 - ARM_H / 2.0),
uv: (u, 0.0),
weights: weights.clone(),
});
m.vertices.push(Vertex {
rest: Point::new(x, ANCHOR.1 + ARM_H / 2.0),
uv: (u, 1.0),
weights,
});
}
for i in 0..COLS {
let (t0, t1) = ((2 * i) as u32, (2 * (i + 1)) as u32);
let (b0, b1) = ((2 * i + 1) as u32, (2 * (i + 1) + 1) as u32);
m.triangles.push([t0, t1, b1]);
m.triangles.push([t0, b1, b0]);
}
(s, m, a, b)
}
fn checker(n: u32, sq: u32) -> ImageBrush {
let mut px = Vec::with_capacity((n * n * 4) as usize);
for y in 0..n {
for x in 0..n {
let on = ((x / sq + y / sq) % 2) == 0;
let (r, g, b) = if on { (235, 110, 90) } else { (250, 200, 120) };
px.extend_from_slice(&[r, g, b, 255]);
}
}
ImageBrush::new(ImageData {
data: Blob::new(Arc::new(px)),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width: n,
height: n,
})
}
#[derive(Clone)]
enum Msg {
Tick,
Target(f64, f64),
Flip,
}
struct Model {
skel: Skeleton,
mesh: Mesh,
upper: BoneId,
lower: BoneId,
image: ImageBrush,
pulse: LottieAsset,
target: Point,
t: f64,
flip: bool,
}
struct Demo;
const TICK: Duration = Duration::from_millis(16);
const TIP_LOCAL: Vec2 = Vec2::new(L2, 0.0);
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · lottie + rive"
}
fn initial_size() -> (u32, u32) {
(640, 420)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
let (skel, mesh, upper, lower) = build_arm();
handle.spawn_periodic(TICK, || Msg::Tick);
Model {
skel,
mesh,
upper,
lower,
image: checker(8, 1),
pulse: LottieAsset::from_str(PULSE_LOTTIE).expect("pulse lottie"),
target: Point::new(360.0, 150.0),
t: 0.0,
flip: false,
}
}
fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::Tick => {
model.t += TICK.as_secs_f64();
// IK cada frame: el brazo alcanza el objetivo (cursor).
solve_two_bone_ik(
&mut model.skel,
model.upper,
model.lower,
TIP_LOCAL,
model.target,
model.flip,
);
}
Msg::Target(x, y) => model.target = Point::new(x, y),
Msg::Flip => model.flip = !model.flip,
}
model
}
fn on_key(_: &Self::Model, e: &KeyEvent) -> Option<Self::Msg> {
if e.state == KeyState::Pressed {
if let Key::Character(c) = &e.key {
if c.as_str() == "f" {
return Some(Msg::Flip);
}
}
}
None
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let positions = model.mesh.deform(&model.skel);
// Punta del brazo (mundo local) = world(lower) · tip_local.
let tip_local = model.skel.world(model.lower) * Point::new(TIP_LOCAL.x, TIP_LOCAL.y);
let mesh = model.mesh.clone();
let image = model.image.clone();
let pulse = model.pulse.clone();
let target = model.target;
let t = model.t;
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(Color::from_rgba8(18, 22, 30, 255))
.paint_with(move |scene, _ts, rect| {
// Coords locales → pantalla (el lienzo arranca en rect.x/rect.y).
let to_screen = Affine::translate((rect.x as f64, rect.y as f64));
// Brazo skinneado (rive).
paint_textured(scene, &mesh, &positions, to_screen, &image);
paint_wireframe(
scene,
&mesh,
&positions,
to_screen,
Color::from_rgba8(255, 255, 255, 90),
1.0,
);
// Pulso lottie en la punta del brazo y en el objetivo.
let pulse_rect = |p: Point, s: f64| PaintRect {
x: (rect.x as f64 + p.x - s) as f32,
y: (rect.y as f64 + p.y - s) as f32,
w: (s * 2.0) as f32,
h: (s * 2.0) as f32,
};
pulse.paint_at_time(scene, pulse_rect(target, 30.0), t);
pulse.paint_at_time(scene, pulse_rect(tip_local, 22.0), t);
})
.on_pointer_move_at(|lx, ly, _w, _h| Some(Msg::Target(lx as f64, ly as f64)))
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,211 @@
//! Demo **chévere**: leyes físicas sobre varios esqueletos.
//!
//! Cinco tentáculos: cada uno es una cadena de huesos con una malla texturizada
//! skinneada, manejada por **física Verlet** (`llimphi_anim::physics`) — cuelgan
//! del techo, se balancean bajo gravedad y rebotan en el piso. **Mové el mouse**
//! y el cursor los **empuja** (campo de repulsión). Cero keyframes: la física
//! posa los esqueletos, el skinning deforma las mallas.
//!
//! Todo Rust nativo sobre vello 0.7. Pensado también como fondo vivo de
//! lock/greeter.
//!
//! Corre con:
//! `cargo run -p llimphi-mesh --example physics_skeletons_demo --release`
use std::sync::Arc;
use std::time::Duration;
use llimphi_anim::physics::{pose_chain_from_points, Physics};
use llimphi_anim::skel::{BoneId, Mesh, Pose, Skeleton, Vertex};
use llimphi_mesh::paint_textured;
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, Vec2};
use llimphi_ui::llimphi_raster::peniko::{
Blob, Color, ImageAlphaType, ImageBrush, ImageData, ImageFormat,
};
use llimphi_ui::{App, Handle, PaintRect, View};
const SEGS: usize = 9;
const SEG_LEN: f64 = 26.0;
const THICK: f64 = 22.0;
const FLOOR_Y: f64 = 372.0;
/// Un tentáculo: física (partículas en cadena) + esqueleto + malla skinneada.
struct Rope {
phys: Physics,
skel: Skeleton,
mesh: Mesh,
bones: Vec<BoneId>,
image: ImageBrush,
}
fn checker(a: (u8, u8, u8), b: (u8, u8, u8)) -> ImageBrush {
let n = 8u32;
let mut px = Vec::with_capacity((n * n * 4) as usize);
for y in 0..n {
for x in 0..n {
let (r, g, bl) = if (x + y) % 2 == 0 { a } else { b };
px.extend_from_slice(&[r, g, bl, 255]);
}
}
ImageBrush::new(ImageData {
data: Blob::new(Arc::new(px)),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width: n,
height: n,
})
}
fn make_rope(anchor: Point, image: ImageBrush) -> Rope {
// Física: partículas desde el ancla (fija) hacia abajo.
let mut phys = Physics::new();
phys.floor_y = Some(FLOOR_Y);
let mut prev = phys.particle(anchor, true);
for i in 1..=SEGS {
let p = phys.particle(Point::new(anchor.x, anchor.y + i as f64 * SEG_LEN), false);
phys.link(prev, p);
prev = p;
}
// Esqueleto en bind pose recto desde el ORIGEN (a lo largo de +x); la física
// lo reubica/orienta cada frame con pose_chain_from_points.
let mut skel = Skeleton::new();
let mut bones = vec![skel.add_bone(None, Pose::identity())];
for _ in 1..=SEGS {
bones.push(skel.add_bone(Some(*bones.last().unwrap()), Pose::translate(Vec2::new(SEG_LEN, 0.0))));
}
skel.bind();
// Malla-tira en reposo (recta desde el origen), rigid por columna al hueso i.
let mut mesh = Mesh::new();
for i in 0..=SEGS {
let x = i as f64 * SEG_LEN;
let u = i as f64 / SEGS as f64;
// La punta se afina (tentáculo).
let half = THICK * 0.5 * (1.0 - 0.6 * (i as f64 / SEGS as f64));
mesh.vertices.push(Vertex::rigid(Point::new(x, -half), (u, 0.0), bones[i]));
mesh.vertices.push(Vertex::rigid(Point::new(x, half), (u, 1.0), bones[i]));
}
for i in 0..SEGS {
let (t0, t1) = ((2 * i) as u32, (2 * (i + 1)) as u32);
let (b0, b1) = ((2 * i + 1) as u32, (2 * (i + 1) + 1) as u32);
mesh.triangles.push([t0, t1, b1]);
mesh.triangles.push([t0, b1, b0]);
}
Rope { phys, skel, mesh, bones, image }
}
#[derive(Clone)]
enum Msg {
Tick,
Cursor(Option<(f64, f64)>),
}
struct Model {
ropes: Vec<Rope>,
cursor: Option<Point>,
}
struct Demo;
const TICK: Duration = Duration::from_millis(16);
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · física sobre esqueletos"
}
fn initial_size() -> (u32, u32) {
(700, 440)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
let palette = [
((90, 180, 230), (40, 90, 140)),
((235, 130, 100), (150, 60, 50)),
((130, 220, 150), (50, 120, 70)),
((220, 180, 90), (140, 100, 40)),
((200, 130, 220), (110, 60, 140)),
];
let ropes = palette
.iter()
.enumerate()
.map(|(i, (a, b))| {
let x = 110.0 + i as f64 * 120.0;
make_rope(Point::new(x, 40.0), checker(*a, *b))
})
.collect();
handle.spawn_periodic(TICK, || Msg::Tick);
Model { ropes, cursor: None }
}
fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::Tick => {
let dt = TICK.as_secs_f64();
let cursor = model.cursor;
for rope in &mut model.ropes {
rope.phys.step(dt, 8);
if let Some(c) = cursor {
rope.phys.repel(c, 90.0, 28.0);
}
let pts = rope.phys.positions();
pose_chain_from_points(&mut rope.skel, &rope.bones, &pts);
}
}
Msg::Cursor(p) => model.cursor = p.map(|(x, y)| Point::new(x, y)),
}
model
}
fn view(model: &Self::Model) -> View<Self::Msg> {
// Deformar todas las mallas ahora; mover los datos al closure.
let painted: Vec<(Mesh, Vec<Point>, ImageBrush)> = model
.ropes
.iter()
.map(|r| (r.mesh.clone(), r.mesh.deform(&r.skel), r.image.clone()))
.collect();
View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(Color::from_rgba8(16, 18, 26, 255))
.paint_with(move |scene, _ts, rect| {
let to_screen = Affine::translate((rect.x as f64, rect.y as f64));
// Piso.
let floor = PaintRect {
x: rect.x,
y: rect.y + FLOOR_Y as f32,
w: rect.w,
h: (rect.h - FLOOR_Y as f32).max(0.0),
};
let mut fp = llimphi_ui::llimphi_raster::kurbo::BezPath::new();
fp.move_to((floor.x as f64, floor.y as f64));
fp.line_to((floor.x as f64 + floor.w as f64, floor.y as f64));
fp.line_to((floor.x as f64 + floor.w as f64, floor.y as f64 + floor.h as f64));
fp.line_to((floor.x as f64, floor.y as f64 + floor.h as f64));
fp.close_path();
scene.fill(
llimphi_ui::llimphi_raster::peniko::Fill::NonZero,
Affine::IDENTITY,
&llimphi_ui::llimphi_raster::peniko::Brush::Solid(Color::from_rgba8(26, 28, 38, 255)),
None,
&fp,
);
for (mesh, positions, image) in &painted {
paint_textured(scene, mesh, positions, to_screen, image);
}
})
.on_pointer_move_at(|lx, ly, _w, _h| Some(Msg::Cursor(Some((lx as f64, ly as f64)))))
.on_pointer_leave(Msg::Cursor(None))
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
+297
View File
@@ -0,0 +1,297 @@
//! `llimphi-mesh` — render de mallas deformables a una `vello::Scene`.
//!
//! La matemática de huesos y skinning vive en [`llimphi_anim::skel`]; este crate
//! sólo **pinta** la malla ya deformada. Recibe la malla ([`Mesh`]) + las
//! posiciones deformadas de sus vértices (lo que devuelve [`Mesh::deform`]) y un
//! `xform` model→pantalla, y ofrece las dos rutas que el spike validó contra
//! vello 0.7:
//!
//! - [`paint_solid`] — rellena cada triángulo deformado con un color (malla
//! vectorial / debug). Trivial.
//! - [`paint_textured`] — malla **texturizada**: por triángulo, recorta al
//! triángulo deformado y dibuja la imagen con el afín que mapea sus UV a la
//! posición deformada (warp piecewise-affine). Costo = un clip-layer por
//! triángulo; cuidado con mallas de miles de triángulos.
//! - [`paint_wireframe`] — traza los bordes de los triángulos (ver la
//! deformación; ideal para demos/debug).
//!
//! Helpers de encuadre: [`rest_bounds`] (bbox de la malla en reposo) +
//! [`fit_transform`] (afín que encaja esos bounds, centrados, en un `PaintRect`).
#![forbid(unsafe_code)]
use llimphi_anim::skel::Mesh;
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Point, Rect, Stroke};
use llimphi_ui::llimphi_raster::peniko::{Brush, Color, Fill, ImageBrush};
use llimphi_ui::llimphi_raster::vello::Scene;
use llimphi_ui::PaintRect;
/// Afín que mapea el triángulo `src` al triángulo `dst` (3 correspondencias).
/// Es la base del warp piecewise-affine de la malla texturizada. Devuelve la
/// identidad si `src` es degenerado (área nula).
pub fn affine_from_tri(src: [Point; 3], dst: [Point; 3]) -> Affine {
let e1 = (src[1].x - src[0].x, src[1].y - src[0].y);
let e2 = (src[2].x - src[0].x, src[2].y - src[0].y);
let f1 = (dst[1].x - dst[0].x, dst[1].y - dst[0].y);
let f2 = (dst[2].x - dst[0].x, dst[2].y - dst[0].y);
let det = e1.0 * e2.1 - e2.0 * e1.1;
if det.abs() < 1e-9 {
return Affine::IDENTITY;
}
let inv = 1.0 / det;
let l00 = (f1.0 * e2.1 - f2.0 * e1.1) * inv;
let l01 = (-f1.0 * e2.0 + f2.0 * e1.0) * inv;
let l10 = (f1.1 * e2.1 - f2.1 * e1.1) * inv;
let l11 = (-f1.1 * e2.0 + f2.1 * e1.0) * inv;
let tx = dst[0].x - (l00 * src[0].x + l01 * src[0].y);
let ty = dst[0].y - (l10 * src[0].x + l11 * src[0].y);
Affine::new([l00, l10, l01, l11, tx, ty])
}
/// Bounding box de las posiciones de **reposo** de la malla. Útil para
/// `fit_transform`. Vacío (`Rect::ZERO`) si la malla no tiene vértices.
pub fn rest_bounds(mesh: &Mesh) -> Rect {
let mut it = mesh.vertices.iter();
let Some(first) = it.next() else {
return Rect::ZERO;
};
let (mut x0, mut y0, mut x1, mut y1) = (first.rest.x, first.rest.y, first.rest.x, first.rest.y);
for v in it {
x0 = x0.min(v.rest.x);
y0 = y0.min(v.rest.y);
x1 = x1.max(v.rest.x);
y1 = y1.max(v.rest.y);
}
Rect::new(x0, y0, x1, y1)
}
/// Afín que encaja `bounds` (espacio de la malla) dentro de `rect` (pantalla),
/// escalando uniforme al mínimo lado y centrando (preserva aspecto).
pub fn fit_transform(bounds: Rect, rect: PaintRect) -> Affine {
let bw = bounds.width();
let bh = bounds.height();
if bw <= 0.0 || bh <= 0.0 || rect.w <= 0.0 || rect.h <= 0.0 {
return Affine::IDENTITY;
}
let s = (rect.w as f64 / bw).min(rect.h as f64 / bh);
let used_w = bw * s;
let used_h = bh * s;
let tx = rect.x as f64 + (rect.w as f64 - used_w) * 0.5 - bounds.x0 * s;
let ty = rect.y as f64 + (rect.h as f64 - used_h) * 0.5 - bounds.y0 * s;
Affine::translate((tx, ty)) * Affine::scale(s)
}
/// Posiciones deformadas (model space) → pantalla, en un buffer reusable.
fn to_screen(positions: &[Point], xform: Affine, out: &mut Vec<Point>) {
out.clear();
out.extend(positions.iter().map(|p| xform * *p));
}
fn tri_path(a: Point, b: Point, c: Point) -> BezPath {
let mut p = BezPath::new();
p.move_to(a);
p.line_to(b);
p.line_to(c);
p.close_path();
p
}
/// Rellena cada triángulo deformado con un color sólido. `positions` son las
/// posiciones deformadas (de `Mesh::deform`), `xform` las lleva a pantalla.
pub fn paint_solid(
scene: &mut Scene,
mesh: &Mesh,
positions: &[Point],
xform: Affine,
color: Color,
) {
let mut screen = Vec::new();
to_screen(positions, xform, &mut screen);
let brush = Brush::Solid(color);
for t in &mesh.triangles {
let (Some(&a), Some(&b), Some(&c)) = (
screen.get(t[0] as usize),
screen.get(t[1] as usize),
screen.get(t[2] as usize),
) else {
continue;
};
scene.fill(Fill::NonZero, Affine::IDENTITY, &brush, None, &tri_path(a, b, c));
}
}
/// Malla texturizada: por triángulo, recorta al triángulo deformado y dibuja la
/// imagen con el afín que mapea las UV (espacio de imagen) a la posición
/// deformada en pantalla — warp piecewise-affine. `image` es la textura; las UV
/// de cada vértice (`0..1`) indexan en ella.
pub fn paint_textured(
scene: &mut Scene,
mesh: &Mesh,
positions: &[Point],
xform: Affine,
image: &ImageBrush,
) {
let mut screen = Vec::new();
to_screen(positions, xform, &mut screen);
let iw = image.image.width as f64;
let ih = image.image.height as f64;
for t in &mesh.triangles {
let idx = [t[0] as usize, t[1] as usize, t[2] as usize];
let (Some(&da), Some(&db), Some(&dc)) =
(screen.get(idx[0]), screen.get(idx[1]), screen.get(idx[2]))
else {
continue;
};
// src = UV·tamaño-de-imagen (espacio de imagen); dst = pantalla deformada.
let uv = |i: usize| {
let v = &mesh.vertices[idx[i]];
Point::new(v.uv.0 * iw, v.uv.1 * ih)
};
let aff = affine_from_tri([uv(0), uv(1), uv(2)], [da, db, dc]);
scene.push_clip_layer(Fill::NonZero, Affine::IDENTITY, &tri_path(da, db, dc));
scene.draw_image(image.as_ref(), aff);
scene.pop_layer();
}
}
/// Traza los bordes de los triángulos (para ver la deformación / debug).
pub fn paint_wireframe(
scene: &mut Scene,
mesh: &Mesh,
positions: &[Point],
xform: Affine,
color: Color,
width: f64,
) {
let mut screen = Vec::new();
to_screen(positions, xform, &mut screen);
let brush = Brush::Solid(color);
let stroke = Stroke::new(width);
for t in &mesh.triangles {
let (Some(&a), Some(&b), Some(&c)) = (
screen.get(t[0] as usize),
screen.get(t[1] as usize),
screen.get(t[2] as usize),
) else {
continue;
};
scene.stroke(&stroke, Affine::IDENTITY, &brush, None, &tri_path(a, b, c));
}
}
#[cfg(test)]
mod tests {
use super::*;
use llimphi_anim::skel::{Mesh, Pose, Skeleton, Vertex};
use llimphi_ui::llimphi_raster::peniko::{Blob, ImageAlphaType, ImageData, ImageFormat};
use std::sync::Arc;
/// Una malla quad (2 triángulos) atada a un hueso, con UVs de esquina.
fn quad_mesh(bone: usize) -> Mesh {
let mut m = Mesh::new();
m.vertices = vec![
Vertex::rigid(Point::new(0.0, 0.0), (0.0, 0.0), bone),
Vertex::rigid(Point::new(100.0, 0.0), (1.0, 0.0), bone),
Vertex::rigid(Point::new(100.0, 100.0), (1.0, 1.0), bone),
Vertex::rigid(Point::new(0.0, 100.0), (0.0, 1.0), bone),
];
m.triangles = vec![[0, 1, 2], [0, 2, 3]];
m
}
fn skel_one_bone() -> (Skeleton, usize) {
let mut s = Skeleton::new();
let b = s.add_bone(None, Pose::identity());
s.bind();
s.update();
(s, b)
}
fn img_2x2() -> ImageBrush {
let px: Vec<u8> = vec![
200, 60, 60, 255, 60, 200, 60, 255, 60, 60, 200, 255, 220, 200, 60, 255,
];
ImageBrush::new(ImageData {
data: Blob::new(Arc::new(px)),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width: 2,
height: 2,
})
}
const RECT: PaintRect = PaintRect {
x: 0.0,
y: 0.0,
w: 200.0,
h: 200.0,
};
#[test]
fn fit_transform_centra_y_escala() {
let m = quad_mesh(0);
let xf = fit_transform(rest_bounds(&m), RECT);
// El quad 100×100 en un rect 200×200 escala ×2 y centra (sin offset, ya
// que llena el lado). Esquina (0,0) → (0,0), (100,100) → (200,200).
let p0 = xf * Point::new(0.0, 0.0);
let p1 = xf * Point::new(100.0, 100.0);
assert!((p0.x).abs() < 1e-9 && (p0.y).abs() < 1e-9, "{p0:?}");
assert!((p1.x - 200.0).abs() < 1e-9 && (p1.y - 200.0).abs() < 1e-9, "{p1:?}");
}
#[test]
fn paint_solid_emite_geometria() {
let (s, b) = skel_one_bone();
let m = quad_mesh(b);
let pos = m.deform(&s);
let xf = fit_transform(rest_bounds(&m), RECT);
let mut sc = Scene::new();
paint_solid(&mut sc, &m, &pos, xf, Color::from_rgba8(200, 100, 50, 255));
assert!(!sc.encoding().is_empty());
}
#[test]
fn paint_textured_emite_geometria() {
let (s, b) = skel_one_bone();
let m = quad_mesh(b);
let pos = m.deform(&s);
let xf = fit_transform(rest_bounds(&m), RECT);
let img = img_2x2();
let mut sc = Scene::new();
paint_textured(&mut sc, &m, &pos, xf, &img);
assert!(!sc.encoding().is_empty(), "la malla texturizada debe emitir geometría");
}
#[test]
fn paint_wireframe_emite_geometria() {
let (s, b) = skel_one_bone();
let m = quad_mesh(b);
let pos = m.deform(&s);
let xf = fit_transform(rest_bounds(&m), RECT);
let mut sc = Scene::new();
paint_wireframe(&mut sc, &m, &pos, xf, Color::from_rgba8(255, 255, 255, 255), 1.5);
assert!(!sc.encoding().is_empty());
}
/// Deformar un hueso mueve la geometría pintada: las posiciones deformadas
/// cambian respecto al reposo (certifica que el render usa la deformación).
#[test]
fn deformar_cambia_las_posiciones() {
let mut s = Skeleton::new();
let b = s.add_bone(None, Pose::identity());
s.bind();
s.update();
let m = quad_mesh(b);
let reposo = m.deform(&s);
s.set_pose(b, Pose::rotate(0.5));
s.update();
let deformado = m.deform(&s);
let movido = reposo
.iter()
.zip(&deformado)
.any(|(a, d)| (a.x - d.x).abs() > 1e-6 || (a.y - d.y).abs() > 1e-6);
assert!(movido, "rotar el hueso debe mover los vértices");
}
}
+1 -1
View File
@@ -5,9 +5,9 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "llimphi-motion — Tween<T> + helpers de animación integrados al bucle Elm de llimphi-ui (Handle::spawn_periodic). Lerp para f32, Color, (f32,f32). Easings comparten convenciones de llimphi-theme::motion."
repository.workspace = true
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+49
View File
@@ -0,0 +1,49 @@
# llimphi-motion
*Read this in English: [README.md](README.md).*
Animaciones simples sobre el bucle Elm de Llimphi.
Llimphi es Elm puro: `update(msg) -> model`. Para animar un valor en
el tiempo (un alpha que sube de 0 a 1, una posición que se desliza)
la app guarda un `Tween` en su modelo y pide al `Handle` que le
dispatchee un `Msg::Tick` periódicamente (cada ~16 ms) hasta que la
animación termine. Cada `update` lee `tween.value()` y la `view` la
pinta.
Esta crate es deliberadamente chiquita:
- `Lerp` — interpolación lineal genérica (impls para `f32`,
`(f32, f32)` y `Color`).
- `Tween` — interpolación temporizada con easing entre dos valores.
- `animate` — helper que arranca un loop de ticks autosuficiente
sobre un `Handle`.
Las duraciones y easings canónicos viven en `llimphi_theme::motion`.
## Patrón típico
```ignore
use llimphi_motion::{Tween, animate};
use llimphi_theme::motion;
enum Msg { ToastShow, Tick, ToastHidden }
struct Model { toast_alpha: Tween<f32> }
// update:
Msg::ToastShow => {
model.toast_alpha = Tween::new(0.0, 1.0, motion::NORMAL, motion::ease_out_cubic);
animate(handle, motion::NORMAL, || Msg::Tick);
model
}
Msg::Tick => {
// El loop interno terminará solo cuando el tween esté done;
// la `view` ya lee el alpha actual sin más.
model
}
// view:
toast_view().alpha(model.toast_alpha.value())
```
---
Parte de **llimphi** — ver [llimphi](../LEEME.md).
+47
View File
@@ -0,0 +1,47 @@
# llimphi-motion
Simple animation over Llimphi's Elm loop.
Llimphi is pure Elm: `update(msg) -> model`. To animate a value over time (an
alpha rising from 0 to 1, a position sliding) the app keeps a `Tween` in its
model and asks the `Handle` to dispatch a `Msg::Tick` periodically (every ~16 ms)
until the animation ends. Each `update` reads `tween.value()` and the `view`
paints it.
This crate is deliberately tiny:
- `Lerp` — generic linear interpolation (impls for `f32`, `(f32, f32)` and
`Color`).
- `Tween` — timed interpolation with easing between two values.
- `animate` — a helper that starts a self-sufficient tick loop over a `Handle`.
The canonical durations and easings live in `llimphi_theme::motion`.
## Typical pattern
```ignore
use llimphi_motion::{Tween, animate};
use llimphi_theme::motion;
enum Msg { ToastShow, Tick, ToastHidden }
struct Model { toast_alpha: Tween<f32> }
// update:
Msg::ToastShow => {
model.toast_alpha = Tween::new(0.0, 1.0, motion::NORMAL, motion::ease_out_cubic);
animate(handle, motion::NORMAL, || Msg::Tick);
model
}
Msg::Tick => {
// The internal loop ends by itself once the tween is done;
// the `view` already reads the current alpha.
model
}
// view:
toast_view().alpha(model.toast_alpha.value())
```
---
Part of **llimphi** — see [llimphi](../README.md).
+2 -2
View File
@@ -1,13 +1,13 @@
[package]
name = "llimphi-raster"
description = "2D GPU rasterizer for llimphi over vello, with an opt-in CPU+GPU hybrid renderer."
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "2D GPU rasterizer for llimphi over vello, with an opt-in CPU+GPU hybrid renderer."
repository.workspace = true
[dependencies]
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
vello = { workspace = true }
+86 -51
View File
@@ -7,10 +7,13 @@
//!
//! Diseño minimal Fase 2/3:
//!
//! - Vertex format triángulos: `[x: f32, y: f32, rgba: u32]` (12 B/vert).
//! - Instance format líneas: `[x0, y0, x1, y1, rgba]` (20 B/seg).
//! - Instance format rects: `[x, y, w, h, rgba]` (20 B/rect).
//! - Instance format discos: `[cx, cy, r, stroke, rgba]` (20 B/disco).
//! - Vertex format triángulos: `[x, y: f32, color: 4×f32]` (24 B/vert).
//! - Instance format líneas: `[x0, y0, x1, y1, color: 4×f32]` (32 B/seg).
//! - Instance format rects: `[x, y, w, h, color: 4×f32]` (32 B/rect).
//! - Instance format discos: `[cx, cy, r, stroke, color: 4×f32]` (32 B/disco).
//! - El color va como **4×f32** (no u32 empaquetado): preserva precisión HDR
//! (>1.0) y sub-8-bit cuando el target es `Rgba16Float`/`Rgb10a2`; en target
//! 8-bit el resultado visible es idéntico al pack anterior.
//! - Sin texturas. Rects/líneas/tris obtienen AA de **bordes** vía MSAA 4×
//! (ver más abajo); los discos SÍ traen AA por SDF en el fragment
//! (smoothstep sobre `fwidth`), que MSAA respeta. Así rects/tris/líneas
@@ -66,10 +69,10 @@ const MSAA_SAMPLES: u32 = 4;
/// particles iniciales, viewport estático) y emitir draw calls
/// manualmente reusando estas pipelines.
///
/// Layouts:
/// - Vertex buffer triángulos: `[x: f32, y: f32, rgba: u32]` (12 B/vert).
/// - Instance buffer rects: `[x, y, w, h, rgba]` (20 B/inst).
/// - Instance buffer líneas: `[x0, y0, x1, y1, rgba]` (20 B/inst).
/// Layouts (color = 4×f32, HDR-capable):
/// - Vertex buffer triángulos: `[x, y, color:4×f32]` (24 B/vert).
/// - Instance buffer rects: `[x, y, w, h, color:4×f32]` (32 B/inst).
/// - Instance buffer líneas: `[x0, y0, x1, y1, color:4×f32]`(32 B/inst).
/// - Bind group 0 binding 0: uniform `{viewport: vec2<f32>, line_width: f32, _pad: f32}` (16 B).
pub struct GpuPipelines {
pub lines: wgpu::RenderPipeline,
@@ -137,7 +140,7 @@ impl GpuPipelines {
entry_point: Some("vs_tris"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: 12,
array_stride: 24,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
@@ -146,7 +149,7 @@ impl GpuPipelines {
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Uint32,
format: wgpu::VertexFormat::Float32x4,
offset: 8,
shader_location: 1,
},
@@ -178,7 +181,7 @@ impl GpuPipelines {
entry_point: Some("vs_rects"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: 20,
array_stride: 32,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
wgpu::VertexAttribute {
@@ -192,7 +195,7 @@ impl GpuPipelines {
shader_location: 1,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Uint32,
format: wgpu::VertexFormat::Float32x4,
offset: 16,
shader_location: 2,
},
@@ -226,7 +229,7 @@ impl GpuPipelines {
entry_point: Some("vs_lines"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: 20,
array_stride: 32,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
wgpu::VertexAttribute {
@@ -235,7 +238,7 @@ impl GpuPipelines {
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Uint32,
format: wgpu::VertexFormat::Float32x4,
offset: 16,
shader_location: 1,
},
@@ -272,7 +275,7 @@ impl GpuPipelines {
entry_point: Some("vs_discs"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: 20,
array_stride: 32,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
// cx, cy
@@ -287,9 +290,9 @@ impl GpuPipelines {
offset: 8,
shader_location: 1,
},
// rgba
// rgba (4×f32, HDR-capable)
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Uint32,
format: wgpu::VertexFormat::Float32x4,
offset: 16,
shader_location: 2,
},
@@ -422,6 +425,11 @@ pub struct GpuBatch<'a> {
tri_vert_count: u32,
rect_count: u32,
disc_count: u32,
/// Rect de recorte (x, y, w, h) en px del target. `None` = pinta todo el
/// viewport (comportamiento histórico). Cuando está seteado, ambas pasadas
/// (primitivos y composite) se restringen a ese rect → sólo se tocan los
/// píxeles sucios: es el habilitador de **damage / partial-present**.
scissor: Option<(u32, u32, u32, u32)>,
}
impl<'a> GpuBatch<'a> {
@@ -437,9 +445,21 @@ impl<'a> GpuBatch<'a> {
tri_vert_count: 0,
rect_count: 0,
disc_count: 0,
scissor: None,
}
}
/// Restringe el `flush` a un rect (x, y, w, h) en px del target: sólo se
/// rasterizan y componen los píxeles dentro del rect, el resto del `view`
/// queda intacto. Es el primitivo de **damage / partial-present**: cuando
/// sólo cambió una región chica (un cursor, un spinner, un nodo animado), el
/// caller setea el rect sucio y evita re-pintar la pantalla entera. El rect
/// se clampea al viewport en `flush` (un rect fuera de rango no revienta).
/// Sin llamar a esto, `flush` pinta todo el viewport (histórico).
pub fn scissor(&mut self, x: u32, y: u32, w: u32, h: u32) {
self.scissor = Some((x, y, w, h));
}
/// Grosor de las próximas líneas (en pixels del frame, sin AA).
/// Se aplica a todas las líneas del batch — el lado bueno de una
/// sola draw call es que sólo hay un grosor "vivo" por flush.
@@ -449,12 +469,11 @@ impl<'a> GpuBatch<'a> {
/// Añade un segmento de línea como instancia.
pub fn add_line(&mut self, p0: (f32, f32), p1: (f32, f32), color: Color) {
let rgba = pack_rgba(color);
self.line_verts.extend_from_slice(&p0.0.to_ne_bytes());
self.line_verts.extend_from_slice(&p0.1.to_ne_bytes());
self.line_verts.extend_from_slice(&p1.0.to_ne_bytes());
self.line_verts.extend_from_slice(&p1.1.to_ne_bytes());
self.line_verts.extend_from_slice(&rgba.to_ne_bytes());
push_color(&mut self.line_verts, color);
self.line_count += 1;
}
@@ -485,10 +504,9 @@ impl<'a> GpuBatch<'a> {
}
fn push_tri_vert(&mut self, p: (f32, f32), color: Color) {
let rgba = pack_rgba(color);
self.tri_verts.extend_from_slice(&p.0.to_ne_bytes());
self.tri_verts.extend_from_slice(&p.1.to_ne_bytes());
self.tri_verts.extend_from_slice(&rgba.to_ne_bytes());
push_color(&mut self.tri_verts, color);
self.tri_vert_count += 1;
}
@@ -504,12 +522,11 @@ impl<'a> GpuBatch<'a> {
/// Añade un rectángulo lleno como instancia (sin radio — para
/// rounded rects sigue por vello).
pub fn add_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Color) {
let rgba = pack_rgba(color);
self.rect_insts.extend_from_slice(&x.to_ne_bytes());
self.rect_insts.extend_from_slice(&y.to_ne_bytes());
self.rect_insts.extend_from_slice(&w.to_ne_bytes());
self.rect_insts.extend_from_slice(&h.to_ne_bytes());
self.rect_insts.extend_from_slice(&rgba.to_ne_bytes());
push_color(&mut self.rect_insts, color);
self.rect_count += 1;
}
@@ -532,12 +549,11 @@ impl<'a> GpuBatch<'a> {
}
fn push_disc(&mut self, cx: f32, cy: f32, r: f32, stroke: f32, color: Color) {
let rgba = pack_rgba(color);
self.disc_insts.extend_from_slice(&cx.to_ne_bytes());
self.disc_insts.extend_from_slice(&cy.to_ne_bytes());
self.disc_insts.extend_from_slice(&r.to_ne_bytes());
self.disc_insts.extend_from_slice(&stroke.to_ne_bytes());
self.disc_insts.extend_from_slice(&rgba.to_ne_bytes());
push_color(&mut self.disc_insts, color);
self.disc_count += 1;
}
@@ -638,9 +654,24 @@ impl<'a> GpuBatch<'a> {
// Texturas por-flush dimensionadas al viewport (mismo patrón que
// los buffers de arriba; el resize "sale gratis"). `tex_w/h` se
// clampean a ≥1 para evitar Extent3d de 0 (un viewport degenerado
// no debería llegar a, pero defensivo).
// no debería llegar aquí, pero defensivo).
let tex_w = (viewport.0.round() as u32).max(1);
let tex_h = (viewport.1.round() as u32).max(1);
// Rect de damage clampeado al viewport. Si se pidió scissor pero clampea
// a área 0 (región sucia fuera de rango), no hay nada que pintar → salir.
let scissor_px: Option<(u32, u32, u32, u32)> = match self.scissor {
None => None,
Some((x, y, w, h)) => {
let x = x.min(tex_w);
let y = y.min(tex_h);
let w = w.min(tex_w.saturating_sub(x));
let h = h.min(tex_h.saturating_sub(y));
if w == 0 || h == 0 {
return;
}
Some((x, y, w, h))
}
};
let extent = wgpu::Extent3d {
width: tex_w,
height: tex_h,
@@ -696,6 +727,9 @@ impl<'a> GpuBatch<'a> {
occlusion_query_set: None,
});
pass.set_bind_group(0, &bind_group, &[]);
if let Some((x, y, w, h)) = scissor_px {
pass.set_scissor_rect(x, y, w, h);
}
// Orden de draws: rects (fondo) → discos → tris → lines (encima).
// Match de la convención usual "fill abajo, stroke arriba".
@@ -763,16 +797,25 @@ impl<'a> GpuBatch<'a> {
});
cpass.set_pipeline(&self.pipelines.composite);
cpass.set_bind_group(0, &composite_bg, &[]);
if let Some((x, y, w, h)) = scissor_px {
cpass.set_scissor_rect(x, y, w, h);
}
cpass.draw(0..3, 0..1);
}
}
/// Empaqueta un `peniko::Color` a u32 little-endian RGBA8.
/// El shader lo lee como `inst.rgba` y separa bytes — debe coincidir
/// con la convención del WGSL (`r = rgba & 0xFF`, etc.).
fn pack_rgba(c: Color) -> u32 {
let [r, g, b, a] = c.to_rgba8().to_u8_array();
(r as u32) | ((g as u32) << 8) | ((b as u32) << 16) | ((a as u32) << 24)
/// Empuja un `peniko::Color` como 4×`f32` (RGBA lineal-del-color, 16 B) al
/// buffer de vértices/instancias. Se lee `Color::components` **directo** — a
/// diferencia de `to_rgba8()`, NO cuantiza a 8-bit ni clampea a [0,1], así que
/// un color HDR (componente >1.0) o un paso fino entre dos niveles 8-bit
/// sobrevive hasta el target. Cuando el target es 8-bit (`Rgba8Unorm`, el caso
/// normal) el resultado visible es idéntico al del pack u32 anterior; la
/// precisión extra sólo aparece con un target `Rgba16Float`/`Rgb10a2`. El WGSL
/// lo recibe como `vec4<f32>` directo (sin `unpack_rgba`).
fn push_color(buf: &mut Vec<u8>, c: Color) {
for comp in c.components {
buf.extend_from_slice(&comp.to_ne_bytes());
}
}
const WGSL: &str = r#"
@@ -789,36 +832,28 @@ struct V2F {
@location(0) color: vec4<f32>,
};
fn unpack_rgba(c: u32) -> vec4<f32> {
let r = f32( c & 0xFFu) / 255.0;
let g = f32((c >> 8u) & 0xFFu) / 255.0;
let b = f32((c >> 16u) & 0xFFu) / 255.0;
let a = f32((c >> 24u) & 0xFFu) / 255.0;
return vec4<f32>(r, g, b, a);
}
fn px_to_ndc(p: vec2<f32>) -> vec2<f32> {
return vec2<f32>(p.x / u.viewport.x * 2.0 - 1.0, 1.0 - p.y / u.viewport.y * 2.0);
}
// -------- triángulos: 1 vértice = (xy, rgba) --------
// -------- triángulos: 1 vértice = (xy, color) --------
@vertex
fn vs_tris(@location(0) xy: vec2<f32>, @location(1) rgba: u32) -> V2F {
fn vs_tris(@location(0) xy: vec2<f32>, @location(1) color: vec4<f32>) -> V2F {
var out: V2F;
out.pos = vec4<f32>(px_to_ndc(xy), 0.0, 1.0);
out.color = unpack_rgba(rgba);
out.color = color;
return out;
}
// -------- rects: 1 instancia = (xy, wh, rgba), 6 vértices/quad --------
// -------- rects: 1 instancia = (xy, wh, color), 6 vértices/quad --------
@vertex
fn vs_rects(
@builtin(vertex_index) vid: u32,
@location(0) inst_xy: vec2<f32>,
@location(1) inst_wh: vec2<f32>,
@location(2) inst_rgba: u32,
@location(2) color: vec4<f32>,
) -> V2F {
var corners = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0),
@@ -832,17 +867,17 @@ fn vs_rects(
let px = inst_xy + local * inst_wh;
var out: V2F;
out.pos = vec4<f32>(px_to_ndc(px), 0.0, 1.0);
out.color = unpack_rgba(inst_rgba);
out.color = color;
return out;
}
// -------- líneas: 1 instancia = (p0xy, p1xy, rgba), expandida a quad ----
// -------- líneas: 1 instancia = (p0xy, p1xy, color), expandida a quad ----
@vertex
fn vs_lines(
@builtin(vertex_index) vid: u32,
@location(0) seg: vec4<f32>,
@location(1) rgba: u32,
@location(1) color: vec4<f32>,
) -> V2F {
// Quad perpendicular al segmento, grosor uniforme `u.line_width` px.
// vid 0..5 mapea a los 6 vértices del quad (2 tris).
@@ -865,7 +900,7 @@ fn vs_lines(
let px = along + across;
var out: V2F;
out.pos = vec4<f32>(px_to_ndc(px), 0.0, 1.0);
out.color = unpack_rgba(rgba);
out.color = color;
return out;
}
@@ -888,7 +923,7 @@ fn vs_discs(
@builtin(vertex_index) vid: u32,
@location(0) inst_c: vec2<f32>,
@location(1) inst_rs: vec2<f32>,
@location(2) inst_rgba: u32,
@location(2) color: vec4<f32>,
) -> DiscV2F {
var corners = array<vec2<f32>, 6>(
vec2<f32>(-1.0, -1.0),
@@ -904,7 +939,7 @@ fn vs_discs(
let px = inst_c + local;
var out: DiscV2F;
out.pos = vec4<f32>(px_to_ndc(px), 0.0, 1.0);
out.color = unpack_rgba(inst_rgba);
out.color = color;
out.local = local;
out.params = inst_rs;
return out;
+170
View File
@@ -0,0 +1,170 @@
//! Prueba de que el backend GPU-directo carga **precisión de color que vello no
//! puede** (vello 0.7 exige target `Rgba8Unorm`; sólo emite 8-bit). Con el color
//! del vértice ensanchado a 4×f32 y un target `Rgba16Float`, el path directo
//! preserva:
//!
//! 1. valores HDR (componente >1.0) que 8-bit **clampea** a 1.0, y
//! 2. pasos de color **más finos que un nivel 8-bit** que 8-bit **cuantiza** al
//! mismo byte.
//!
//! Es la certificación headless (§8, por texto: `assert` numérico sobre el
//! readback) del deliverable "GPU-directo desbloquea HDR" de PLAN-MOTOR-PROFUNDO.
//! Corre en llvmpipe. El look HDR en pantalla sigue siendo [METAL].
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::gpu::{GpuBatch, GpuPipelines};
use llimphi_raster::peniko::Color;
const N: u32 = 8; // target chico; el rect cubre todo
/// Decodifica un half-float (f16, IEEE 754 binary16) a f32.
fn f16_to_f32(h: u16) -> f32 {
let sign = if (h >> 15) & 1 == 1 { -1.0 } else { 1.0 };
let exp = (h >> 10) & 0x1f;
let frac = h & 0x3ff;
if exp == 0 {
sign * (frac as f32) * 2f32.powi(-24) // subnormal
} else if exp == 0x1f {
if frac == 0 { sign * f32::INFINITY } else { f32::NAN }
} else {
sign * (1.0 + frac as f32 / 1024.0) * 2f32.powi(exp as i32 - 15)
}
}
/// Rasteriza un rect que cubre todo el target de formato `fmt` con `color` y
/// devuelve los bytes crudos del pixel central (fila con padding quitado).
fn solid_center_bytes(
hal: &Hal,
fmt: wgpu::TextureFormat,
bytes_per_pixel: usize,
color: Color,
) -> Vec<u8> {
let pipelines = GpuPipelines::new(&hal.device, fmt);
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("hdr-target"),
size: wgpu::Extent3d { width: N, height: N, 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 view = target.create_view(&wgpu::TextureViewDescriptor::default());
let mut batch = GpuBatch::new(&pipelines);
batch.add_rect(0.0, 0.0, N as f32, N as f32, color);
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
batch.flush(
&hal.device,
&hal.queue,
&mut enc,
&view,
(N as f32, N as f32),
// Clear a negro OPACO: en el pixel cubierto el composite (premult
// over) deja el color del rect intacto; en los no cubiertos, negro.
wgpu::LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }),
);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
// Readback con padding de fila.
let unpadded = N as usize * bytes_per_pixel;
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("hdr-readback"),
size: (padded * N 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(N),
},
},
wgpu::Extent3d { width: N, height: N, 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();
// Pixel central (4,4).
let row = 4usize;
let col = 4usize;
let start = row * padded + col * bytes_per_pixel;
let px = data[start..start + bytes_per_pixel].to_vec();
drop(data);
buf.unmap();
px
}
/// Lee el canal R de un pixel `Rgba16Float` (2 B/canal).
fn r_channel_f16(px: &[u8]) -> f32 {
f16_to_f32(u16::from_le_bytes([px[0], px[1]]))
}
#[test]
fn valor_hdr_mayor_que_uno_sobrevive_en_float_target() {
let hal = pollster::block_on(Hal::new_headless(true)).expect("hal");
// R = 4.0 (HDR). En Rgba8Unorm esto clampea a 1.0; en float debe sobrevivir.
let color = Color::new([4.0, 0.0, 0.0, 1.0]);
let px = solid_center_bytes(&hal, wgpu::TextureFormat::Rgba16Float, 8, color);
let r = r_channel_f16(&px);
assert!(
r > 3.9,
"el valor HDR 4.0 no sobrevivió al target float: R leído = {r} \
(si fuera 8-bit estaría clampeado a 1.0)"
);
}
#[test]
fn paso_sub_8bit_se_pierde_en_8bit_pero_sobrevive_en_float() {
let hal = pollster::block_on(Hal::new_headless(true)).expect("hal");
// Dos valores dentro del MISMO nivel de 8-bit: base 100 (lejos de cualquier
// borde .5 de redondeo) + 0.4/255. Ambos redondean a 100 en 8-bit, pero
// distan ~0.0016 en lineal → f16 (paso ~0.0005 cerca de 0.4) los distingue.
let v1 = 100.0 / 255.0;
let v2 = v1 + 0.4 / 255.0;
let c1 = Color::new([v1, 0.0, 0.0, 1.0]);
let c2 = Color::new([v2, 0.0, 0.0, 1.0]);
// En 8-bit: ambos cuantizan al MISMO byte (indistinguibles).
let r1_8 = solid_center_bytes(&hal, wgpu::TextureFormat::Rgba8Unorm, 4, c1)[0];
let r2_8 = solid_center_bytes(&hal, wgpu::TextureFormat::Rgba8Unorm, 4, c2)[0];
assert_eq!(
r1_8, r2_8,
"premisa: en 8-bit los dos valores deben cuantizar al mismo byte \
(fueron {r1_8} y {r2_8})"
);
// En float: el path directo los preserva distintos.
let r1_f = r_channel_f16(&solid_center_bytes(&hal, wgpu::TextureFormat::Rgba16Float, 8, c1));
let r2_f = r_channel_f16(&solid_center_bytes(&hal, wgpu::TextureFormat::Rgba16Float, 8, c2));
assert!(
(r1_f - r2_f).abs() > 0.0007,
"el target float debería distinguir el paso sub-8-bit: R1={r1_f} R2={r2_f} \
(Δ={:.5}); en 8-bit Δ fue exactamente 0",
(r1_f - r2_f).abs()
);
}
+134
View File
@@ -0,0 +1,134 @@
//! Correctitud del `scissor` del GPU-directo (habilitador de damage /
//! partial-present): un `flush` con `scissor(x,y,w,h)` + `LoadOp::Load` sólo
//! pinta dentro del rect y **preserva** el resto del `view`. Es la propiedad que
//! el runtime necesita para re-pintar únicamente la región sucia (un cursor, un
//! spinner) sin tocar la pantalla entera. Certificado por texto (§8), llvmpipe.
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::gpu::{GpuBatch, GpuPipelines};
use llimphi_raster::peniko::Color;
const N: u32 = 16;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn read_all(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
let unpadded = N 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("scissor-readback"),
size: (padded * N 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(N),
},
},
wgpu::Extent3d { width: N, height: N, 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((N * N * 4) as usize);
for row in 0..N as usize {
let s = row * padded;
out.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
out
}
fn px(buf: &[u8], x: u32, y: u32) -> [u8; 4] {
let o = ((y * N + x) * 4) as usize;
[buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]
}
#[test]
fn scissor_pinta_solo_la_region_sucia_y_preserva_el_resto() {
let hal = pollster::block_on(Hal::new_headless(true)).expect("hal");
let pipelines = GpuPipelines::new(&hal.device, FMT);
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("scissor-target"),
size: wgpu::Extent3d { width: N, height: N, 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 view = target.create_view(&wgpu::TextureViewDescriptor::default());
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
// 1) Pintar TODO de azul (estado "previo" del view).
let mut base = GpuBatch::new(&pipelines);
base.add_rect(0.0, 0.0, N as f32, N as f32, Color::from_rgba8(0, 0, 255, 255));
base.flush(
&hal.device,
&hal.queue,
&mut enc,
&view,
(N as f32, N as f32),
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
);
// 2) Un rect rojo full-viewport PERO con scissor a [4,4]-[12,12] y Load:
// debe pintar rojo sólo ahí y dejar el azul intacto afuera.
let mut dirty = GpuBatch::new(&pipelines);
dirty.add_rect(0.0, 0.0, N as f32, N as f32, Color::from_rgba8(255, 0, 0, 255));
dirty.scissor(4, 4, 8, 8);
dirty.flush(
&hal.device,
&hal.queue,
&mut enc,
&view,
(N as f32, N as f32),
wgpu::LoadOp::Load,
);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
let buf = read_all(&hal, &target);
// Centro (8,8): dentro del scissor → rojo.
let c = px(&buf, 8, 8);
assert!(c[0] > 200 && c[2] < 60, "centro debería ser rojo, fue {c:?}");
// Esquinas fuera del scissor → azul preservado.
for (x, y) in [(1, 1), (14, 1), (1, 14), (14, 14)] {
let p = px(&buf, x, y);
assert!(
p[2] > 200 && p[0] < 60,
"({x},{y}) fuera del scissor debería seguir azul, fue {p:?}"
);
}
// Justo fuera del borde del scissor (3,3) azul; justo dentro (4,4) rojo.
let out = px(&buf, 3, 3);
assert!(out[2] > 200 && out[0] < 60, "(3,3) borde-fuera debería ser azul, fue {out:?}");
let inn = px(&buf, 5, 5);
assert!(inn[0] > 200 && inn[2] < 60, "(5,5) borde-dentro debería ser rojo, fue {inn:?}");
}
+2 -2
View File
@@ -1,13 +1,13 @@
[package]
name = "llimphi-surface"
description = "Surface/compositing glue for the llimphi UI framework."
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "Surface/compositing glue for the llimphi UI framework."
repository.workspace = true
[dependencies]
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
llimphi-ui = { path = "../llimphi-ui", version = "0.1.0" }
+40
View File
@@ -0,0 +1,40 @@
# llimphi-surface
*Read this in English: [README.md](README.md).*
Superficies externas dentro del bucle Elm.
Un `ExternalSurface` es una textura RGBA8 que vive en GPU y se pinta
sobre un rect del frame Llimphi cada vez que la app lo expone vía
`View::gpu_paint_with`. La fuente de bytes corre afuera del bucle
Elm: un decoder de video, un capture de cámara, un raster de PDF,
una textura raw producida por otro motor — cualquier productor que
genere RGBA puede empujar frames con `ExternalSurface::upload` y
ver el resultado en la próxima pasada de raster.
El crate provee:
- `ExternalSurface`: dueño de la textura + render pipeline + bind
group. `upload(rgba, w, h)` sube bytes y recrea la textura si
`w`/`h` cambiaron.
- `ExternalSurface::view`: helper que construye un `View` con
`gpu_paint_with` ya conectado. La app sólo elige el `Style` del
nodo (qué porción del layout ocupa).
## Diseño
El pipeline es un textured-quad clásico: dos triángulos cubren el
rect destino, el fragment shader samplea la textura externa con
sampler bilineal. Las coordenadas NDC del quad se computan en GPU
a partir de `(rect, viewport)` que viajan por uniform — por eso
el callback necesita el `viewport` que `llimphi-ui` empezó a
propagar en `GpuPaintFn`.
La textura intermedia donde Llimphi pinta vello es `Rgba8Unorm`
(ver `llimphi-hal::INTERMEDIATE_FORMAT`). El pipeline emite
`Rgba8Unorm` también — el target del render pass es esa misma
intermedia con `LoadOp::Load`, así el fondo vello queda preservado.
---
Parte de **llimphi** — ver [llimphi](../LEEME.md).
+35
View File
@@ -0,0 +1,35 @@
# llimphi-surface
External surfaces inside the Elm loop.
An `ExternalSurface` is an RGBA8 texture living on the GPU, painted over a rect
of the Llimphi frame every time the app exposes it through
`View::gpu_paint_with`. The byte source runs outside the Elm loop: a video
decoder, a camera capture, a PDF raster, a raw texture produced by another engine
— any producer generating RGBA can push frames with `ExternalSurface::upload` and
see the result on the next raster pass.
The crate provides:
- `ExternalSurface`: owner of the texture + render pipeline + bind group.
`upload(rgba, w, h)` uploads bytes and recreates the texture if `w`/`h` changed.
- `ExternalSurface::view`: a helper that builds a `View` with `gpu_paint_with`
already wired. The app only chooses the node's `Style` (which portion of the
layout it occupies).
## Design
The pipeline is a classic textured quad: two triangles cover the destination
rect, and the fragment shader samples the external texture with a bilinear
sampler. The quad's NDC coordinates are computed on the GPU from
`(rect, viewport)` travelling through a uniform — which is why the callback needs
the `viewport` that `llimphi-ui` began propagating in `GpuPaintFn`.
The intermediate texture Llimphi paints vello into is `Rgba8Unorm` (see
`llimphi-hal::INTERMEDIATE_FORMAT`). The pipeline emits `Rgba8Unorm` too — the
render pass target is that same intermediate with `LoadOp::Load`, so the vello
background is preserved.
---
Part of **llimphi** — see [llimphi](../README.md).
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-svg"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-svg — puente fino vello_svg → Llimphi. Carga .svg arbitrario una vez (SvgAsset) y lo stampea en cualquier View vía paint_with, escalado al rect."
[dependencies]
llimphi-ui = { workspace = true }
vello_svg = { version = "0.9", default-features = false }
+44
View File
@@ -0,0 +1,44 @@
# llimphi-svg
*Read this in English: [README.md](README.md).*
Puente fino entre `vello_svg` y Llimphi.
`llimphi-icons` cubre el set canónico de ~50 íconos a mano (BezPath en
grid 24×24). Para lo demás — íconos de apps `.desktop` arbitrarias,
logotipos, assets de marca, exports vectoriales de pineal/cosmos — hace
falta cargar SVG real. Este crate es el puente: parsea una vez con
`vello_svg` y stampea la `vello::Scene` resultante en cualquier `View`
escalándola al rect del nodo.
## Uso
```ignore
use llimphi_svg::SvgAsset;
// Parsea UNA vez (al cargar la app o el ícono):
let svg = SvgAsset::from_str(include_str!("logo.svg")).expect("logo válido");
// Pintalo en un View tantas veces como quieras (escala al rect):
View::new(style).children(vec![svg.view::<Msg>()])
```
El parse cuesta — el `view()` no. Si vas a stampear el mismo SVG en muchos
nodos (lista de apps con el mismo ícono fallback), parsea una sola vez y
clonea el `SvgAsset` (es barato: `Arc` internamente).
## Por qué no parsear en cada paint
`vello_svg::render` corre el parser de `usvg` (~ms por SVG no trivial). En
una lista con 80 íconos `.desktop`, parsear en cada frame mata el thread
de UI. La regla: **el asset se parsea una vez**, la `Scene` resultante se
retiene en memoria y se stampea con `scene.append(&inner, Some(xf))`
cuesta lo mismo que dibujar el resto del UI.
## Errores
`SvgAsset::from_str` devuelve `Result<Self, SvgError>` — si el XML está
corrupto o usa features que `usvg` no soporta. Las apps típicas tratan el
error como "fallback a glyph genérico" — no rompen.
---
Parte de **llimphi** — ver [llimphi](../LEEME.md).
+43
View File
@@ -0,0 +1,43 @@
# llimphi-svg
A thin bridge between `vello_svg` and Llimphi.
`llimphi-icons` covers the canonical set of ~50 hand-made icons (a `BezPath` on a
24×24 grid). For everything else — arbitrary `.desktop` app icons, logos, brand
assets, vector exports from pineal/cosmos — you need to load real SVG. This crate
is the bridge: it parses once with `vello_svg` and stamps the resulting
`vello::Scene` into any `View`, scaling it to the node's rect.
## Use
```ignore
use llimphi_svg::SvgAsset;
// Parse ONCE (when loading the app or the icon):
let svg = SvgAsset::from_str(include_str!("logo.svg")).expect("valid logo");
// Paint it in a View as many times as you like (it scales to the rect):
View::new(style).children(vec![svg.view::<Msg>()])
```
The parse costs something — `view()` does not. If you are going to stamp the same
SVG in many nodes (an app list sharing a fallback icon), parse once and clone the
`SvgAsset` (it is cheap: `Arc` internally).
## Why not parse on every paint
`vello_svg::render` runs `usvg`'s parser (~ms for a non-trivial SVG). In a list
of 80 `.desktop` icons, parsing every frame kills the UI thread. The rule: **the
asset is parsed once**, the resulting `Scene` is retained in memory and stamped
with `scene.append(&inner, Some(xf))` — which costs the same as drawing the rest
of the UI.
## Errors
`SvgAsset::from_str` returns `Result<Self, SvgError>` — for corrupt XML or
features `usvg` does not support. Typical apps treat the error as "fall back to a
generic glyph" — they do not break.
---
Part of **llimphi** — see [llimphi](../README.md).

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