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:
Generated
+1382
-338
File diff suppressed because it is too large
Load Diff
+279
-74
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 & in &s {
|
||||
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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
//! Acá 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;
|
||||
|
||||
@@ -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 acá.
|
||||
//! la capa de contenido (la app), no aquí.
|
||||
|
||||
use glam::Vec3;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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. Acá 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, &[]);
|
||||
|
||||
@@ -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. Acá:
|
||||
//! 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
@@ -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×). Acá 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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,2–0,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:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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).
|
||||
@@ -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 (acá 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();
|
||||
}
|
||||
@@ -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`); acá 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
@@ -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 acá: 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 (acá 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 acá — 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());
|
||||
|
||||
@@ -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 acá. 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.
|
||||
|
||||
+2249
-31
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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 acá 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)]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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),
|
||||
)]),
|
||||
);
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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 acá.");
|
||||
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();
|
||||
|
||||
|
||||
@@ -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
@@ -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 acá 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 acá — 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());
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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…), acá 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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
@@ -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
@@ -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]]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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
@@ -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 acá, 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;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
@@ -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
Reference in New Issue
Block a user