diff --git a/Cargo.lock b/Cargo.lock
index f58b6a5..84b8678 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -18,6 +18,114 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
+[[package]]
+name = "accesskit"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
+dependencies = [
+ "uuid",
+]
+
+[[package]]
+name = "accesskit_atspi_common"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e98018dbef3583d751dbb96e07b8728fb99581360e1c3df408af16f4a80b821"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "atspi-common",
+ "phf",
+ "serde",
+ "zvariant",
+]
+
+[[package]]
+name = "accesskit_consumer"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb"
+dependencies = [
+ "accesskit",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "accesskit_ios"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02ecb52198c7cf5f8d3e9ffc03d2ca0a5c7201926befd96721437829da4c5c6a"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "hashbrown 0.16.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit",
+]
+
+[[package]]
+name = "accesskit_macos"
+version = "0.26.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17cb8b66cef272d48161b02a6317cc2bdd5f98bb0a5e79c68f704a5862aa396b"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "hashbrown 0.16.1",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "accesskit_unix"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5376ba4cc23312587634abb5250b1ce8618f01a55915608209aafd01efb4bf8c"
+dependencies = [
+ "accesskit",
+ "accesskit_atspi_common",
+ "async-channel",
+ "async-executor",
+ "async-task",
+ "atspi",
+ "futures-lite",
+ "futures-util",
+ "serde",
+ "zbus",
+]
+
+[[package]]
+name = "accesskit_windows"
+version = "0.33.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "hashbrown 0.16.1",
+ "static_assertions",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
+]
+
+[[package]]
+name = "accesskit_winit"
+version = "0.33.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe5862066316f6caaf02cd3aecd54bced25503ac5dbbfd0d03a42bc1246217"
+dependencies = [
+ "accesskit",
+ "accesskit_ios",
+ "accesskit_macos",
+ "accesskit_unix",
+ "accesskit_windows",
+ "raw-window-handle",
+ "winit",
+]
+
[[package]]
name = "adler2"
version = "2.0.1"
@@ -31,7 +139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
- "getrandom",
+ "getrandom 0.3.4",
"once_cell",
"version_check",
"zerocopy",
@@ -66,7 +174,7 @@ dependencies = [
"log",
"ndk",
"ndk-context",
- "ndk-sys 0.6.0+11769913",
+ "ndk-sys",
"num_enum",
"thiserror 2.0.18",
]
@@ -86,6 +194,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "app-bus"
+version = "0.1.0"
+dependencies = [
+ "directories",
+ "serde",
+ "serde_json",
+ "toml",
+]
+
[[package]]
name = "arboard"
version = "3.6.1"
@@ -133,25 +251,217 @@ dependencies = [
"libloading",
]
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix 1.1.4",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix 1.1.4",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix 1.1.4",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "atomic-polyfill"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
+dependencies = [
+ "critical-section",
+]
+
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+[[package]]
+name = "atspi"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d"
+dependencies = [
+ "atspi-common",
+ "atspi-proxies",
+]
+
+[[package]]
+name = "atspi-common"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d"
+dependencies = [
+ "enumflags2",
+ "serde",
+ "static_assertions",
+ "zbus",
+ "zbus-lockstep",
+ "zbus-lockstep-macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "atspi-proxies"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc"
+dependencies = [
+ "atspi-common",
+ "serde",
+ "zbus",
+]
+
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
- "bit-vec",
+ "bit-vec 0.8.0",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd"
+dependencies = [
+ "bit-vec 0.9.1",
]
[[package]]
@@ -160,6 +470,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+[[package]]
+name = "bit-vec"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -175,6 +491,20 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "blake3"
+version = "1.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+ "cpufeatures",
+]
+
[[package]]
name = "block"
version = "0.1.6"
@@ -190,6 +520,28 @@ dependencies = [
"objc2 0.5.2",
]
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -216,6 +568,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -278,6 +636,19 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link",
+]
+
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -288,11 +659,32 @@ dependencies = [
]
[[package]]
-name = "codespan-reporting"
-version = "0.11.1"
+name = "cobs"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
+checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "codespan-reporting"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
+dependencies = [
+ "serde",
+ "termcolor",
+ "unicode-width",
+]
+
+[[package]]
+name = "codespan-reporting"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
+dependencies = [
+ "serde",
"termcolor",
"unicode-width",
]
@@ -302,6 +694,9 @@ name = "color"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf"
+dependencies = [
+ "bytemuck",
+]
[[package]]
name = "combine"
@@ -322,6 +717,12 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -332,6 +733,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -345,8 +756,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
- "core-foundation",
- "core-graphics-types",
+ "core-foundation 0.9.4",
+ "core-graphics-types 0.1.3",
"foreign-types",
"libc",
]
@@ -358,7 +769,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
- "core-foundation",
+ "core-foundation 0.9.4",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
+dependencies = [
+ "bitflags 2.12.1",
+ "core-foundation 0.10.1",
+ "libc",
+]
+
+[[package]]
+name = "core_maths"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
"libc",
]
@@ -371,6 +811,12 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -389,6 +835,27 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
+[[package]]
+name = "directories"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.48.0",
+]
+
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -446,6 +913,45 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+[[package]]
+name = "embedded-io"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
+
+[[package]]
+name = "embedded-io"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -477,6 +983,33 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
[[package]]
name = "fax"
version = "0.2.7"
@@ -492,6 +1025,12 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "fearless_simd"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
+
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -508,15 +1047,6 @@ dependencies = [
"miniz_oxide",
]
-[[package]]
-name = "fluent-uri"
-version = "0.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d"
-dependencies = [
- "bitflags 1.3.2",
-]
-
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -524,10 +1054,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
-name = "font-types"
-version = "0.9.0"
+name = "foldhash"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "font-types"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5"
dependencies = [
"bytemuck",
]
@@ -542,36 +1078,36 @@ dependencies = [
]
[[package]]
-name = "fontconfig-cache-parser"
-version = "0.2.0"
+name = "fontdue"
+version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18"
+checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b"
dependencies = [
- "bytemuck",
- "thiserror 1.0.69",
+ "hashbrown 0.15.5",
+ "ttf-parser 0.21.1",
]
[[package]]
name = "fontique"
-version = "0.4.0"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "64763d1f274c8383333851435b6cdf071c31cfcdb39fd5860d20943205a007a7"
+checksum = "ff3336bc0b87fe42305047263fa60d2eabd650d29cbe62fdeb2a66c7a0a595f9"
dependencies = [
"bytemuck",
- "fontconfig-cache-parser",
"hashbrown 0.15.5",
- "icu_locid",
+ "icu_locale_core",
+ "linebender_resource_handle",
"memmap2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
- "peniko",
- "read-fonts 0.29.3",
+ "read-fonts 0.35.0",
"roxmltree",
"smallvec",
- "windows",
- "windows-core",
+ "windows 0.58.0",
+ "windows-core 0.58.0",
+ "yeslogic-fontconfig-sys",
]
[[package]]
@@ -601,6 +1137,10 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+[[package]]
+name = "foreign-vox"
+version = "0.1.0"
+
[[package]]
name = "futures-core"
version = "0.3.32"
@@ -618,6 +1158,36 @@ dependencies = [
"parking_lot",
]
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "futures-task"
version = "0.3.32"
@@ -631,6 +1201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
+ "futures-macro",
"futures-task",
"pin-project-lite",
"slab",
@@ -646,6 +1217,17 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -654,10 +1236,21 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
- "r-efi",
+ "r-efi 5.3.0",
"wasip2",
]
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
[[package]]
name = "gl_generator"
version = "0.14.0"
@@ -669,6 +1262,28 @@ dependencies = [
"xml-rs",
]
+[[package]]
+name = "glam"
+version = "0.30.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
+
+[[package]]
+name = "glifo"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
+dependencies = [
+ "bytemuck",
+ "foldhash 0.2.0",
+ "hashbrown 0.17.1",
+ "log",
+ "peniko",
+ "skrifa 0.42.1",
+ "smallvec",
+ "vello_common",
+]
+
[[package]]
name = "glow"
version = "0.16.0"
@@ -681,6 +1296,18 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "glow"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
[[package]]
name = "glutin_wgl_sys"
version = "0.6.1"
@@ -718,7 +1345,21 @@ dependencies = [
"log",
"presser",
"thiserror 1.0.69",
- "windows",
+ "windows 0.58.0",
+]
+
+[[package]]
+name = "gpu-allocator"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795"
+dependencies = [
+ "ash",
+ "hashbrown 0.16.1",
+ "log",
+ "presser",
+ "thiserror 2.0.18",
+ "windows 0.58.0",
]
[[package]]
@@ -757,6 +1398,15 @@ dependencies = [
"svg_fmt",
]
+[[package]]
+name = "guillotiere"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab"
+dependencies = [
+ "euclid",
+]
+
[[package]]
name = "half"
version = "2.7.1"
@@ -765,9 +1415,32 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
+ "num-traits",
"zerocopy",
]
+[[package]]
+name = "harfrust"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8"
+dependencies = [
+ "bitflags 2.12.1",
+ "bytemuck",
+ "core_maths",
+ "read-fonts 0.35.0",
+ "smallvec",
+]
+
+[[package]]
+name = "hash32"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
+dependencies = [
+ "byteorder",
+]
+
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -776,7 +1449,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
- "foldhash",
+ "foldhash 0.1.5",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
]
[[package]]
@@ -784,12 +1468,23 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "foldhash 0.2.0",
+]
[[package]]
-name = "heck"
-version = "0.5.0"
+name = "heapless"
+version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
+dependencies = [
+ "atomic-polyfill",
+ "hash32",
+ "rustc_version",
+ "serde",
+ "spin",
+ "stable_deref_trait",
+]
[[package]]
name = "hermit-abi"
@@ -797,6 +1492,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
[[package]]
name = "hexf-parse"
version = "0.2.1"
@@ -804,13 +1505,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
-name = "icu_locid"
-version = "1.5.0"
+name = "iana-time-zone"
+version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core 0.58.0",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
+ "serde",
"tinystr",
"writeable",
]
@@ -909,7 +1635,7 @@ version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
- "getrandom",
+ "getrandom 0.3.4",
"libc",
]
@@ -944,12 +1670,13 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kurbo"
-version = "0.11.3"
+version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
+checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
dependencies = [
"arrayvec",
"euclid",
+ "polycool",
"smallvec",
]
@@ -969,6 +1696,12 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
[[package]]
name = "libredox"
version = "0.1.17"
@@ -1001,9 +1734,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
-version = "0.7.5"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
@@ -1011,6 +1744,19 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+[[package]]
+name = "llimphi-3d"
+version = "0.1.0"
+dependencies = [
+ "glam",
+ "llimphi-hal",
+ "llimphi-raster",
+ "llimphi-ui",
+ "png 0.18.1",
+ "pollster",
+ "wgpu 27.0.1",
+]
+
[[package]]
name = "llimphi-clipboard"
version = "0.1.0"
@@ -1023,10 +1769,20 @@ dependencies = [
name = "llimphi-compositor"
version = "0.1.0"
dependencies = [
+ "llimphi-hal",
"llimphi-layout",
+ "llimphi-raster",
"llimphi-text",
+ "llimphi-theme",
+ "llimphi-widget-button",
+ "llimphi-widget-progress",
+ "llimphi-widget-segmented",
+ "llimphi-widget-slider",
+ "llimphi-widget-switch",
+ "png 0.18.1",
+ "pollster",
"vello",
- "wgpu",
+ "wgpu 27.0.1",
]
[[package]]
@@ -1035,7 +1791,7 @@ version = "0.1.0"
dependencies = [
"pollster",
"raw-window-handle",
- "wgpu",
+ "wgpu 27.0.1",
"winit",
]
@@ -1114,10 +1870,6 @@ dependencies = [
[[package]]
name = "llimphi-module-selector"
version = "0.1.0"
-dependencies = [
- "llimphi-theme",
- "llimphi-ui",
-]
[[package]]
name = "llimphi-module-symbol-outline"
@@ -1144,6 +1896,7 @@ dependencies = [
"llimphi-hal",
"pollster",
"vello",
+ "vello_hybrid",
]
[[package]]
@@ -1177,12 +1930,32 @@ dependencies = [
name = "llimphi-ui"
version = "0.1.0"
dependencies = [
+ "accesskit",
+ "accesskit_winit",
+ "arboard",
"llimphi-compositor",
"llimphi-hal",
"llimphi-layout",
"llimphi-raster",
"llimphi-text",
"pollster",
+ "uuid",
+]
+
+[[package]]
+name = "llimphi-voxel"
+version = "0.1.0"
+dependencies = [
+ "blake3",
+ "foreign-vox",
+ "llimphi-3d",
+ "llimphi-hal",
+ "llimphi-raster",
+ "png 0.18.1",
+ "pollster",
+ "postcard",
+ "ron",
+ "serde",
]
[[package]]
@@ -1198,7 +1971,6 @@ dependencies = [
name = "llimphi-widget-avatar"
version = "0.1.0"
dependencies = [
- "llimphi-theme",
"llimphi-ui",
]
@@ -1206,7 +1978,6 @@ dependencies = [
name = "llimphi-widget-badge"
version = "0.1.0"
dependencies = [
- "llimphi-theme",
"llimphi-ui",
]
@@ -1234,6 +2005,15 @@ dependencies = [
"llimphi-ui",
]
+[[package]]
+name = "llimphi-widget-calendar"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
[[package]]
name = "llimphi-widget-card"
version = "0.1.0"
@@ -1243,6 +2023,32 @@ dependencies = [
"llimphi-widget-panel",
]
+[[package]]
+name = "llimphi-widget-carousel"
+version = "0.1.0"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
+[[package]]
+name = "llimphi-widget-chip"
+version = "0.1.0"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
+[[package]]
+name = "llimphi-widget-color-picker"
+version = "0.1.0"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+ "llimphi-widget-slider",
+ "llimphi-widget-text-input",
+]
+
[[package]]
name = "llimphi-widget-context-menu"
version = "0.1.0"
@@ -1252,6 +2058,14 @@ dependencies = [
"llimphi-widget-panel",
]
+[[package]]
+name = "llimphi-widget-detail-table"
+version = "0.1.0"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
[[package]]
name = "llimphi-widget-dock-rail"
version = "0.1.0"
@@ -1279,6 +2093,14 @@ dependencies = [
"llimphi-ui",
]
+[[package]]
+name = "llimphi-widget-fab"
+version = "0.1.0"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
[[package]]
name = "llimphi-widget-field"
version = "0.1.0"
@@ -1287,6 +2109,21 @@ dependencies = [
"llimphi-ui",
]
+[[package]]
+name = "llimphi-widget-fitted-box"
+version = "0.1.0"
+dependencies = [
+ "llimphi-ui",
+]
+
+[[package]]
+name = "llimphi-widget-gauge"
+version = "0.1.0"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
[[package]]
name = "llimphi-widget-grid"
version = "0.1.0"
@@ -1295,6 +2132,14 @@ dependencies = [
"llimphi-ui",
]
+[[package]]
+name = "llimphi-widget-hero"
+version = "0.1.0"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
[[package]]
name = "llimphi-widget-list"
version = "0.1.0"
@@ -1303,6 +2148,17 @@ dependencies = [
"llimphi-ui",
]
+[[package]]
+name = "llimphi-widget-menubar"
+version = "0.1.0"
+dependencies = [
+ "app-bus",
+ "llimphi-theme",
+ "llimphi-ui",
+ "llimphi-widget-button",
+ "llimphi-widget-context-menu",
+]
+
[[package]]
name = "llimphi-widget-modal"
version = "0.1.0"
@@ -1355,6 +2211,30 @@ dependencies = [
"llimphi-ui",
]
+[[package]]
+name = "llimphi-widget-range-slider"
+version = "0.1.0"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
+[[package]]
+name = "llimphi-widget-rating"
+version = "0.1.0"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
+[[package]]
+name = "llimphi-widget-scaffold"
+version = "0.1.0"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
[[package]]
name = "llimphi-widget-scroll"
version = "0.1.0"
@@ -1371,6 +2251,16 @@ dependencies = [
"llimphi-ui",
]
+[[package]]
+name = "llimphi-widget-select"
+version = "0.1.0"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+ "llimphi-widget-badge",
+ "llimphi-widget-panel",
+]
+
[[package]]
name = "llimphi-widget-shortcuts-help"
version = "0.1.0"
@@ -1400,7 +2290,6 @@ dependencies = [
name = "llimphi-widget-spinner"
version = "0.1.0"
dependencies = [
- "llimphi-theme",
"llimphi-ui",
]
@@ -1448,6 +2337,15 @@ dependencies = [
"llimphi-ui",
]
+[[package]]
+name = "llimphi-widget-table"
+version = "0.1.0"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+ "llimphi-widget-text-input",
+]
+
[[package]]
name = "llimphi-widget-tabs"
version = "0.1.0"
@@ -1457,6 +2355,20 @@ dependencies = [
"llimphi-widget-panel",
]
+[[package]]
+name = "llimphi-widget-terminal"
+version = "0.1.0"
+dependencies = [
+ "fontdue",
+ "llimphi-hal",
+ "llimphi-theme",
+ "llimphi-ui",
+ "llimphi-widget-scroll",
+ "png 0.18.1",
+ "pollster",
+ "tempfile",
+]
+
[[package]]
name = "llimphi-widget-text-area"
version = "0.1.0"
@@ -1491,8 +2403,6 @@ name = "llimphi-widget-text-editor-lsp"
version = "0.1.0"
dependencies = [
"llimphi-widget-text-editor",
- "lsp-types",
- "serde",
"serde_json",
"tokio",
]
@@ -1539,6 +2449,15 @@ dependencies = [
"llimphi-ui",
]
+[[package]]
+name = "llimphi-widget-toolbar"
+version = "0.1.0"
+dependencies = [
+ "llimphi-icons",
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
[[package]]
name = "llimphi-widget-tooltip"
version = "0.1.0"
@@ -1547,6 +2466,15 @@ dependencies = [
"llimphi-ui",
]
+[[package]]
+name = "llimphi-widget-transport"
+version = "0.1.0"
+dependencies = [
+ "llimphi-icons",
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
[[package]]
name = "llimphi-widget-tree"
version = "0.1.0"
@@ -1556,13 +2484,27 @@ dependencies = [
]
[[package]]
-name = "llimphi-widget-wawa-mark"
+name = "llimphi-widget-waveform"
version = "0.1.0"
dependencies = [
"llimphi-theme",
"llimphi-ui",
]
+[[package]]
+name = "llimphi-widget-wawa-mark"
+version = "0.1.0"
+dependencies = [
+ "llimphi-ui",
+]
+
+[[package]]
+name = "llimphi-widget-wrap"
+version = "0.1.0"
+dependencies = [
+ "llimphi-ui",
+]
+
[[package]]
name = "llimphi-workspace"
version = "0.1.0"
@@ -1587,19 +2529,6 @@ version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
-[[package]]
-name = "lsp-types"
-version = "0.97.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
-dependencies = [
- "bitflags 1.3.2",
- "fluent-uri",
- "serde",
- "serde_json",
- "serde_repr",
-]
-
[[package]]
name = "malloc_buf"
version = "0.0.6"
@@ -1625,14 +2554,23 @@ dependencies = [
]
[[package]]
-name = "metal"
-version = "0.31.0"
+name = "memoffset"
+version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "metal"
+version = "0.32.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605"
dependencies = [
"bitflags 2.12.1",
"block",
- "core-graphics-types",
+ "core-graphics-types 0.2.0",
"foreign-types",
"log",
"objc",
@@ -1672,24 +2610,54 @@ dependencies = [
[[package]]
name = "naga"
-version = "24.0.0"
+version = "27.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e"
+checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8"
dependencies = [
"arrayvec",
- "bit-set",
+ "bit-set 0.8.0",
"bitflags 2.12.1",
+ "cfg-if",
"cfg_aliases",
- "codespan-reporting",
+ "codespan-reporting 0.12.0",
+ "half",
+ "hashbrown 0.16.1",
"hexf-parse",
"indexmap",
+ "libm",
"log",
+ "num-traits",
+ "once_cell",
"rustc-hash",
- "spirv",
- "strum",
- "termcolor",
+ "spirv 0.3.0+sdk-1.3.268.0",
"thiserror 2.0.18",
- "unicode-xid",
+ "unicode-ident",
+]
+
+[[package]]
+name = "naga"
+version = "29.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dd91265cc2454558f659b3b4b9640f0ddb8cc6521277f166b8a8c181c898079"
+dependencies = [
+ "arrayvec",
+ "bit-set 0.9.1",
+ "bitflags 2.12.1",
+ "cfg-if",
+ "cfg_aliases",
+ "codespan-reporting 0.13.1",
+ "half",
+ "hashbrown 0.16.1",
+ "hexf-parse",
+ "indexmap",
+ "libm",
+ "log",
+ "num-traits",
+ "once_cell",
+ "rustc-hash",
+ "spirv 0.4.0+sdk-1.4.341.0",
+ "thiserror 2.0.18",
+ "unicode-ident",
]
[[package]]
@@ -1701,7 +2669,7 @@ dependencies = [
"bitflags 2.12.1",
"jni-sys 0.3.1",
"log",
- "ndk-sys 0.6.0+11769913",
+ "ndk-sys",
"num_enum",
"raw-window-handle",
"thiserror 1.0.69",
@@ -1713,15 +2681,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
-[[package]]
-name = "ndk-sys"
-version = "0.5.0+25.2.9519653"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
-dependencies = [
- "jni-sys 0.3.1",
-]
-
[[package]]
name = "ndk-sys"
version = "0.6.0+11769913"
@@ -1748,6 +2707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
+ "libm",
]
[[package]]
@@ -1813,13 +2773,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [
"bitflags 2.12.1",
- "block2",
+ "block2 0.5.1",
"libc",
"objc2 0.5.2",
"objc2-core-data",
"objc2-core-image",
"objc2-foundation 0.2.2",
- "objc2-quartz-core",
+ "objc2-quartz-core 0.2.2",
]
[[package]]
@@ -1841,7 +2801,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [
"bitflags 2.12.1",
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-core-location",
"objc2-foundation 0.2.2",
@@ -1853,7 +2813,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
dependencies = [
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -1865,7 +2825,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
"bitflags 2.12.1",
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -1900,10 +2860,10 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
- "objc2-metal",
+ "objc2-metal 0.2.2",
]
[[package]]
@@ -1912,7 +2872,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
dependencies = [
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-contacts",
"objc2-foundation 0.2.2",
@@ -1941,7 +2901,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
"bitflags 2.12.1",
- "block2",
+ "block2 0.5.1",
"dispatch",
"libc",
"objc2 0.5.2",
@@ -1975,7 +2935,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -1988,11 +2948,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
"bitflags 2.12.1",
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
+[[package]]
+name = "objc2-metal"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
+dependencies = [
+ "bitflags 2.12.1",
+ "block2 0.6.2",
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+]
+
[[package]]
name = "objc2-quartz-core"
version = "0.2.2"
@@ -2000,10 +2972,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
"bitflags 2.12.1",
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
- "objc2-metal",
+ "objc2-metal 0.2.2",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.12.1",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
]
[[package]]
@@ -2023,7 +3008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [
"bitflags 2.12.1",
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-cloud-kit",
"objc2-core-data",
@@ -2031,7 +3016,7 @@ dependencies = [
"objc2-core-location",
"objc2-foundation 0.2.2",
"objc2-link-presentation",
- "objc2-quartz-core",
+ "objc2-quartz-core 0.2.2",
"objc2-symbols",
"objc2-uniform-type-identifiers",
"objc2-user-notifications",
@@ -2043,7 +3028,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
dependencies = [
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2055,7 +3040,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [
"bitflags 2.12.1",
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-core-location",
"objc2-foundation 0.2.2",
@@ -2067,6 +3052,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -2086,15 +3077,31 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
dependencies = [
- "ttf-parser",
+ "ttf-parser 0.25.1",
]
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2120,14 +3127,15 @@ dependencies = [
[[package]]
name = "parley"
-version = "0.4.0"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e28dadbe655332fd7d996794ec8d0c376695f6ca47bc75aa01e0967c7f28e42a"
+checksum = "26746861bb76dbc9bcd5ed1b0b55d2fedf291100961251702a031ab2abd2ce52"
dependencies = [
"fontique",
+ "harfrust",
"hashbrown 0.15.5",
- "peniko",
- "skrifa 0.31.3",
+ "linebender_resource_handle",
+ "skrifa 0.37.0",
"swash",
]
@@ -2139,10 +3147,11 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "peniko"
-version = "0.4.1"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b44f9ddd2f480176b34278eb653ec1c8062f3b143a4e16eeff5ffac3334e288"
+checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6"
dependencies = [
+ "bytemuck",
"color",
"kurbo",
"linebender_resource_handle",
@@ -2155,6 +3164,49 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -2181,6 +3233,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
[[package]]
name = "pkg-config"
version = "0.3.33"
@@ -2239,6 +3302,43 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+[[package]]
+name = "polycool"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
+dependencies = [
+ "arrayvec",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "postcard"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
+dependencies = [
+ "cobs",
+ "embedded-io 0.4.0",
+ "embedded-io 0.6.1",
+ "heapless",
+ "serde",
+]
+
[[package]]
name = "presser"
version = "0.3.1"
@@ -2251,7 +3351,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
- "toml_edit",
+ "toml_edit 0.25.12+spec-1.1.0",
]
[[package]]
@@ -2288,6 +3388,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
+ "serde",
]
[[package]]
@@ -2305,6 +3406,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -2318,23 +3425,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
-name = "read-fonts"
-version = "0.29.3"
+name = "raw-window-metal"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04ca636dac446b5664bd16c069c00a9621806895b8bb02c2dc68542b23b8f25d"
+checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
dependencies = [
- "bytemuck",
- "font-types 0.9.0",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "objc2-quartz-core 0.3.2",
]
[[package]]
name = "read-fonts"
-version = "0.33.1"
+version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50ea612a55c08586a1d15134be8a776186c440c312ebda3b9e8efbfe4255b7f4"
+checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358"
dependencies = [
"bytemuck",
- "font-types 0.9.0",
+ "core_maths",
+ "font-types 0.10.1",
]
[[package]]
@@ -2347,6 +3457,16 @@ dependencies = [
"font-types 0.11.3",
]
+[[package]]
+name = "read-fonts"
+version = "0.39.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81"
+dependencies = [
+ "bytemuck",
+ "font-types 0.11.3",
+]
+
[[package]]
name = "redox_syscall"
version = "0.4.1"
@@ -2374,6 +3494,17 @@ dependencies = [
"bitflags 2.12.1",
]
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 1.0.69",
+]
+
[[package]]
name = "regex"
version = "1.12.3"
@@ -2409,6 +3540,18 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+[[package]]
+name = "ron"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
+dependencies = [
+ "base64",
+ "bitflags 2.12.1",
+ "serde",
+ "serde_derive",
+]
+
[[package]]
name = "ropey"
version = "1.6.1"
@@ -2566,6 +3709,15 @@ dependencies = [
"syn",
]
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "shlex"
version = "2.0.1"
@@ -2611,23 +3763,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
-name = "skrifa"
-version = "0.31.3"
+name = "siphasher"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dbeb4ca4399663735553a09dd17ce7e49a0a0203f03b706b39628c4d913a8607"
-dependencies = [
- "bytemuck",
- "read-fonts 0.29.3",
-]
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "skrifa"
-version = "0.35.0"
+version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "576e60c7de4bb6a803a0312f9bef17e78cf1e8d25a80e1ade76770d7a0237955"
+checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841"
dependencies = [
"bytemuck",
- "read-fonts 0.33.1",
+ "read-fonts 0.35.0",
]
[[package]]
@@ -2640,6 +3788,16 @@ dependencies = [
"read-fonts 0.37.0",
]
+[[package]]
+name = "skrifa"
+version = "0.42.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564"
+dependencies = [
+ "bytemuck",
+ "read-fonts 0.39.2",
+]
+
[[package]]
name = "slab"
version = "0.4.12"
@@ -2705,6 +3863,15 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
+]
+
[[package]]
name = "spirv"
version = "0.3.0+sdk-1.3.268.0"
@@ -2714,6 +3881,21 @@ dependencies = [
"bitflags 2.12.1",
]
+[[package]]
+name = "spirv"
+version = "0.4.0+sdk-1.4.341.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
+dependencies = [
+ "bitflags 2.12.1",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
[[package]]
name = "static_assertions"
version = "1.1.0"
@@ -2738,28 +3920,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
-[[package]]
-name = "strum"
-version = "0.26.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
-dependencies = [
- "strum_macros",
-]
-
-[[package]]
-name = "strum_macros"
-version = "0.26.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
-dependencies = [
- "heck",
- "proc-macro2",
- "quote",
- "rustversion",
- "syn",
-]
-
[[package]]
name = "svg_fmt"
version = "0.4.5"
@@ -2800,6 +3960,19 @@ dependencies = [
"slotmap",
]
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.3.4",
+ "once_cell",
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -2890,11 +4063,13 @@ dependencies = [
[[package]]
name = "tinystr"
-version = "0.7.6"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
+ "serde_core",
+ "zerovec",
]
[[package]]
@@ -2925,6 +4100,27 @@ dependencies = [
"syn",
]
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime 0.6.11",
+ "toml_edit 0.22.27",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
@@ -2934,6 +4130,20 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime 0.6.11",
+ "toml_write",
+ "winnow 0.7.15",
+]
+
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
@@ -2941,9 +4151,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap",
- "toml_datetime",
+ "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
- "winnow",
+ "winnow 1.0.3",
]
[[package]]
@@ -2952,9 +4162,15 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
- "winnow",
+ "winnow 1.0.3",
]
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
[[package]]
name = "tracing"
version = "0.1.44"
@@ -2962,14 +4178,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
+ "tracing-attributes",
"tracing-core",
]
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
[[package]]
name = "tree-sitter"
@@ -3010,12 +4241,29 @@ dependencies = [
"tree-sitter-language",
]
+[[package]]
+name = "ttf-parser"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8"
+
[[package]]
name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -3035,55 +4283,100 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
-name = "unicode-xid"
-version = "0.2.6"
+name = "uuid"
+version = "1.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
[[package]]
name = "vello"
-version = "0.5.1"
+version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa3f8a53870a2ee699ce05b738a3f9974c92c35ed4874de86052ac68d214811c"
+checksum = "72fef40773530322d5c2ffe3c1107e9874bd8239ac137d1c2b6c1edad695146e"
dependencies = [
"bytemuck",
"futures-intrusive",
"log",
"peniko",
"png 0.17.16",
- "skrifa 0.35.0",
+ "skrifa 0.40.0",
"static_assertions",
"thiserror 2.0.18",
"vello_encoding",
"vello_shaders",
- "wgpu",
+ "wgpu 27.0.1",
+]
+
+[[package]]
+name = "vello_common"
+version = "0.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750"
+dependencies = [
+ "bytemuck",
+ "fearless_simd",
+ "guillotiere 0.7.0",
+ "hashbrown 0.17.1",
+ "log",
+ "peniko",
+ "smallvec",
+ "thiserror 2.0.18",
]
[[package]]
name = "vello_encoding"
-version = "0.5.1"
+version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c69b0fe94b0ac7e47619c504ee2c377355174f5c46353c46d03fa5f7e435922b"
+checksum = "24c91203ec4b483440614a9a5c7c2d991932af72c5349659a63ec49476f0b79c"
dependencies = [
"bytemuck",
- "guillotiere",
+ "guillotiere 0.6.2",
"peniko",
- "skrifa 0.35.0",
+ "skrifa 0.40.0",
"smallvec",
]
[[package]]
-name = "vello_shaders"
-version = "0.5.1"
+name = "vello_hybrid"
+version = "0.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2ebea426bb2f95b7610bca09178b03d809ede1d3c500a9acf6eca43e8f200be"
+checksum = "86ca7acbf6e59ae8c354c524732c73da45239a3a8ee03d0c8a9ad66a6622e488"
dependencies = [
"bytemuck",
- "naga",
+ "glifo",
+ "hashbrown 0.17.1",
+ "log",
+ "thiserror 2.0.18",
+ "vello_common",
+ "vello_sparse_shaders",
+ "wgpu 29.0.3",
+]
+
+[[package]]
+name = "vello_shaders"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a765d44d4bd354146e44f9a860f4e92effd91a97302549be9e47f0a18d8128c"
+dependencies = [
+ "bytemuck",
+ "log",
+ "naga 27.0.3",
"thiserror 2.0.18",
"vello_encoding",
]
+[[package]]
+name = "vello_sparse_shaders"
+version = "0.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e004cfe28eb71738643f60460b9cae2236eba57b351afebf244148256ff25d8b"
+
[[package]]
name = "version_check"
version = "0.9.5"
@@ -3307,18 +4600,21 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wgpu"
-version = "24.0.5"
+version = "27.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353"
+checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77"
dependencies = [
"arrayvec",
"bitflags 2.12.1",
+ "cfg-if",
"cfg_aliases",
"document-features",
+ "hashbrown 0.16.1",
"js-sys",
"log",
- "naga",
+ "naga 27.0.3",
"parking_lot",
+ "portable-atomic",
"profiling",
"raw-window-handle",
"smallvec",
@@ -3326,91 +4622,298 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
- "wgpu-core",
- "wgpu-hal",
- "wgpu-types",
+ "wgpu-core 27.0.3",
+ "wgpu-hal 27.0.4",
+ "wgpu-types 27.0.1",
+]
+
+[[package]]
+name = "wgpu"
+version = "29.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb3feacc458f7bee8bc1737149b42b6c731aa461039a4264a67bb6681646b250"
+dependencies = [
+ "arrayvec",
+ "bitflags 2.12.1",
+ "bytemuck",
+ "cfg-if",
+ "cfg_aliases",
+ "document-features",
+ "hashbrown 0.16.1",
+ "js-sys",
+ "log",
+ "naga 29.0.3",
+ "parking_lot",
+ "portable-atomic",
+ "profiling",
+ "raw-window-handle",
+ "smallvec",
+ "static_assertions",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "wgpu-core 29.0.3",
+ "wgpu-hal 29.0.3",
+ "wgpu-types 29.0.3",
]
[[package]]
name = "wgpu-core"
-version = "24.0.5"
+version = "27.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499"
+checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7"
dependencies = [
"arrayvec",
- "bit-vec",
+ "bit-set 0.8.0",
+ "bit-vec 0.8.0",
"bitflags 2.12.1",
+ "bytemuck",
"cfg_aliases",
"document-features",
+ "hashbrown 0.16.1",
"indexmap",
"log",
- "naga",
+ "naga 27.0.3",
"once_cell",
"parking_lot",
+ "portable-atomic",
"profiling",
"raw-window-handle",
"rustc-hash",
"smallvec",
"thiserror 2.0.18",
- "wgpu-hal",
- "wgpu-types",
+ "wgpu-core-deps-apple 27.0.0",
+ "wgpu-core-deps-emscripten 27.0.0",
+ "wgpu-core-deps-windows-linux-android 27.0.0",
+ "wgpu-hal 27.0.4",
+ "wgpu-types 27.0.1",
+]
+
+[[package]]
+name = "wgpu-core"
+version = "29.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02da3ad1b568337f25513b317870960ef87073ea0945502e44b864b67a8c77b7"
+dependencies = [
+ "arrayvec",
+ "bit-set 0.9.1",
+ "bit-vec 0.9.1",
+ "bitflags 2.12.1",
+ "bytemuck",
+ "cfg_aliases",
+ "document-features",
+ "hashbrown 0.16.1",
+ "indexmap",
+ "log",
+ "naga 29.0.3",
+ "once_cell",
+ "parking_lot",
+ "portable-atomic",
+ "profiling",
+ "raw-window-handle",
+ "rustc-hash",
+ "smallvec",
+ "thiserror 2.0.18",
+ "wgpu-core-deps-apple 29.0.3",
+ "wgpu-core-deps-emscripten 29.0.3",
+ "wgpu-core-deps-windows-linux-android 29.0.3",
+ "wgpu-hal 29.0.3",
+ "wgpu-naga-bridge",
+ "wgpu-types 29.0.3",
+]
+
+[[package]]
+name = "wgpu-core-deps-apple"
+version = "27.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233"
+dependencies = [
+ "wgpu-hal 27.0.4",
+]
+
+[[package]]
+name = "wgpu-core-deps-apple"
+version = "29.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62e51b5447e144b3dbba4feb01f80f4fa21696fa0cd99afb2c3df1affd6fdb28"
+dependencies = [
+ "wgpu-hal 29.0.3",
+]
+
+[[package]]
+name = "wgpu-core-deps-emscripten"
+version = "27.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5"
+dependencies = [
+ "wgpu-hal 27.0.4",
+]
+
+[[package]]
+name = "wgpu-core-deps-emscripten"
+version = "29.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3487cd6293a963bc5c0c0396f6a2192043c50003c07f4efdccbad3d90ec9d819"
+dependencies = [
+ "wgpu-hal 29.0.3",
+]
+
+[[package]]
+name = "wgpu-core-deps-windows-linux-android"
+version = "27.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3"
+dependencies = [
+ "wgpu-hal 27.0.4",
+]
+
+[[package]]
+name = "wgpu-core-deps-windows-linux-android"
+version = "29.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb01076d0aa08b0ba9bd741e178b5cc440f5abe99d9581323a4c8b5d1a1916"
+dependencies = [
+ "wgpu-hal 29.0.3",
]
[[package]]
name = "wgpu-hal"
-version = "24.0.4"
+version = "27.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259"
+checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce"
dependencies = [
"android_system_properties",
"arrayvec",
"ash",
- "bit-set",
+ "bit-set 0.8.0",
"bitflags 2.12.1",
"block",
"bytemuck",
+ "cfg-if",
"cfg_aliases",
- "core-graphics-types",
- "glow",
+ "core-graphics-types 0.2.0",
+ "glow 0.16.0",
"glutin_wgl_sys",
"gpu-alloc",
- "gpu-allocator",
+ "gpu-allocator 0.27.0",
"gpu-descriptor",
+ "hashbrown 0.16.1",
"js-sys",
"khronos-egl",
"libc",
"libloading",
"log",
"metal",
- "naga",
- "ndk-sys 0.5.0+25.2.9519653",
+ "naga 27.0.3",
+ "ndk-sys",
"objc",
"once_cell",
"ordered-float",
"parking_lot",
+ "portable-atomic",
+ "portable-atomic-util",
"profiling",
"range-alloc",
"raw-window-handle",
"renderdoc-sys",
- "rustc-hash",
"smallvec",
"thiserror 2.0.18",
"wasm-bindgen",
"web-sys",
- "wgpu-types",
- "windows",
- "windows-core",
+ "wgpu-types 27.0.1",
+ "windows 0.58.0",
+ "windows-core 0.58.0",
+]
+
+[[package]]
+name = "wgpu-hal"
+version = "29.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31f8e1a9e7a8512f276f7c62e018c7fa8d60954303fed2e5750114332049193f"
+dependencies = [
+ "android_system_properties",
+ "arrayvec",
+ "ash",
+ "bit-set 0.9.1",
+ "bitflags 2.12.1",
+ "block2 0.6.2",
+ "bytemuck",
+ "cfg-if",
+ "cfg_aliases",
+ "glow 0.17.0",
+ "glutin_wgl_sys",
+ "gpu-allocator 0.28.0",
+ "gpu-descriptor",
+ "hashbrown 0.16.1",
+ "js-sys",
+ "khronos-egl",
+ "libc",
+ "libloading",
+ "log",
+ "naga 29.0.3",
+ "ndk-sys",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
+ "objc2-quartz-core 0.3.2",
+ "once_cell",
+ "ordered-float",
+ "parking_lot",
+ "portable-atomic",
+ "portable-atomic-util",
+ "profiling",
+ "range-alloc",
+ "raw-window-handle",
+ "raw-window-metal",
+ "renderdoc-sys",
+ "smallvec",
+ "thiserror 2.0.18",
+ "wasm-bindgen",
+ "wayland-sys",
+ "web-sys",
+ "wgpu-naga-bridge",
+ "wgpu-types 29.0.3",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
+ "windows-result 0.4.1",
+]
+
+[[package]]
+name = "wgpu-naga-bridge"
+version = "29.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59c654c483f058800972c3645e95388a7eca31bf9fe1933bc20e036588a0be02"
+dependencies = [
+ "naga 29.0.3",
+ "wgpu-types 29.0.3",
]
[[package]]
name = "wgpu-types"
-version = "24.0.0"
+version = "27.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c"
+checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb"
dependencies = [
"bitflags 2.12.1",
+ "bytemuck",
"js-sys",
"log",
+ "thiserror 2.0.18",
+ "web-sys",
+]
+
+[[package]]
+name = "wgpu-types"
+version = "29.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9bcc31518a0e9735aefebedb5f7a9ef3ed1c42549c9f4c882fa9060ceaac639"
+dependencies = [
+ "bitflags 2.12.1",
+ "bytemuck",
+ "js-sys",
+ "log",
+ "raw-window-handle",
"web-sys",
]
@@ -3429,23 +4932,68 @@ version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
- "windows-core",
+ "windows-core 0.58.0",
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core 0.62.2",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core 0.62.2",
+]
+
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
- "windows-implement",
- "windows-interface",
- "windows-result",
- "windows-strings",
+ "windows-implement 0.58.0",
+ "windows-interface 0.58.0",
+ "windows-result 0.2.0",
+ "windows-strings 0.1.0",
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
+ "windows-link",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core 0.62.2",
+ "windows-link",
+ "windows-threading",
+]
+
[[package]]
name = "windows-implement"
version = "0.58.0"
@@ -3457,6 +5005,17 @@ dependencies = [
"syn",
]
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "windows-interface"
version = "0.58.0"
@@ -3468,12 +5027,33 @@ dependencies = [
"syn",
]
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core 0.62.2",
+ "windows-link",
+]
+
[[package]]
name = "windows-result"
version = "0.2.0"
@@ -3483,16 +5063,43 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
- "windows-result",
+ "windows-result 0.2.0",
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -3529,6 +5136,21 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -3562,6 +5184,21 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -3574,6 +5211,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -3586,6 +5229,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -3610,6 +5259,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -3622,6 +5277,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -3634,6 +5295,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -3646,6 +5313,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -3668,12 +5341,12 @@ dependencies = [
"android-activity",
"atomic-waker",
"bitflags 2.12.1",
- "block2",
+ "block2 0.5.1",
"bytemuck",
"calloop",
"cfg_aliases",
"concurrent-queue",
- "core-foundation",
+ "core-foundation 0.9.4",
"core-graphics",
"cursor-icon",
"dpi",
@@ -3710,6 +5383,15 @@ dependencies = [
"xkbcommon-dl",
]
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "winnow"
version = "1.0.3"
@@ -3727,9 +5409,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
-version = "0.5.5"
+version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "x11-dl"
@@ -3800,6 +5482,114 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
+[[package]]
+name = "yeslogic-fontconfig-sys"
+version = "6.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
+dependencies = [
+ "dlib",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "zbus"
+version = "5.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix 1.1.4",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow 1.0.3",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus-lockstep"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863"
+dependencies = [
+ "zbus_xml",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus-lockstep-macros"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "zbus-lockstep",
+ "zbus_xml",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
+dependencies = [
+ "serde",
+ "winnow 1.0.3",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_xml"
+version = "5.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9"
+dependencies = [
+ "quick-xml",
+ "serde",
+ "zbus_names",
+ "zvariant",
+]
+
[[package]]
name = "zeno"
version = "0.3.3"
@@ -3826,6 +5616,22 @@ dependencies = [
"syn",
]
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "serde",
+ "zerofrom",
+]
+
[[package]]
name = "zmij"
version = "1.0.21"
@@ -3846,3 +5652,43 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+
+[[package]]
+name = "zvariant"
+version = "5.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow 1.0.3",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn",
+ "winnow 1.0.3",
+]
diff --git a/Cargo.toml b/Cargo.toml
index 14d2ada..d6dfb6c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,12 +10,13 @@ members = [
"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",
]
exclude = [
"android",
"llimphi-gallery", "llimphi-gpu-bench",
"widgets/gallery",
- "modules/shuma-term", "modules/plugin-host",
+ "modules/shuma-term", "modules/plugin-host", "modules/allichay",
]
[workspace.package]
@@ -30,6 +31,7 @@ repository = "https://git.tawasuyu.net/tawasuyu/llimphi"
[workspace.dependencies]
# === Registro de apps / menú global ===
app-bus = { path = "shared/app-bus" }
+foreign-vox = { path = "shared/foreign-vox" }
# === Serialización ===
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -158,14 +160,17 @@ tempfile = "3"
# vello 0.5 = rasterizador vectorial sobre wgpu 24.
# 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).
-wgpu = "24"
+wgpu = "27"
winit = "0.30"
raw-window-handle = "0.6"
pollster = "0.4"
-vello = "0.5"
+vello = "0.7"
taffy = "0.9"
# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
-parley = "0.4"
+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" }
# Paleta semántica compartida por las apps y los widgets.
diff --git a/README.md b/README.md
index c6c1e77..57a4676 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# llimphi
-> Native UI framework: HAL · raster · layout · text · theme · ui — plus widgets and modules.
+> Native UI framework — 2D **and** 3D: HAL · raster · layout · text · theme · ui · 3D voxel engine — plus widgets and modules.
-`llimphi` is a sovereign, retained-mode UI framework with an Elm-style loop (`input → update → view → layout → raster → present`). Declarative pipeline over `vello` + `wgpu` + `taffy` + `parley`, with `Dark/Light/Aurora/Sunset` themes and a multi-platform HAL (Wayland · X11 · Win32 · Android · Wawa bare-metal). It powers a full Rust application suite; this repository is the framework extracted to stand on its own.
+`llimphi` is a sovereign, retained-mode UI framework with an Elm-style loop (`input → update → view → layout → raster → present`). Declarative pipeline over `vello` 0.7 + `wgpu` 27 + `taffy` + `parley` 0.6, with `Dark/Light/Aurora/Sunset/Tawa` themes and a multi-platform HAL (Wayland · X11 · Win32 · Android · Wawa bare-metal). It powers a full Rust application suite; this repository is the framework extracted to stand on its own.
@@ -16,6 +16,18 @@
…and the entire Elm loop in ~124 LOC — cargo run -p llimphi-ui --example counter
+## Not just 2D — a 3D voxel engine
+
+
+
+
+ a procedural voxel world orbiting — rendered headless by llimphi-3d, frame-by-frame
+
+
+`llimphi-3d` is a **3D engine** on the same `wgpu`: it composes voxels (a GPU ray-march) and triangle meshes in one shared depth pass, with a keyframed cinema camera. It mounts into the ordinary 2D `View` tree through the GPU paint node (`set_viewport` + scissor), so a 3D viewport can live in a panel next to regular widgets — no second window, same Elm loop.
+
+`llimphi-voxel` adds the *content* layer on top: procedural world-gen (`WorldRecipe`), articulated characters (age + animation clips) and a scripted scene **director**. The GIF above is one such world. (A full voxel **world studio** — edit worlds, cast characters, direct filmed scenes, export to video — is built on these in the wider project.)
+
**Usage manual:** [MANUAL.md](MANUAL.md) — full reference (Elm loop, `View` DSL, the ~44 widgets and 10 modules, GPU path, gotchas) for humans and AI. Design rationale and roadmap: [SDD.md](SDD.md).
Philosophy: **widgets aren't designed against mockups; they're designed with what `vello` and `taffy` can do.**
diff --git a/docs/llimphi_voxel.gif b/docs/llimphi_voxel.gif
new file mode 100644
index 0000000..d1b961e
Binary files /dev/null and b/docs/llimphi_voxel.gif differ
diff --git a/llimphi-3d/Cargo.toml b/llimphi-3d/Cargo.toml
new file mode 100644
index 0000000..3a8e827
--- /dev/null
+++ b/llimphi-3d/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "llimphi-3d"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.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."
+
+[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.
+wgpu = { workspace = true }
+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" }
+png = { workspace = true }
+pollster = { workspace = true }
+# Demo interactivo: bucle Elm + ventana + mouse (orbita/zoom) sobre gpu_paint_with.
+llimphi-ui = { path = "../llimphi-ui" }
diff --git a/llimphi-3d/examples/cubo_demo.rs b/llimphi-3d/examples/cubo_demo.rs
new file mode 100644
index 0000000..aaeece2
--- /dev/null
+++ b/llimphi-3d/examples/cubo_demo.rs
@@ -0,0 +1,145 @@
+//! Demo headless de M0: un cubo 3D con depth test, compuesto sobre un fondo
+//! vello — el mismo orden que aplica el runtime de Llimphi para
+//! `View::gpu_paint_with` (`[vello base] → [GPU 3D]`).
+//!
+//! No abre ventana: compone sobre una textura intermedia `Rgba8Unorm` (misma
+//! mecánica que el frame real) y vuelca a PNG.
+//!
+//! `cargo run -p llimphi-3d --example cubo_demo --release -- [out.png] [yaw_deg]`
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use llimphi_3d::glam::Vec3;
+use llimphi_3d::{Camera3d, Renderer3d};
+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;
+
+fn main() {
+ let out = std::env::args()
+ .nth(1)
+ .unwrap_or_else(|| "cubo_demo.png".to_string());
+ let yaw_deg: f32 = std::env::args()
+ .nth(2)
+ .and_then(|s| s.parse().ok())
+ .unwrap_or(35.0);
+
+ let hal = pollster::block_on(Hal::new(None)).expect("hal");
+ let mut renderer = Renderer::new(&hal).expect("renderer");
+ let mut r3d = Renderer3d::new(&hal.device, FMT);
+
+ 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());
+
+ // (1) Fondo vello: limpia la intermedia a un azul oscuro (render_to_view
+ // escribe todos los pixels con base_color).
+ let base = vello::Scene::new();
+ renderer
+ .render_to_view(
+ &hal,
+ &base,
+ &inter_view,
+ W,
+ H,
+ Color::from_rgba8(18, 22, 32, 255),
+ )
+ .expect("render base");
+
+ // (2) Pase 3D: cubo orbitado, depth test propio, LoadOp::Load sobre el fondo.
+ let camera = Camera3d::orbit(
+ Vec3::ZERO,
+ yaw_deg.to_radians(),
+ 25_f32.to_radians(),
+ 4.0,
+ );
+ let mut enc = hal
+ .device
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor {
+ label: Some("3d-pass"),
+ });
+ r3d.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());
+
+ write_png(&hal, &inter, &out);
+ eprintln!("cubo_demo: escrito {out} ({W}x{H}, yaw={yaw_deg}°)");
+}
+
+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();
+}
diff --git a/llimphi-3d/examples/lights_demo.rs b/llimphi-3d/examples/lights_demo.rs
new file mode 100644
index 0000000..ce47302
--- /dev/null
+++ b/llimphi-3d/examples/lights_demo.rs
@@ -0,0 +1,177 @@
+//! Demo de **luces puntuales coloreadas** en el ray-march voxel: antorchas/
+//! lámparas que tiñen los voxels cercanos con caída por distancia. Útil para
+//! mood cinematográfico (la rama machinima) y para juegos (antorchas).
+//!
+//! Rinde tres PNG para el contraste:
+//! - `/tmp/lights_off.png` — sólo sol + ambiente (la escena base).
+//! - `/tmp/lights_noshadow.png` — + una luz cálida y una fría (MVP plano, sin sombra).
+//! - `/tmp/lights_on.png` — las mismas luces **con sombra dura** (default):
+//! los pilares/esfera bloquean la luz puntual y proyectan su sombra en el piso.
+//!
+//! La diferencia `noshadow` → `on` aísla la sombra de las puntuales (el feature
+//! nuevo): se ven los conos oscuros detrás de cada obstáculo respecto de la luz.
+//!
+//! `cargo run -p llimphi-3d --example lights_demo --release`
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use llimphi_3d::glam::Vec3;
+use llimphi_3d::{Camera3d, PointLight, VoxelGrid, VoxelRenderer};
+use llimphi_hal::{wgpu, Hal};
+use llimphi_raster::peniko::Color;
+use llimphi_raster::{vello, Renderer};
+
+const W: u32 = 960;
+const H: u32 = 540;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+fn main() {
+ let dim = [96u32, 96, 96];
+ let hal = pollster::block_on(Hal::new(None)).expect("hal");
+ let mut renderer = Renderer::new(&hal).expect("renderer");
+
+ let mut grid = VoxelGrid::demo_scene(dim);
+ // Losa flotante en una zona despejada del piso: con una luz puntual justo
+ // ENCIMA, proyecta una sombra rectangular nítida en el piso de abajo — la
+ // prueba más legible de que las puntuales ya ocluyen.
+ for z in 58..74 {
+ for x in 16..34 {
+ grid.set(x, 20, z, [180, 180, 190]);
+ grid.set(x, 21, z, [180, 180, 190]);
+ }
+ }
+
+ let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
+ // Sol bajo y tenue para que las luces puntuales destaquen.
+ vr.sun_dir = [0.3, 0.35, 0.5];
+
+ let camera = Camera3d::orbit(
+ Vec3::new(0.0, 4.0, 0.0),
+ 40_f32.to_radians(),
+ 24_f32.to_radians(),
+ dim[0] as f32 * 1.6,
+ );
+
+ // Toma 1: sin luces puntuales.
+ let off = render(&hal, &mut renderer, &mut vr, &camera);
+ write_png(&off, "/tmp/lights_off.png");
+
+ // Toma 2: una luz cálida (naranja, junto a un pilar) y una fría (cian, junto a
+ // la esfera). Color > 1.0 = brillo intenso; `range` en voxels.
+ // Cerca del piso (gris neutro = lee bien el color) y de un pilar, intensas.
+ vr.lights = vec![
+ // Cálida JUSTO sobre la losa flotante → sombra rectangular nítida abajo.
+ PointLight { pos: [25.0, 40.0, 66.0], color: [3.6, 1.7, 0.7], range: 70.0, radius: 0.0 },
+ // Fría junto a la esfera, a media altura → la esfera corta su luz.
+ PointLight { pos: [70.0, 30.0, 60.0], color: [0.6, 1.7, 3.6], range: 70.0, radius: 0.0 },
+ ];
+
+ // 2a: MVP plano (sin sombra) — para aislar el feature nuevo.
+ vr.point_shadows = false;
+ let noshadow = render(&hal, &mut renderer, &mut vr, &camera);
+ write_png(&noshadow, "/tmp/lights_noshadow.png");
+
+ // 2b: con sombra DURA (radius = 0) — los obstáculos cortan la luz de golpe.
+ vr.point_shadows = true;
+ let on = render(&hal, &mut renderer, &mut vr, &camera);
+ write_png(&on, "/tmp/lights_on.png");
+
+ // 2c: con sombra BLANDA (radius > 0) — la luz pasa a fuente de área: el borde
+ // de la sombra se abre en penumbra (más cuanto más lejos el ocluyente).
+ for l in vr.lights.iter_mut() {
+ l.radius = 7.0;
+ }
+ let soft = render(&hal, &mut renderer, &mut vr, &camera);
+ write_png(&soft, "/tmp/lights_soft.png");
+
+ eprintln!(
+ "escritos /tmp/lights_off.png (sin luces), /tmp/lights_noshadow.png (sin \
+ sombra), /tmp/lights_on.png (sombra dura) y /tmp/lights_soft.png (penumbra)"
+ );
+}
+
+fn render(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, camera: &Camera3d) -> Vec {
+ 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 view = inter.create_view(&wgpu::TextureViewDescriptor::default());
+ renderer
+ .render_to_view(hal, &vello::Scene::new(), &view, W, H, Color::from_rgba8(0, 0, 0, 255))
+ .expect("base");
+ let mut enc = hal
+ .device
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("lights") });
+ vr.render(&hal.device, &hal.queue, &mut enc, &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 {
+ 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 wtr = enc.write_header().unwrap();
+ wtr.write_image_data(pixels).unwrap();
+}
diff --git a/llimphi-3d/examples/scene_mixed.rs b/llimphi-3d/examples/scene_mixed.rs
new file mode 100644
index 0000000..b2e35f6
--- /dev/null
+++ b/llimphi-3d/examples/scene_mixed.rs
@@ -0,0 +1,133 @@
+//! Demo headless del **motor 3D general**: voxels + mallas de triángulos en
+//! UNA escena con depth compartido ([`Scene3d`]). Prueba de oclusión mutua: un
+//! cubo-malla y la esfera voxel se **interpenetran** — la esfera asoma por las
+//! caras del cubo. Si el depth NO se compartiera, uno taparía al otro entero;
+//! con `Scene3d` se ve una intersección limpia (cada píxel = lo más cercano,
+//! sea voxel o triángulo).
+//!
+//! `cargo run -p llimphi-3d --example scene_mixed --release -- [out.png] [yaw_deg]`
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use llimphi_3d::glam::{Mat4, Vec3};
+use llimphi_3d::{Camera3d, Renderer3d, Scene3d, VoxelGrid, VoxelRenderer};
+use llimphi_hal::{wgpu, Hal};
+use llimphi_raster::peniko::Color;
+use llimphi_raster::{vello, Renderer};
+
+const W: u32 = 800;
+const H: u32 = 600;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+const D: u32 = 80;
+
+fn main() {
+ let out = std::env::args().nth(1).unwrap_or_else(|| "/tmp/scene_mixed.png".to_string());
+ let yaw_deg: f32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(40.0);
+
+ let hal = pollster::block_on(Hal::new(None)).expect("hal");
+ let mut renderer = Renderer::new(&hal).expect("renderer");
+
+ // Voxel: esfera + piso + pilares, centro de la esfera en mundo ≈ (0, 4, 0).
+ let grid = VoxelGrid::demo_scene([D, D, D]);
+ let voxel = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
+
+ // Malla: cubo coloreado escalado a ~0.45·D, centrado en la esfera → la
+ // esfera (r≈0.3·D) lo atraviesa y asoma por las caras.
+ let mut mesh = Renderer3d::new(&hal.device, FMT);
+ mesh.set_model(Mat4::from_translation(Vec3::new(0.0, 4.0, 0.0)) * Mat4::from_scale(Vec3::splat(0.45 * D as f32)));
+
+ let mut scene = Scene3d::new();
+
+ 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());
+
+ // (1) Fondo vello oscuro.
+ let base = vello::Scene::new();
+ renderer
+ .render_to_view(&hal, &base, &inter_view, W, H, Color::from_rgba8(16, 18, 24, 255))
+ .expect("render base");
+
+ // (2) Escena 3D mixta (voxels + malla, depth compartido).
+ let camera = Camera3d::orbit(
+ Vec3::new(0.0, 4.0, 0.0),
+ yaw_deg.to_radians(),
+ 20_f32.to_radians(),
+ D as f32 * 1.7,
+ );
+ let mut enc = hal
+ .device
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("scene") });
+ scene.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), &camera, Some(&voxel), &[&mesh]);
+ hal.queue.submit(std::iter::once(enc.finish()));
+ let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
+
+ write_png(&hal, &inter, &out);
+ eprintln!("scene_mixed: escrito {out} ({W}x{H}, yaw={yaw_deg}°) — voxel ∩ malla con depth compartido");
+}
+
+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();
+}
diff --git a/llimphi-3d/examples/voxel_demo.rs b/llimphi-3d/examples/voxel_demo.rs
new file mode 100644
index 0000000..41aa491
--- /dev/null
+++ b/llimphi-3d/examples/voxel_demo.rs
@@ -0,0 +1,144 @@
+//! Demo headless de M1: una grilla de voxels densa renderizada por
+//! **ray-marching DDA** (sin meshear), compuesta sobre un fondo vello — el
+//! mismo orden que el runtime aplica a `View::gpu_paint_with`.
+//!
+//! `cargo run -p llimphi-3d --example voxel_demo --release -- [out.png] [yaw_deg] [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;
+
+fn main() {
+ let out = std::env::args()
+ .nth(1)
+ .unwrap_or_else(|| "voxel_demo.png".to_string());
+ let yaw_deg: f32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(35.0);
+ let dim: u32 = std::env::args().nth(3).and_then(|s| s.parse().ok()).unwrap_or(64);
+
+ let hal = pollster::block_on(Hal::new(None)).expect("hal");
+ let mut renderer = Renderer::new(&hal).expect("renderer");
+
+ let grid = VoxelGrid::demo_scene([dim, dim, dim]);
+ let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
+ let (used, total) = vr.brick_usage();
+ let (pool, dense) = vr.memory_bytes();
+ eprintln!(
+ "brick pool: {used}/{total} bricks ocupados ({:.1}%) — pool {} KiB vs denso {} KiB ({:.1}× menos)",
+ used as f32 / total as f32 * 100.0,
+ pool / 1024,
+ dense / 1024,
+ dense as f32 / pool.max(1) as f32,
+ );
+
+ 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());
+
+ // (1) Fondo vello.
+ let base = vello::Scene::new();
+ renderer
+ .render_to_view(&hal, &base, &inter_view, W, H, Color::from_rgba8(18, 22, 32, 255))
+ .expect("render base");
+
+ // (2) Pase voxel ray-march. Cámara orbitando el centro de la grilla (origen).
+ let d = dim as f32;
+ let camera = Camera3d::orbit(
+ Vec3::ZERO,
+ yaw_deg.to_radians(),
+ 30_f32.to_radians(),
+ d * 1.7,
+ );
+ 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());
+
+ write_png(&hal, &inter, &out);
+ eprintln!("voxel_demo: escrito {out} ({W}x{H}, yaw={yaw_deg}°, dim={dim}³)");
+}
+
+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();
+}
diff --git a/llimphi-3d/examples/voxel_dimensiones.rs b/llimphi-3d/examples/voxel_dimensiones.rs
new file mode 100644
index 0000000..9a1c395
--- /dev/null
+++ b/llimphi-3d/examples/voxel_dimensiones.rs
@@ -0,0 +1,430 @@
+//! Demo de M5 — **dimensiones / mundos paralelos**. Tres mundos voxel
+//! independientes (Jardín, Inframundo, Cristal), cada uno con su grid, su cielo,
+//! su sol y sus entidades. La cámara ve la dimensión activa; "viajar" = cambiar
+//! cuál se renderiza.
+//!
+//! - **Arrastrar**: orbita. **Rueda**: zoom.
+//! - **Tab / N**: siguiente dimensión. **P**: anterior. **1/2/3**: ir a una.
+//! - Las entidades de la dimensión activa orbitan solas.
+//!
+//! `cargo run -p llimphi-3d --example voxel_dimensiones --release`
+//! `… --release -- --shot` → vuelca un PNG por dimensión a /tmp/m5_*.png
+
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+use llimphi_3d::glam::Vec3;
+use llimphi_3d::{Atmosphere, Camera3d, Dimension, Entity3d, Multiverse, VoxelGrid};
+use llimphi_ui::llimphi_hal::{wgpu, Hal};
+use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
+use llimphi_ui::llimphi_layout::LayoutTree;
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_raster::{vello, Renderer};
+use llimphi_ui::{
+ mount, paint_gpu, App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View,
+ WheelDelta,
+};
+
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+const DIM: u32 = 64;
+
+// ── Construcción de los tres mundos ─────────────────────────────────────────
+
+fn world_jardin(d: u32) -> Dimension {
+ Dimension::new("Jardín", VoxelGrid::demo_scene([d, d, d]))
+ .with_sky([20, 30, 26])
+ .with_sun([0.5, 1.0, 0.35])
+ .with_atmosphere(Atmosphere {
+ sky_zenith: [70, 130, 90],
+ sky_horizon: [196, 222, 188],
+ fog_density: 0.22 / d as f32,
+ })
+ .with_entities(orbit_entities(
+ d,
+ &[[235, 70, 70], [70, 220, 110], [90, 130, 250], [240, 200, 60]],
+ ))
+}
+
+fn world_inframundo(d: u32) -> Dimension {
+ let mut g = VoxelGrid::new([d, d, d]);
+ // Piso de lava (damero rojo/naranja).
+ for z in 0..d {
+ for x in 0..d {
+ let chk = ((x / 4 + z / 4) % 2) == 0;
+ let c = if chk { [150, 45, 22] } else { [185, 70, 28] };
+ for y in 0..2 {
+ g.set(x, y, z, c);
+ }
+ }
+ }
+ // Estalagmitas (columnas que se afinan hacia arriba).
+ for &(sx, sz, h) in &[(d / 4, d / 4, d * 2 / 5), (d * 3 / 4, d / 3, d / 2), (d / 2, d * 3 / 4, d * 3 / 5), (d / 5, d * 4 / 5, d * 3 / 10)] {
+ for y in 2..(2 + h).min(d) {
+ let t = (y - 2) as f32 / h as f32;
+ let r = ((1.0 - t) * 3.0).round() as i32;
+ for dx in -r..=r {
+ for dz in -r..=r {
+ let x = sx as i32 + dx;
+ let z = sz as i32 + dz;
+ if x >= 0 && z >= 0 {
+ let shade = 60 + (t * 70.0) as u8;
+ g.set(x as u32, y, z as u32, [120 + shade / 2, 50, 30]);
+ }
+ }
+ }
+ }
+ }
+ Dimension::new("Inframundo", g)
+ .with_sky([28, 8, 8])
+ .with_sun([0.35, 0.7, 0.5])
+ .with_atmosphere(Atmosphere {
+ sky_zenith: [60, 12, 10],
+ sky_horizon: [180, 70, 24],
+ fog_density: 0.4 / d as f32,
+ })
+ .with_entities(orbit_entities(d, &[[255, 140, 30], [255, 90, 20], [255, 200, 60]]))
+}
+
+fn world_cristal(d: u32) -> Dimension {
+ let mut g = VoxelGrid::new([d, d, d]);
+ // Cristales octaédricos flotando en el vacío (sin piso).
+ let crystals: [(u32, u32, u32, [u8; 3]); 6] = [
+ (d / 2, d * 3 / 4, d / 2, [120, 220, 255]),
+ (d / 3, d / 2, d * 2 / 3, [200, 160, 255]),
+ (d * 2 / 3, d * 3 / 5, d / 3, [160, 255, 220]),
+ (d / 4, d * 2 / 3, d / 4, [255, 240, 200]),
+ (d * 3 / 4, d / 2, d * 3 / 4, [180, 200, 255]),
+ (d / 2, d / 3, d * 4 / 5, [220, 180, 255]),
+ ];
+ for (cx, cy, cz, col) in crystals {
+ let r = 4i32;
+ for dx in -r..=r {
+ for dy in -r..=r {
+ for dz in -r..=r {
+ if dx.abs() + dy.abs() + dz.abs() <= r {
+ let x = cx as i32 + dx;
+ let y = cy as i32 + dy;
+ let z = cz as i32 + dz;
+ if x >= 0 && y >= 0 && z >= 0 {
+ g.set(x as u32, y as u32, z as u32, col);
+ }
+ }
+ }
+ }
+ }
+ }
+ Dimension::new("Cristal", g)
+ .with_sky([10, 10, 22])
+ .with_sun([0.4, 0.8, 0.45])
+ .with_atmosphere(Atmosphere {
+ sky_zenith: [24, 18, 60],
+ sky_horizon: [120, 90, 200],
+ fog_density: 0.28 / d as f32,
+ })
+ .with_entities(orbit_entities(d, &[[120, 240, 255], [220, 180, 255]]))
+}
+
+/// Entidades distribuidas en una órbita ecuatorial (se animan girando).
+fn orbit_entities(d: u32, colors: &[[u8; 3]]) -> Vec {
+ let n = colors.len();
+ let df = d as f32;
+ (0..n)
+ .map(|k| {
+ let a = k as f32 / n as f32 * std::f32::consts::TAU;
+ Entity3d {
+ pos: [df * 0.5 + a.cos() * df * 0.42, df * 0.45, df * 0.5 + a.sin() * df * 0.42],
+ half: [df * 0.05, df * 0.05, df * 0.05],
+ color: colors[k],
+ }
+ })
+ .collect()
+}
+
+fn build_multiverse(d: u32) -> Multiverse {
+ Multiverse::new(vec![world_jardin(d), world_inframundo(d), world_cristal(d)])
+}
+
+fn rotate_y(e: &mut Entity3d, center: [f32; 3], ang: f32) {
+ let dx = e.pos[0] - center[0];
+ let dz = e.pos[2] - center[2];
+ let (s, c) = ang.sin_cos();
+ e.pos[0] = center[0] + dx * c - dz * s;
+ e.pos[2] = center[2] + dx * s + dz * c;
+}
+
+// ── App interactiva ─────────────────────────────────────────────────────────
+
+#[derive(Clone)]
+enum Msg {
+ Orbit(f32, f32),
+ Zoom(f32),
+ Tick,
+ Next,
+ Prev,
+ Go(usize),
+}
+
+struct Model {
+ yaw: f32,
+ pitch: f32,
+ dist: f32,
+ active: usize,
+ names: Vec,
+ skies: Vec<[u8; 3]>,
+ mv: Arc>,
+}
+
+struct DimApp;
+
+impl App for DimApp {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "llimphi-3d · dimensiones"
+ }
+ fn initial_size() -> (u32, u32) {
+ (1000, 720)
+ }
+
+ fn init(handle: &Handle) -> Model {
+ handle.spawn_periodic(Duration::from_millis(33), || Msg::Tick);
+ let mv = build_multiverse(DIM);
+ Model {
+ yaw: 35_f32.to_radians(),
+ pitch: 30_f32.to_radians(),
+ dist: DIM as f32 * 1.7,
+ active: mv.active(),
+ names: mv.names(),
+ skies: mv.skies(),
+ mv: Arc::new(Mutex::new(mv)),
+ }
+ }
+
+ fn window_title(model: &Model) -> Option {
+ Some(format!(
+ "llimphi-3d · {} ({}/{}) — Tab=siguiente",
+ model.names[model.active],
+ model.active + 1,
+ model.names.len()
+ ))
+ }
+
+ fn on_key(_model: &Model, ev: &KeyEvent) -> Option {
+ if !matches!(ev.state, KeyState::Pressed) {
+ return None;
+ }
+ match &ev.key {
+ Key::Named(NamedKey::Tab) => Some(Msg::Next),
+ Key::Character(c) => match c.as_str() {
+ "n" | "N" => Some(Msg::Next),
+ "p" | "P" => Some(Msg::Prev),
+ "1" => Some(Msg::Go(0)),
+ "2" => Some(Msg::Go(1)),
+ "3" => Some(Msg::Go(2)),
+ _ => None,
+ },
+ _ => None,
+ }
+ }
+
+ fn on_wheel(_m: &Model, delta: WheelDelta, _c: (f32, f32), _mods: Modifiers) -> Option {
+ Some(Msg::Zoom(delta.y))
+ }
+
+ fn update(mut model: Model, msg: Msg, _handle: &Handle) -> Model {
+ match msg {
+ Msg::Orbit(dx, dy) => {
+ model.yaw -= dx * 0.008;
+ model.pitch += dy * 0.008;
+ }
+ Msg::Zoom(dy) => {
+ let f = (1.0 + dy * 0.1).clamp(0.5, 1.5);
+ model.dist = (model.dist * f).clamp(DIM as f32 * 0.5, DIM as f32 * 4.0);
+ }
+ Msg::Tick => {
+ // Anima las entidades de la dimensión activa.
+ let mut mv = model.mv.lock().unwrap();
+ let c = [DIM as f32 * 0.5, DIM as f32 * 0.45, DIM as f32 * 0.5];
+ for e in &mut mv.active_dim_mut().entities {
+ rotate_y(e, c, 0.02);
+ }
+ }
+ Msg::Next => {
+ model.mv.lock().unwrap().next();
+ model.active = model.mv.lock().unwrap().active();
+ }
+ Msg::Prev => {
+ model.mv.lock().unwrap().prev();
+ model.active = model.mv.lock().unwrap().active();
+ }
+ Msg::Go(i) => {
+ let mut mv = model.mv.lock().unwrap();
+ mv.switch(i);
+ model.active = mv.active();
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View {
+ let camera = Camera3d::orbit(Vec3::ZERO, model.yaw, model.pitch, model.dist);
+ let mv = model.mv.clone();
+
+ let canvas = View::new(fill())
+ .gpu_paint_with(move |device, queue, encoder, target, _rect, vp| {
+ mv.lock().unwrap().render(device, queue, encoder, target, vp, &camera);
+ })
+ .draggable(|phase, dx, dy| match phase {
+ DragPhase::Move => Some(Msg::Orbit(dx, dy)),
+ DragPhase::End => None,
+ });
+
+ View::new(fill()).children(vec![canvas])
+ }
+}
+
+fn fill() -> Style {
+ Style {
+ size: Size {
+ width: percent(1.0),
+ height: percent(1.0),
+ },
+ ..Default::default()
+ }
+}
+
+fn main() {
+ let args: Vec = std::env::args().collect();
+ if args.iter().any(|a| a == "--shot") {
+ shot();
+ return;
+ }
+ llimphi_ui::run::();
+}
+
+/// Vuelca un PNG por dimensión por el compositor real (mount → paint_gpu).
+fn shot() {
+ const W: u32 = 1000;
+ const H: u32 = 720;
+ let mv = Arc::new(Mutex::new(build_multiverse(DIM)));
+ let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 30_f32.to_radians(), DIM as f32 * 1.7);
+
+ let hal = pollster::block_on(Hal::new(None)).expect("hal");
+ let mut renderer = Renderer::new(&hal).expect("renderer");
+ let count = mv.lock().unwrap().count();
+
+ for i in 0..count {
+ let (name, sky) = {
+ let mut g = mv.lock().unwrap();
+ g.switch(i);
+ (g.active_name().to_string(), g.active_dim().sky)
+ };
+ let model_mv = mv.clone();
+ let cam = camera;
+ let canvas: View = View::new(fill()).gpu_paint_with(
+ move |device, queue, encoder, target, _rect, vp| {
+ model_mv.lock().unwrap().render(device, queue, encoder, target, vp, &cam);
+ },
+ );
+ let view: View = View::new(fill()).children(vec![canvas]);
+
+ let mut layout = LayoutTree::new();
+ let mounted = mount(&mut layout, view);
+ let computed = layout.compute(mounted.root, (W as f32, H as f32)).expect("layout");
+
+ 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());
+ renderer
+ .render_to_view(&hal, &vello::Scene::new(), &inter_view, W, H, Color::from_rgba8(sky[0], sky[1], sky[2], 255))
+ .expect("base");
+
+ let mut enc = hal
+ .device
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("gpu") });
+ let any = paint_gpu(&mounted, &computed, &hal.device, &hal.queue, &mut enc, &inter_view, (W, H));
+ hal.queue.submit(std::iter::once(enc.finish()));
+ let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
+ assert!(any, "gpu_painter no corrió");
+
+ let out = format!("/tmp/m5_{i}_{}.png", name.to_lowercase());
+ write_png(&hal, &inter, W, H, &out);
+ eprintln!("dimensión {i} = {name} → {out}");
+ }
+}
+
+fn write_png(hal: &Hal, target: &wgpu::Texture, w: u32, h: u32, path: &str) {
+ use std::fs::File;
+ use std::io::BufWriter;
+ 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 penc = png::Encoder::new(BufWriter::new(file), w, h);
+ penc.set_color(png::ColorType::Rgba);
+ penc.set_depth(png::BitDepth::Eight);
+ let mut wr = penc.write_header().unwrap();
+ wr.write_image_data(&pixels).unwrap();
+}
diff --git a/llimphi-3d/examples/voxel_dynamic_demo.rs b/llimphi-3d/examples/voxel_dynamic_demo.rs
new file mode 100644
index 0000000..1c98a6e
--- /dev/null
+++ b/llimphi-3d/examples/voxel_dynamic_demo.rs
@@ -0,0 +1,173 @@
+//! Demo headless de M3: **mutación incremental** de la grilla en GPU.
+//!
+//! Renderiza la escena, luego (a) agrega un bloque flotante en aire antes vacío
+//! y (b) carva un mordisco en la esfera — cada edición sube SÓLO su sub-caja vía
+//! `VoxelRenderer::sync` (no re-sube el grid ni remesha). Vuelca un PNG "antes"
+//! y uno "después", e imprime los bytes subidos vs el grid completo.
+//!
+//! El bloque flotante es el test clave del coarse map: si `sync` no actualizara
+//! la ocupación gruesa, el brick seguiría marcado vacío y el bloque sería
+//! invisible (lo saltaría el DDA grueso).
+//!
+//! `cargo run -p llimphi-3d --example voxel_dynamic_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;
+
+fn main() {
+ let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(64);
+
+ let hal = pollster::block_on(Hal::new(None)).expect("hal");
+ let mut renderer = Renderer::new(&hal).expect("renderer");
+
+ let mut grid = VoxelGrid::demo_scene([dim, dim, dim]);
+ let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
+
+ let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 30_f32.to_radians(), dim as f32 * 1.7);
+
+ // ── Frame ANTES ──────────────────────────────────────────────────────
+ render_frame(&hal, &mut renderer, &mut vr, &camera, "/tmp/m3_antes.png");
+
+ let full = dim * dim * dim * 4;
+
+ // ── Edición (a): bloque flotante en aire vacío (arriba, a un costado) ──
+ let bx = dim / 6;
+ let by = dim * 4 / 5;
+ let bz = dim / 6;
+ for z in 0..8 {
+ for y in 0..8 {
+ for x in 0..8 {
+ grid.set(bx + x, by + y, bz + z, [240, 150, 40]);
+ }
+ }
+ }
+ let n_a = vr.sync(&hal.queue, &mut grid);
+ eprintln!("edición (a) bloque flotante: subidos {n_a} B ({:.3}% del grid completo)", n_a as f32 / full as f32 * 100.0);
+
+ // ── Edición (b): mordisco cúbico en lo alto de la esfera ──────────────
+ let cx = dim / 2;
+ let cy = dim * 7 / 10;
+ let cz = dim / 2;
+ for z in 0..(dim / 4) {
+ for y in 0..(dim / 4) {
+ for x in 0..(dim / 4) {
+ grid.clear(cx + x, cy + y, cz - dim / 8 + z);
+ }
+ }
+ }
+ let n_b = vr.sync(&hal.queue, &mut grid);
+ eprintln!("edición (b) mordisco esfera: subidos {n_b} B ({:.3}% del grid completo)", n_b as f32 / full as f32 * 100.0);
+
+ // ── Frame DESPUÉS ────────────────────────────────────────────────────
+ render_frame(&hal, &mut renderer, &mut vr, &camera, "/tmp/m3_despues.png");
+ eprintln!("voxel_dynamic_demo: /tmp/m3_antes.png + /tmp/m3_despues.png (dim={dim}³)");
+}
+
+fn render_frame(
+ hal: &Hal,
+ renderer: &mut Renderer,
+ vr: &mut VoxelRenderer,
+ camera: &Camera3d,
+ out: &str,
+) {
+ 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(18, 22, 32, 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());
+
+ write_png(hal, &inter, out);
+}
+
+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();
+}
diff --git a/llimphi-3d/examples/voxel_entities_demo.rs b/llimphi-3d/examples/voxel_entities_demo.rs
new file mode 100644
index 0000000..814b23c
--- /dev/null
+++ b/llimphi-3d/examples/voxel_entities_demo.rs
@@ -0,0 +1,160 @@
+//! Demo headless de M4: **entidades** (agentes) ray-marcheadas como cajas
+//! analíticas en el mismo pase que los voxels. Se mueven con posición sub-voxel
+//! (suave, no snapeada a la grilla), ocluyen y son ocluidas por el mundo voxel
+//! (esfera/pilares) por comparación de `t`, y proyectan sombras sobre el piso.
+//!
+//! Genera 3 frames con las entidades en distintas posiciones de una órbita para
+//! evidenciar el movimiento + oclusión + sombras.
+//!
+//! `cargo run -p llimphi-3d --example voxel_entities_demo --release -- [dim]`
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use llimphi_3d::glam::Vec3;
+use llimphi_3d::{Camera3d, Entity3d, 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;
+
+fn main() {
+ let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(64);
+ 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 = VoxelGrid::demo_scene([dim, dim, dim]);
+ let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
+
+ let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 30_f32.to_radians(), d * 1.7);
+
+ let colors = [[235u8, 70, 70], [70, 220, 110], [90, 130, 250], [240, 200, 60]];
+
+ for (fi, phase) in [0.0_f32, 0.9, 1.8].iter().enumerate() {
+ // 4 entidades orbitando el centro a media altura, con bobeo vertical.
+ // Una pasa por delante de la esfera y otra por detrás → oclusión mutua.
+ vr.entities.clear();
+ for k in 0..4 {
+ let a = phase + k as f32 * std::f32::consts::FRAC_PI_2;
+ let radius = d * 0.42;
+ let pos = [
+ d * 0.5 + a.cos() * radius,
+ d * (0.45 + 0.12 * (a * 1.3).sin()),
+ d * 0.5 + a.sin() * radius,
+ ];
+ vr.entities.push(Entity3d {
+ pos,
+ half: [d * 0.05, d * 0.05, d * 0.05],
+ color: colors[k],
+ });
+ }
+ let out = format!("/tmp/m4_frame{fi}.png");
+ render_frame(&hal, &mut renderer, &mut vr, &camera, &out);
+ eprintln!("frame {fi}: {} entidades → {out}", vr.entities.len());
+ }
+ eprintln!("voxel_entities_demo: /tmp/m4_frame0..2.png (dim={dim}³)");
+}
+
+fn render_frame(
+ hal: &Hal,
+ renderer: &mut Renderer,
+ vr: &mut VoxelRenderer,
+ camera: &Camera3d,
+ out: &str,
+) {
+ 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(18, 22, 32, 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());
+
+ write_png(hal, &inter, out);
+}
+
+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();
+}
diff --git a/llimphi-3d/examples/voxel_interactivo.rs b/llimphi-3d/examples/voxel_interactivo.rs
new file mode 100644
index 0000000..bb5de1f
--- /dev/null
+++ b/llimphi-3d/examples/voxel_interactivo.rs
@@ -0,0 +1,296 @@
+//! Demo **interactivo** del motor 3D: el mundo voxel (M1-M4) dentro de un
+//! `View` vivo de Llimphi, manejado con el mouse.
+//!
+//! - **Arrastrar** (botón izquierdo): orbita la cámara (yaw/pitch).
+//! - **Rueda**: zoom (acerca/aleja).
+//! - Las 4 entidades de colores orbitan solas (animación por `spawn_periodic`).
+//!
+//! Es el cableado real a una app: el `VoxelRenderer` se compone dentro del
+//! árbol `View` por `View::gpu_paint_with` (corre DESPUÉS de la pasada
+//! vello, con `LoadOp::Load`). El renderer se crea perezosamente en la primera
+//! llamada GPU (ahí recién hay `Device`/`Queue`) y se cachea en el Model tras
+//! un `Arc>`.
+//!
+//! `cargo run -p llimphi-3d --example voxel_interactivo --release -- [dim]`
+
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+use llimphi_3d::glam::Vec3;
+use llimphi_3d::{Camera3d, Entity3d, VoxelGrid, VoxelRenderer};
+use llimphi_ui::llimphi_hal::{wgpu, Hal};
+use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
+use llimphi_ui::llimphi_layout::LayoutTree;
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_raster::{vello, Renderer};
+use llimphi_ui::{mount, paint_gpu, App, DragPhase, Handle, Modifiers, View, WheelDelta};
+
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+#[derive(Clone)]
+enum Msg {
+ Orbit(f32, f32),
+ Zoom(f32),
+ Tick,
+}
+
+struct Model {
+ yaw: f32,
+ pitch: f32,
+ dist: f32,
+ phase: f32,
+ dim: u32,
+ grid: Arc,
+ /// Renderer voxel, creado en la 1ª pintada GPU (necesita el Device).
+ engine: Arc>>,
+}
+
+fn entities_at(phase: f32, dim: u32) -> Vec {
+ let d = dim as f32;
+ let colors = [[235u8, 70, 70], [70, 220, 110], [90, 130, 250], [240, 200, 60]];
+ (0..4)
+ .map(|k| {
+ let a = phase + k as f32 * std::f32::consts::FRAC_PI_2;
+ let radius = d * 0.42;
+ Entity3d {
+ pos: [
+ d * 0.5 + a.cos() * radius,
+ d * (0.45 + 0.12 * (a * 1.3).sin()),
+ d * 0.5 + a.sin() * radius,
+ ],
+ half: [d * 0.05, d * 0.05, d * 0.05],
+ color: colors[k],
+ }
+ })
+ .collect()
+}
+
+struct VoxelApp;
+
+impl App for VoxelApp {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "llimphi-3d · motor voxel interactivo"
+ }
+
+ fn initial_size() -> (u32, u32) {
+ (1000, 720)
+ }
+
+ fn init(handle: &Handle) -> Model {
+ let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(64);
+ // Anima las entidades a ~30 fps.
+ handle.spawn_periodic(Duration::from_millis(33), || Msg::Tick);
+ Model {
+ yaw: 35_f32.to_radians(),
+ pitch: 30_f32.to_radians(),
+ dist: dim as f32 * 1.7,
+ phase: 0.0,
+ dim,
+ grid: Arc::new(VoxelGrid::demo_scene([dim, dim, dim])),
+ engine: Arc::new(Mutex::new(None)),
+ }
+ }
+
+ fn update(mut model: Model, msg: Msg, _handle: &Handle) -> Model {
+ match msg {
+ Msg::Orbit(dx, dy) => {
+ model.yaw -= dx * 0.008;
+ model.pitch += dy * 0.008;
+ }
+ Msg::Zoom(dy) => {
+ // Rueda hacia adelante = acercar (reduce la distancia). El signo
+ // va invertido respecto del delta crudo para que sea natural.
+ let f = (1.0 + dy * 0.1).clamp(0.5, 1.5);
+ let d = model.dim as f32;
+ model.dist = (model.dist * f).clamp(d * 0.5, d * 4.0);
+ }
+ Msg::Tick => {
+ model.phase += 0.035;
+ }
+ }
+ model
+ }
+
+ fn on_wheel(
+ _model: &Model,
+ delta: WheelDelta,
+ _cursor: (f32, f32),
+ _mods: Modifiers,
+ ) -> Option {
+ Some(Msg::Zoom(delta.y))
+ }
+
+ fn view(model: &Model) -> View {
+ let camera = Camera3d::orbit(Vec3::ZERO, model.yaw, model.pitch, model.dist);
+ let entities = entities_at(model.phase, model.dim);
+ let engine = model.engine.clone();
+ let grid = model.grid.clone();
+
+ let canvas = View::new(Style {
+ size: Size {
+ width: percent(1.0),
+ height: percent(1.0),
+ },
+ ..Default::default()
+ })
+ .gpu_paint_with(move |device, queue, encoder, target, _rect, vp| {
+ let mut guard = engine.lock().unwrap();
+ let er = guard.get_or_insert_with(|| VoxelRenderer::new(device, queue, FMT, &grid));
+ er.entities = entities.clone();
+ er.render(device, queue, encoder, target, vp, &camera);
+ })
+ .draggable(|phase, dx, dy| match phase {
+ DragPhase::Move => Some(Msg::Orbit(dx, dy)),
+ DragPhase::End => None,
+ });
+
+ View::new(Style {
+ size: Size {
+ width: percent(1.0),
+ height: percent(1.0),
+ },
+ ..Default::default()
+ })
+ .children(vec![canvas])
+ }
+}
+
+fn main() {
+ let args: Vec = std::env::args().collect();
+ // Modo verificación headless: monta el MISMO View por el compositor real
+ // (mount → compute → paint_gpu) y vuelca un PNG, sin abrir ventana.
+ if let Some(i) = args.iter().position(|a| a == "--shot") {
+ let out = args
+ .get(i + 1)
+ .cloned()
+ .unwrap_or_else(|| "/tmp/voxel_interactivo.png".to_string());
+ shot(&out);
+ return;
+ }
+ llimphi_ui::run::();
+}
+
+/// Render headless del árbol `View` de la app a través del compositor real.
+fn shot(out: &str) {
+ const W: u32 = 1000;
+ const H: u32 = 720;
+ let dim = 64u32;
+ let model = Model {
+ yaw: 35_f32.to_radians(),
+ pitch: 30_f32.to_radians(),
+ dist: dim as f32 * 1.7,
+ phase: 0.6,
+ dim,
+ grid: Arc::new(VoxelGrid::demo_scene([dim, dim, dim])),
+ engine: Arc::new(Mutex::new(None)),
+ };
+
+ // Árbol real de la app → mount + layout (igual que el runtime por frame).
+ let view = VoxelApp::view(&model);
+ let mut layout = LayoutTree::new();
+ let mounted = mount(&mut layout, view);
+ let computed = layout
+ .compute(mounted.root, (W as f32, H as f32))
+ .expect("layout");
+
+ let hal = pollster::block_on(Hal::new(None)).expect("hal");
+ let mut renderer = Renderer::new(&hal).expect("renderer");
+ 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());
+
+ // Pasada vello base (fondo) — igual que el frame real.
+ renderer
+ .render_to_view(&hal, &vello::Scene::new(), &inter_view, W, H, Color::from_rgba8(18, 22, 32, 255))
+ .expect("base");
+
+ // Pasada GPU directo: dispara los gpu_painter del árbol (nuestro voxel).
+ let mut enc = hal
+ .device
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("gpu") });
+ let any = paint_gpu(&mounted, &computed, &hal.device, &hal.queue, &mut enc, &inter_view, (W, H));
+ hal.queue.submit(std::iter::once(enc.finish()));
+ let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
+ assert!(any, "ningún gpu_painter corrió — el cableado no llegó al compositor");
+
+ write_png(&hal, &inter, W, H, out);
+ eprintln!("voxel_interactivo --shot: {out} ({W}x{H}) — gpu_painter del View ejecutado por el compositor");
+}
+
+fn write_png(hal: &Hal, target: &wgpu::Texture, w: u32, h: u32, path: &str) {
+ use std::fs::File;
+ use std::io::BufWriter;
+ 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 penc = png::Encoder::new(BufWriter::new(file), w, h);
+ penc.set_color(png::ColorType::Rgba);
+ penc.set_depth(png::BitDepth::Eight);
+ let mut wr = penc.write_header().unwrap();
+ wr.write_image_data(&pixels).unwrap();
+}
diff --git a/llimphi-3d/src/camera.rs b/llimphi-3d/src/camera.rs
new file mode 100644
index 0000000..76773ef
--- /dev/null
+++ b/llimphi-3d/src/camera.rs
@@ -0,0 +1,84 @@
+//! Cámara 3D — produce la matriz `view_proj` que el shader aplica a cada
+//! vértice. Convención de mano derecha y profundidad `0..1` (la de wgpu/
+//! Vulkan/Metal/DX12, **no** la `-1..1` de OpenGL).
+
+use glam::{Mat4, Vec3};
+
+/// Cámara en perspectiva. `eye` mira a `target` con `up` como vertical.
+#[derive(Debug, Clone, Copy)]
+pub struct Camera3d {
+ /// Posición del ojo en mundo.
+ pub eye: Vec3,
+ /// Punto al que mira.
+ pub target: Vec3,
+ /// Vector "arriba" (normalmente `Vec3::Y`).
+ pub up: Vec3,
+ /// Campo de visión vertical, en radianes.
+ pub fovy_rad: f32,
+ /// Plano cercano (`> 0`).
+ pub znear: f32,
+ /// Plano lejano.
+ pub zfar: f32,
+}
+
+impl Default for Camera3d {
+ fn default() -> Self {
+ Self {
+ eye: Vec3::new(2.5, 2.0, 3.5),
+ target: Vec3::ZERO,
+ up: Vec3::Y,
+ fovy_rad: 60_f32.to_radians(),
+ znear: 0.1,
+ // Generoso: cubre mundos voxel de cientos de unidades. Importa desde
+ // que el pase de voxels escribe profundidad (`Scene3d`): un hit más
+ // allá de `zfar` se clamparía a 1.0 y fallaría el depth test. Float32
+ // de depth mantiene precisión de sobra en este rango para oclusión.
+ zfar: 5000.0,
+ }
+ }
+}
+
+impl Camera3d {
+ /// Cámara orbitando `target` a `dist`, con `yaw`/`pitch` en radianes.
+ /// `yaw` gira alrededor del eje Y; `pitch` sube/baja (clamp suave para no
+ /// cruzar los polos y degenerar el `up`).
+ pub fn orbit(target: Vec3, yaw: f32, pitch: f32, dist: f32) -> Self {
+ let lim = std::f32::consts::FRAC_PI_2 - 0.01;
+ let pitch = pitch.clamp(-lim, lim);
+ let (sy, cy) = yaw.sin_cos();
+ let (sp, cp) = pitch.sin_cos();
+ let offset = Vec3::new(cp * sy, sp, cp * cy) * dist;
+ Self {
+ eye: target + offset,
+ target,
+ ..Self::default()
+ }
+ }
+
+ /// Cámara **libre / primera persona**: parada en `eye`, mirando según
+ /// `yaw` (giro alrededor de Y) y `pitch` (cabeceo, clamped para no cruzar el
+ /// cenit). Complementa a [`orbit`](Self::orbit): `orbit` mira un punto desde
+ /// afuera (vista de paisaje), `fly` te pone *adentro* del mundo (vuelo / FPS).
+ /// `yaw=0` mira hacia `+Z`.
+ pub fn fly(eye: Vec3, yaw: f32, pitch: f32) -> Self {
+ let lim = std::f32::consts::FRAC_PI_2 - 0.01;
+ let pitch = pitch.clamp(-lim, lim);
+ let (sy, cy) = yaw.sin_cos();
+ let (sp, cp) = pitch.sin_cos();
+ let dir = Vec3::new(cp * sy, sp, cp * cy);
+ Self {
+ eye,
+ target: eye + dir,
+ ..Self::default()
+ }
+ }
+
+ /// Matriz `proj * view` lista para `mvp * vec4(pos, 1.0)` en el shader.
+ /// `aspect` = ancho/alto del viewport en pixels.
+ pub fn view_proj(&self, aspect: f32) -> Mat4 {
+ let view = Mat4::look_at_rh(self.eye, self.target, self.up);
+ // `perspective_rh` (no `_gl`): profundidad 0..1, la que espera wgpu.
+ let proj = Mat4::perspective_rh(self.fovy_rad, aspect.max(1e-4), self.znear, self.zfar);
+ proj * view
+ }
+}
diff --git a/llimphi-3d/src/cinema.rs b/llimphi-3d/src/cinema.rs
new file mode 100644
index 0000000..802b740
--- /dev/null
+++ b/llimphi-3d/src/cinema.rs
@@ -0,0 +1,139 @@
+//! `CameraTrack` — interpolación de cámara por **keyframes** en el tiempo, el
+//! ingrediente "cine" del motor: en vez de una `Camera3d` fija o atada a input,
+//! una secuencia de poses `(t, eye, target, fov)` que se interpolan suave para
+//! producir un **movimiento de cámara guionado** (travelling, grúa, dolly,
+//! corte). Determinista por construcción → ideal para *filmar* frame a frame.
+//!
+//! 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á.
+
+use glam::Vec3;
+
+use crate::camera::Camera3d;
+
+/// Una pose de cámara anclada a un instante `t` (segundos). Entre keys
+/// consecutivas, [`CameraTrack::sample`] interpola `eye`/`target`/`fovy_rad`.
+#[derive(Debug, Clone, Copy)]
+pub struct CamKey {
+ /// Instante de la pose, en segundos desde el inicio.
+ pub t: f32,
+ /// Posición del ojo.
+ pub eye: Vec3,
+ /// Punto al que mira.
+ pub target: Vec3,
+ /// Campo de visión vertical (radianes) en esta pose.
+ pub fovy_rad: f32,
+}
+
+impl CamKey {
+ /// Atajo: una pose mirando de `eye` a `target` con FOV en **grados**.
+ pub fn look(t: f32, eye: Vec3, target: Vec3, fov_deg: f32) -> Self {
+ Self { t, eye, target, fovy_rad: fov_deg.to_radians() }
+ }
+}
+
+/// Secuencia de [`CamKey`] ordenada en el tiempo. `sample(t)` devuelve la
+/// `Camera3d` interpolada; fuera de rango hace *clamp* a la primera/última pose.
+#[derive(Debug, Clone, Default)]
+pub struct CameraTrack {
+ keys: Vec,
+}
+
+impl CameraTrack {
+ /// Crea el track a partir de las keys (se ordenan por `t`). Un track vacío
+ /// o de una sola key es válido (devuelve siempre esa pose).
+ pub fn new(mut keys: Vec) -> Self {
+ keys.sort_by(|a, b| a.t.total_cmp(&b.t));
+ Self { keys }
+ }
+
+ /// Duración total (el `t` de la última key), o `0.0` si está vacío.
+ pub fn duration(&self) -> f32 {
+ self.keys.last().map(|k| k.t).unwrap_or(0.0)
+ }
+
+ /// La cámara interpolada en el instante `t` (segundos). Entre dos keys usa
+ /// **smoothstep** (acelera/desacelera suave, sin tirones) sobre la fracción
+ /// del segmento; antes de la primera / después de la última, clampa.
+ pub fn sample(&self, t: f32) -> Camera3d {
+ match self.keys.as_slice() {
+ [] => Camera3d::default(),
+ [only] => cam_of(only),
+ keys => {
+ // Clamp a los extremos.
+ if t <= keys[0].t {
+ return cam_of(&keys[0]);
+ }
+ if t >= keys[keys.len() - 1].t {
+ return cam_of(&keys[keys.len() - 1]);
+ }
+ // Segmento que contiene a `t`: última key con `t_key <= t`
+ // (existe y no es la última, por el clamp de arriba).
+ let i = keys.iter().rposition(|k| k.t <= t).unwrap_or(0).min(keys.len() - 2);
+ let (a, b) = (&keys[i], &keys[i + 1]);
+ let span = (b.t - a.t).max(1e-6);
+ let f = smoothstep((t - a.t) / span);
+ Camera3d {
+ eye: a.eye.lerp(b.eye, f),
+ target: a.target.lerp(b.target, f),
+ fovy_rad: a.fovy_rad + (b.fovy_rad - a.fovy_rad) * f,
+ ..Camera3d::default()
+ }
+ }
+ }
+ }
+}
+
+/// Construye una `Camera3d` (con `up`/planos por defecto) desde una key.
+fn cam_of(k: &CamKey) -> Camera3d {
+ Camera3d {
+ eye: k.eye,
+ target: k.target,
+ fovy_rad: k.fovy_rad,
+ ..Camera3d::default()
+ }
+}
+
+/// Suavizado Hermite clásico `3t²−2t³` en `[0,1]` (deriva nula en los extremos).
+fn smoothstep(x: f32) -> f32 {
+ let x = x.clamp(0.0, 1.0);
+ x * x * (3.0 - 2.0 * x)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn track() -> CameraTrack {
+ CameraTrack::new(vec![
+ CamKey::look(0.0, Vec3::new(0.0, 0.0, 0.0), Vec3::Z, 60.0),
+ CamKey::look(2.0, Vec3::new(10.0, 0.0, 0.0), Vec3::Z, 40.0),
+ ])
+ }
+
+ #[test]
+ fn clamp_en_los_extremos() {
+ let tr = track();
+ assert_eq!(tr.sample(-1.0).eye, Vec3::ZERO);
+ assert_eq!(tr.sample(5.0).eye.x, 10.0);
+ assert_eq!(tr.duration(), 2.0);
+ }
+
+ #[test]
+ fn interpola_la_mitad_con_smoothstep() {
+ let tr = track();
+ // En la mitad temporal, smoothstep(0.5)=0.5 → punto medio exacto.
+ let c = tr.sample(1.0);
+ assert!((c.eye.x - 5.0).abs() < 1e-4, "x={}", c.eye.x);
+ assert!((c.fovy_rad - 50_f32.to_radians()).abs() < 1e-4);
+ }
+
+ #[test]
+ fn smoothstep_acelera_suave() {
+ let tr = track();
+ // A 1/4 del tiempo, smoothstep(0.25)=0.15625 < 0.25 (arranca lento).
+ let c = tr.sample(0.5);
+ assert!(c.eye.x < 2.5, "debería ir más lento al principio: x={}", c.eye.x);
+ }
+}
diff --git a/llimphi-3d/src/dimensions.rs b/llimphi-3d/src/dimensions.rs
new file mode 100644
index 0000000..3ccd82a
--- /dev/null
+++ b/llimphi-3d/src/dimensions.rs
@@ -0,0 +1,156 @@
+//! Dimensiones / mundos paralelos (M5) — `MOTOR-VOXEL.md` §3.8.
+//!
+//! Una **dimensión = un mundo voxel independiente** con su propio grid, su sol,
+//! su cielo (color de fondo) y sus entidades. "Viajar" = cambiar qué dimensión
+//! renderiza la cámara (un portal = un `switch`). No agrega complejidad de motor
+//! (cada dimensión reusa el `VoxelRenderer` sparse tal cual): es contenido.
+//!
+//! El [`Multiverse`] mantiene N dimensiones y la activa; cada una materializa su
+//! `VoxelRenderer` (su brick pool) perezosamente la primera vez que se la pinta,
+//! y queda "tibia" en memoria para que el switch sea instantáneo.
+
+use crate::camera::Camera3d;
+use crate::voxel::VoxelGrid;
+use crate::voxel_renderer::{Atmosphere, Entity3d, VoxelRenderer};
+
+/// Formato de la textura intermedia de Llimphi (target de `gpu_paint_with`).
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+/// Un mundo voxel independiente.
+pub struct Dimension {
+ pub name: String,
+ pub grid: VoxelGrid,
+ /// Color de fondo (cielo) sugerido para la pasada vello base.
+ pub sky: [u8; 3],
+ /// Dirección hacia el sol de esta dimensión.
+ pub sun_dir: [f32; 3],
+ /// Atmósfera (cielo + niebla) de esta dimensión. Default = niebla off, así
+ /// una dimensión sin configurar se comporta como en M5 (miss → discard).
+ pub atmosphere: Atmosphere,
+ /// Entidades (agentes) de esta dimensión; se copian al renderer por frame.
+ pub entities: Vec,
+ renderer: Option,
+}
+
+impl Dimension {
+ /// Dimensión nueva con cielo/sol por defecto y sin entidades.
+ pub fn new(name: impl Into, grid: VoxelGrid) -> Self {
+ Self {
+ name: name.into(),
+ grid,
+ sky: [18, 22, 32],
+ sun_dir: [0.5, 1.0, 0.35],
+ atmosphere: Atmosphere::default(),
+ entities: Vec::new(),
+ renderer: None,
+ }
+ }
+
+ pub fn with_sky(mut self, sky: [u8; 3]) -> Self {
+ self.sky = sky;
+ self
+ }
+ pub fn with_sun(mut self, sun_dir: [f32; 3]) -> Self {
+ self.sun_dir = sun_dir;
+ self
+ }
+ /// Activa cielo + niebla propios para esta dimensión (el `render` los aplica
+ /// al renderer). Con `fog_density > 0`, el motor pinta su propio cielo en los
+ /// misses (ya no se ve el fondo vello).
+ pub fn with_atmosphere(mut self, atmosphere: Atmosphere) -> Self {
+ self.atmosphere = atmosphere;
+ self
+ }
+ pub fn with_entities(mut self, entities: Vec) -> Self {
+ self.entities = entities;
+ self
+ }
+}
+
+/// Conjunto de dimensiones con una activa. La cámara siempre ve la activa.
+pub struct Multiverse {
+ dims: Vec,
+ active: usize,
+ format: wgpu::TextureFormat,
+}
+
+impl Multiverse {
+ pub fn new(dims: Vec) -> Self {
+ Self {
+ dims,
+ active: 0,
+ format: FMT,
+ }
+ }
+
+ /// Cambia el formato de color del target (default `Rgba8Unorm`, la
+ /// intermedia de Llimphi). Sólo afecta a renderers aún no materializados.
+ pub fn with_format(mut self, format: wgpu::TextureFormat) -> Self {
+ self.format = format;
+ self
+ }
+
+ pub fn count(&self) -> usize {
+ self.dims.len()
+ }
+ pub fn active(&self) -> usize {
+ self.active
+ }
+ pub fn active_name(&self) -> &str {
+ &self.dims[self.active].name
+ }
+ pub fn names(&self) -> Vec {
+ self.dims.iter().map(|d| d.name.clone()).collect()
+ }
+ pub fn skies(&self) -> Vec<[u8; 3]> {
+ self.dims.iter().map(|d| d.sky).collect()
+ }
+
+ /// Viaja a la dimensión `i` (no-op si fuera de rango).
+ pub fn switch(&mut self, i: usize) {
+ if i < self.dims.len() {
+ self.active = i;
+ }
+ }
+ pub fn next(&mut self) {
+ self.active = (self.active + 1) % self.dims.len();
+ }
+ pub fn prev(&mut self) {
+ self.active = (self.active + self.dims.len() - 1) % self.dims.len();
+ }
+
+ pub fn active_dim(&self) -> &Dimension {
+ &self.dims[self.active]
+ }
+ pub fn active_dim_mut(&mut self) -> &mut Dimension {
+ &mut self.dims[self.active]
+ }
+
+ /// Ray-marchea la dimensión activa sobre `target`. Materializa su brick pool
+ /// la primera vez. Firma compatible con la closure de `gpu_paint_with`.
+ pub fn render(
+ &mut self,
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ encoder: &mut wgpu::CommandEncoder,
+ target: &wgpu::TextureView,
+ viewport: (u32, u32),
+ camera: &Camera3d,
+ ) {
+ let fmt = self.format;
+ let d = &mut self.dims[self.active];
+ let r = d
+ .renderer
+ .get_or_insert_with(|| VoxelRenderer::new(device, queue, fmt, &d.grid));
+ r.sun_dir = d.sun_dir;
+ r.atmosphere = d.atmosphere;
+ r.entities = d.entities.clone();
+ r.render(device, queue, encoder, target, viewport, camera);
+ }
+
+ /// Acceso al renderer ya materializado de la dimensión activa (para `sync`
+ /// incremental de mutaciones, stats, etc.). `None` si aún no se pintó.
+ pub fn active_renderer_mut(&mut self) -> Option<&mut VoxelRenderer> {
+ self.dims[self.active].renderer.as_mut()
+ }
+}
diff --git a/llimphi-3d/src/hud.rs b/llimphi-3d/src/hud.rs
new file mode 100644
index 0000000..f0ed77a
--- /dev/null
+++ b/llimphi-3d/src/hud.rs
@@ -0,0 +1,306 @@
+//! `Hud` — un pase **screen-space** mínimo: dibuja rectángulos de color plano
+//! (con alpha) directamente en NDC, *después* del pase 3D, sobre el mismo
+//! target. Es la pieza que faltaba para un **HUD / mira (crosshair)** en primera
+//! persona: el contenido vello del árbol Llimphi queda **debajo** del canvas GPU
+//! full-screen, así que cualquier overlay que deba ir *encima* del ray-march
+//! tiene que pintarse en GPU en la misma closure `gpu_paint_with`, y eso es
+//! justo lo que hace [`Hud::render`].
+//!
+//! Deliberadamente tonto: sin texturas, sin bind groups, sin depth. Geometría
+//! en CPU → un vertex buffer dinámico → un draw. Suficiente para miras, barras,
+//! marcos y **texto** ([`HudQuad::text`], fuente bitmap 5×7 = un quad por píxel
+//! encendido, sin salir del pipeline de quads).
+
+/// Un rectángulo de HUD en **pixels** (origen arriba-izquierda, como la
+/// pantalla), color RGBA lineal `0..1`.
+#[derive(Debug, Clone, Copy)]
+pub struct HudQuad {
+ pub x: f32,
+ pub y: f32,
+ pub w: f32,
+ pub h: f32,
+ pub color: [f32; 4],
+}
+
+impl HudQuad {
+ /// Una **mira (crosshair)** centrada en un viewport `(w, h)`: dos barras
+ /// (horizontal + vertical) de brazo `arm` y grosor `th` pixels.
+ pub fn crosshair(viewport: (u32, u32), arm: f32, th: f32, color: [f32; 4]) -> [HudQuad; 2] {
+ let cx = viewport.0 as f32 * 0.5;
+ let cy = viewport.1 as f32 * 0.5;
+ [
+ HudQuad { x: cx - arm, y: cy - th * 0.5, w: arm * 2.0, h: th, color },
+ HudQuad { x: cx - th * 0.5, y: cy - arm, w: th, h: arm * 2.0, color },
+ ]
+ }
+
+ /// Emite los quads de una cadena con la **fuente bitmap 5×7** embebida
+ /// ([`glyph`]): origen arriba-izquierda en `(x, y)` pixels, cada píxel de
+ /// glifo mide `px` pixels de lado y los caracteres avanzan `6·px` (5 de ancho
+ /// + 1 de espacio). Sólo ASCII; las minúsculas se dibujan en mayúscula y los
+ /// caracteres desconocidos quedan en blanco. Se mantiene dentro del pipeline
+ /// tonto del HUD (un quad por píxel encendido, sin texturas).
+ pub fn text(s: &str, x: f32, y: f32, px: f32, color: [f32; 4]) -> Vec {
+ let mut out = Vec::new();
+ let mut cx = x;
+ for ch in s.chars() {
+ if ch != ' ' {
+ let g = glyph(ch);
+ for (r, row) in g.iter().enumerate() {
+ for c in 0..5u32 {
+ if row & (1 << (4 - c)) != 0 {
+ out.push(HudQuad {
+ x: cx + c as f32 * px,
+ y: y + r as f32 * px,
+ w: px,
+ h: px,
+ color,
+ });
+ }
+ }
+ }
+ }
+ cx += 6.0 * px;
+ }
+ out
+ }
+
+ /// Ancho en pixels que ocuparía `s` con [`text`](Self::text) a tamaño `px`
+ /// (útil para dimensionar un panel de fondo antes de dibujar el texto).
+ pub fn text_width(s: &str, px: f32) -> f32 {
+ s.chars().count() as f32 * 6.0 * px
+ }
+}
+
+/// Mapa de un carácter a su bitmap **5×7**: 7 filas, cada `u8` con los 5 bits
+/// bajos = columnas de izquierda (bit 4) a derecha (bit 0). Cubre `0-9`, `A-Z`
+/// y puntuación común; lo desconocido devuelve un glifo en blanco. Las filas se
+/// escriben en binario para que la forma sea legible en el código.
+fn glyph(c: char) -> [u8; 7] {
+ match c.to_ascii_uppercase() {
+ '0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
+ '1' => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
+ '2' => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
+ '3' => [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
+ '4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
+ '5' => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
+ '6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
+ '7' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
+ '8' => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
+ '9' => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
+ 'A' => [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
+ 'B' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
+ 'C' => [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
+ 'D' => [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110],
+ 'E' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
+ 'F' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
+ 'G' => [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110],
+ 'H' => [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
+ 'I' => [0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
+ 'J' => [0b00111, 0b00010, 0b00010, 0b00010, 0b10010, 0b10010, 0b01100],
+ 'K' => [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
+ 'L' => [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
+ 'M' => [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001],
+ 'N' => [0b10001, 0b11001, 0b10101, 0b10101, 0b10011, 0b10001, 0b10001],
+ 'O' => [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
+ 'P' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
+ 'Q' => [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101],
+ 'R' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
+ 'S' => [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110],
+ 'T' => [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
+ 'U' => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
+ 'V' => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100],
+ 'W' => [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
+ 'X' => [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001],
+ 'Y' => [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100],
+ 'Z' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111],
+ ':' => [0b00000, 0b00100, 0b00000, 0b00000, 0b00100, 0b00000, 0b00000],
+ '.' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b00100],
+ ',' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b00100, 0b01000],
+ '-' => [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000],
+ '+' => [0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000],
+ '/' => [0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000],
+ '(' => [0b00110, 0b01000, 0b01000, 0b01000, 0b01000, 0b01000, 0b00110],
+ ')' => [0b01100, 0b00010, 0b00010, 0b00010, 0b00010, 0b00010, 0b01100],
+ '%' => [0b11001, 0b11001, 0b00010, 0b00100, 0b01000, 0b10011, 0b10011],
+ _ => [0; 7],
+ }
+}
+
+/// Tamaño de un vértice del HUD: `pos: vec2` + `color: vec4`.
+const VSIZE: usize = 2 * 4 + 4 * 4;
+
+/// Renderer de overlay screen-space. Cachea pipeline + un vertex buffer
+/// dinámico que crece según haga falta.
+pub struct Hud {
+ pipeline: wgpu::RenderPipeline,
+ vbuf: wgpu::Buffer,
+ cap: u64,
+}
+
+impl Hud {
+ /// Crea el HUD para el `color_format` del target (el de la intermedia del
+ /// frame). No toca depth: dibuja siempre encima.
+ pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
+ let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("llimphi-3d-hud-shader"),
+ source: wgpu::ShaderSource::Wgsl(WGSL.into()),
+ });
+
+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("llimphi-3d-hud-pl"),
+ bind_group_layouts: &[],
+ push_constant_ranges: &[],
+ });
+
+ let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("llimphi-3d-hud-pipeline"),
+ layout: Some(&pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &shader,
+ entry_point: Some("vs"),
+ compilation_options: Default::default(),
+ buffers: &[wgpu::VertexBufferLayout {
+ array_stride: VSIZE as u64,
+ step_mode: wgpu::VertexStepMode::Vertex,
+ attributes: &[
+ wgpu::VertexAttribute {
+ format: wgpu::VertexFormat::Float32x2,
+ offset: 0,
+ shader_location: 0,
+ },
+ wgpu::VertexAttribute {
+ format: wgpu::VertexFormat::Float32x4,
+ offset: 8,
+ shader_location: 1,
+ },
+ ],
+ }],
+ },
+ primitive: wgpu::PrimitiveState {
+ topology: wgpu::PrimitiveTopology::TriangleList,
+ ..Default::default()
+ },
+ // Sin depth: el HUD va siempre encima del 3D.
+ depth_stencil: None,
+ multisample: wgpu::MultisampleState::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 cap = (64 * 6 * VSIZE) as u64; // ~64 quads sin recrear
+ let vbuf = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("llimphi-3d-hud-vbuf"),
+ size: cap,
+ usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+
+ Self { pipeline, vbuf, cap }
+ }
+
+ /// Dibuja `quads` sobre `target` (color `LoadOp::Load`, sin depth). Firma
+ /// compatible con la closure `gpu_paint_with`: llamar *después* del pase 3D.
+ pub fn render(
+ &mut self,
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ encoder: &mut wgpu::CommandEncoder,
+ target: &wgpu::TextureView,
+ (w, h): (u32, u32),
+ quads: &[HudQuad],
+ ) {
+ if w == 0 || h == 0 || quads.is_empty() {
+ return;
+ }
+
+ // Geometría en CPU: 2 triángulos (6 vértices) por quad, en NDC. El eje Y
+ // de pantalla va hacia abajo; NDC hacia arriba → `1 - 2·y/h`.
+ let (fw, fh) = (w as f32, h as f32);
+ let mut bytes = Vec::with_capacity(quads.len() * 6 * VSIZE);
+ let mut vert = |x_px: f32, y_px: f32, c: [f32; 4]| {
+ let ndc_x = x_px / fw * 2.0 - 1.0;
+ let ndc_y = 1.0 - y_px / fh * 2.0;
+ bytes.extend_from_slice(&ndc_x.to_ne_bytes());
+ bytes.extend_from_slice(&ndc_y.to_ne_bytes());
+ for ch in c {
+ bytes.extend_from_slice(&ch.to_ne_bytes());
+ }
+ };
+ for q in quads {
+ let (x0, y0, x1, y1) = (q.x, q.y, q.x + q.w, q.y + q.h);
+ vert(x0, y0, q.color);
+ vert(x1, y0, q.color);
+ vert(x1, y1, q.color);
+ vert(x0, y0, q.color);
+ vert(x1, y1, q.color);
+ vert(x0, y1, q.color);
+ }
+
+ // Crecer el buffer si hiciera falta (raro: la mira son 2 quads).
+ if bytes.len() as u64 > self.cap {
+ self.cap = (bytes.len() as u64).next_power_of_two();
+ self.vbuf = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("llimphi-3d-hud-vbuf"),
+ size: self.cap,
+ usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ }
+ queue.write_buffer(&self.vbuf, 0, &bytes);
+
+ let count = (quads.len() * 6) as u32;
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("llimphi-3d-hud-pass"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: target,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Load,
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ pass.set_pipeline(&self.pipeline);
+ pass.set_vertex_buffer(0, self.vbuf.slice(..bytes.len() as u64));
+ pass.draw(0..count, 0..1);
+ }
+}
+
+const WGSL: &str = r#"
+struct VIn {
+ @location(0) pos: vec2,
+ @location(1) color: vec4,
+};
+struct VOut {
+ @builtin(position) clip: vec4,
+ @location(0) color: vec4,
+};
+
+@vertex
+fn vs(in: VIn) -> VOut {
+ var out: VOut;
+ out.clip = vec4(in.pos, 0.0, 1.0);
+ out.color = in.color;
+ return out;
+}
+
+@fragment
+fn fs(in: VOut) -> @location(0) vec4 {
+ return in.color;
+}
+"#;
diff --git a/llimphi-3d/src/lib.rs b/llimphi-3d/src/lib.rs
new file mode 100644
index 0000000..c2666fa
--- /dev/null
+++ b/llimphi-3d/src/lib.rs
@@ -0,0 +1,54 @@
+//! # 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
+
+pub use glam;
+pub use wgpu;
+
+mod camera;
+mod cinema;
+mod dimensions;
+mod hud;
+mod mesh;
+mod renderer;
+mod scene;
+mod voxel;
+mod voxel_renderer;
+
+pub use camera::Camera3d;
+pub use cinema::{CamKey, CameraTrack};
+pub use dimensions::{Dimension, Multiverse};
+pub use hud::{Hud, HudQuad};
+pub use mesh::{cube, push_cube, Vertex3d, CUBE_INDICES};
+pub use renderer::Renderer3d;
+pub use scene::Scene3d;
+pub use voxel::{DirtyBox, VoxelGrid};
+pub use voxel_renderer::{
+ Atmosphere, Entity3d, PointLight, VoxelRenderer, VOXEL_BRICK, VOXEL_MAX_LIGHTS,
+};
diff --git a/llimphi-3d/src/mesh.rs b/llimphi-3d/src/mesh.rs
new file mode 100644
index 0000000..18d1111
--- /dev/null
+++ b/llimphi-3d/src/mesh.rs
@@ -0,0 +1,85 @@
+//! Geometría de mallas: el vértice 3D ([`Vertex3d`]), un cubo de prueba
+//! ([`cube`]) y un compositor de cajas transformadas ([`push_cube`]) para armar
+//! mallas multi-caja en CPU — p.ej. un **muñeco articulado** (cabeza/torso/
+//! miembros como cajas rotadas en sus articulaciones).
+//!
+//! Sigue el idiom de `llimphi-raster::gpu` (subir a GPU vía `to_ne_bytes`, sin
+//! `bytemuck`) para no agregar una dependencia nueva al workspace.
+
+use glam::{Mat4, Vec3};
+
+/// Vértice 3D: posición en mundo + color RGB lineal.
+#[derive(Debug, Clone, Copy)]
+pub struct Vertex3d {
+ pub pos: [f32; 3],
+ pub color: [f32; 3],
+}
+
+impl Vertex3d {
+ /// Tamaño en bytes de un vértice empaquetado (`6 × f32`).
+ pub const SIZE: usize = 6 * 4;
+
+ /// Vuelca este vértice al buffer en orden `pos.xyz, color.rgb` (native
+ /// endian, como hace `GpuBatch`).
+ pub fn write_to(&self, out: &mut Vec) {
+ 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());
+ }
+ }
+}
+
+/// Las 8 esquinas del cubo unitario centrado en el origen (lado 1, `-0.5..0.5`).
+const CUBE_CORNERS: [[f32; 3]; 8] = [
+ [-0.5, -0.5, -0.5],
+ [0.5, -0.5, -0.5],
+ [0.5, 0.5, -0.5],
+ [-0.5, 0.5, -0.5],
+ [-0.5, -0.5, 0.5],
+ [0.5, -0.5, 0.5],
+ [0.5, 0.5, 0.5],
+ [-0.5, 0.5, 0.5],
+];
+
+/// Los 36 índices (12 triángulos) del cubo, winding CCW visto desde afuera.
+#[rustfmt::skip]
+pub const CUBE_INDICES: [u16; 36] = [
+ 0, 2, 1, 0, 3, 2, // -Z (atrás)
+ 4, 5, 6, 4, 6, 7, // +Z (frente)
+ 0, 4, 7, 0, 7, 3, // -X (izquierda)
+ 1, 2, 6, 1, 6, 5, // +X (derecha)
+ 0, 1, 5, 0, 5, 4, // -Y (abajo)
+ 3, 7, 6, 3, 6, 2, // +Y (arriba)
+];
+
+/// Cubo unitario centrado en el origen (lado 1, de `-0.5` a `0.5`). 8 vértices
+/// coloreados por su posición (`color = pos + 0.5`) → un degradé que deja ver
+/// las tres caras visibles distintas. 36 índices (12 triángulos), winding CCW.
+pub fn cube() -> (Vec, Vec) {
+ let verts = CUBE_CORNERS
+ .iter()
+ .map(|&[x, y, z]| Vertex3d {
+ pos: [x, y, z],
+ color: [x + 0.5, y + 0.5, z + 0.5],
+ })
+ .collect();
+ (verts, CUBE_INDICES.to_vec())
+}
+
+/// 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
+/// índices con la base reubicada. Para un miembro articulado, `m` suele ser
+/// `T(articulación) · R(ángulo) · T(0,-largo/2,0) · S(tamaño)`.
+pub fn push_cube(verts: &mut Vec, indices: &mut Vec, m: Mat4, color: [f32; 3]) {
+ let base = verts.len() as u16;
+ for c in CUBE_CORNERS {
+ let p = m.transform_point3(Vec3::from_array(c));
+ verts.push(Vertex3d { pos: p.to_array(), color });
+ }
+ for i in CUBE_INDICES {
+ indices.push(base + i);
+ }
+}
diff --git a/llimphi-3d/src/renderer.rs b/llimphi-3d/src/renderer.rs
new file mode 100644
index 0000000..137e48c
--- /dev/null
+++ b/llimphi-3d/src/renderer.rs
@@ -0,0 +1,314 @@
+//! `Renderer3d` — pipeline wgpu mínimo que dibuja geometría 3D indexada con
+//! test de profundidad sobre la textura intermedia del frame de Llimphi.
+//!
+//! La firma de [`Renderer3d::render`] es la que pide la closure de
+//! `View::gpu_paint_with` (`device, queue, encoder, target_view, (w, h)`), más
+//! la cámara — así un nodo 3D entra en el árbol `View` sin tocar el
+//! runtime. Mantiene su **propio depth buffer** (recreado al cambiar de
+//! tamaño); el color se compone con `LoadOp::Load` para preservar la UI vello
+//! que ya está debajo.
+
+use glam::Mat4;
+
+use crate::camera::Camera3d;
+use crate::mesh::{cube, Vertex3d};
+use crate::scene::{ensure_depth, DepthBuffer, DEPTH_FORMAT};
+
+/// Renderer de **mallas** indexadas (por defecto un cubo) visto desde una
+/// [`Camera3d`]. Cachea pipeline, buffers de geometría, uniform y (para el
+/// camino standalone) un depth propio. En [`Scene3d`](crate::Scene3d) comparte
+/// el depth con el pase de voxels para ocluirse mutuamente.
+///
+/// `model` ubica la malla en el mundo (default identidad): `mvp = view_proj ·
+/// model`, así una misma malla se instancia/posiciona sin reconstruir buffers.
+pub struct Renderer3d {
+ pipeline: wgpu::RenderPipeline,
+ vbuf: wgpu::Buffer,
+ ibuf: wgpu::Buffer,
+ index_count: u32,
+ ubuf: wgpu::Buffer,
+ bind_group: wgpu::BindGroup,
+ model: Mat4,
+ depth: Option,
+}
+
+impl Renderer3d {
+ /// Crea el renderer para un `color_format` dado (el de la textura
+ /// intermedia del frame — `Rgba8Unorm` en headless, el de la surface en
+ /// vivo). Arranca con el cubo de prueba cargado.
+ pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
+ let (verts, indices) = cube();
+ Self::with_mesh(device, color_format, &verts, &indices)
+ }
+
+ /// Igual que [`Self::new`] pero con una malla arbitraria.
+ pub fn with_mesh(
+ device: &wgpu::Device,
+ color_format: wgpu::TextureFormat,
+ verts: &[Vertex3d],
+ indices: &[u16],
+ ) -> Self {
+ let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("llimphi-3d-shader"),
+ source: wgpu::ShaderSource::Wgsl(WGSL.into()),
+ });
+
+ let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("llimphi-3d-bgl"),
+ 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 pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("llimphi-3d-pl"),
+ bind_group_layouts: &[&bind_layout],
+ push_constant_ranges: &[],
+ });
+
+ let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("llimphi-3d-pipeline"),
+ layout: Some(&pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &shader,
+ entry_point: Some("vs"),
+ compilation_options: Default::default(),
+ buffers: &[wgpu::VertexBufferLayout {
+ array_stride: Vertex3d::SIZE as u64,
+ step_mode: wgpu::VertexStepMode::Vertex,
+ attributes: &[
+ wgpu::VertexAttribute {
+ format: wgpu::VertexFormat::Float32x3,
+ offset: 0,
+ shader_location: 0,
+ },
+ wgpu::VertexAttribute {
+ format: wgpu::VertexFormat::Float32x3,
+ offset: 12,
+ shader_location: 1,
+ },
+ ],
+ }],
+ },
+ primitive: wgpu::PrimitiveState {
+ topology: wgpu::PrimitiveTopology::TriangleList,
+ strip_index_format: None,
+ front_face: wgpu::FrontFace::Ccw,
+ // M0 sin cull: el depth test ya resuelve la oclusión y nos
+ // ahorra bugs de winding al sumar mallas. El cull entra en M1+.
+ cull_mode: None,
+ unclipped_depth: false,
+ polygon_mode: wgpu::PolygonMode::Fill,
+ conservative: false,
+ },
+ depth_stencil: Some(wgpu::DepthStencilState {
+ format: DEPTH_FORMAT,
+ depth_write_enabled: true,
+ depth_compare: wgpu::CompareFunction::Less,
+ stencil: wgpu::StencilState::default(),
+ bias: wgpu::DepthBiasState::default(),
+ }),
+ multisample: wgpu::MultisampleState::default(),
+ fragment: Some(wgpu::FragmentState {
+ module: &shader,
+ entry_point: Some("fs"),
+ compilation_options: Default::default(),
+ targets: &[Some(wgpu::ColorTargetState {
+ format: color_format,
+ // Opaco: el cubo reemplaza el fondo vello donde lo cubre.
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ }),
+ multiview: None,
+ cache: None,
+ });
+
+ // Geometría → buffers (idiom `to_ne_bytes`, sin bytemuck).
+ let mut vbytes = Vec::with_capacity(verts.len() * Vertex3d::SIZE);
+ for v in verts {
+ v.write_to(&mut vbytes);
+ }
+ let vbuf = create_buffer_init(device, "llimphi-3d-vbuf", wgpu::BufferUsages::VERTEX, &vbytes);
+
+ let mut ibytes = Vec::with_capacity(indices.len() * 2);
+ for &i in indices {
+ ibytes.extend_from_slice(&i.to_ne_bytes());
+ }
+ let ibuf = create_buffer_init(device, "llimphi-3d-ibuf", wgpu::BufferUsages::INDEX, &ibytes);
+
+ let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("llimphi-3d-ubuf"),
+ size: 64, // una mat4x4
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+
+ let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("llimphi-3d-bg"),
+ layout: &bind_layout,
+ entries: &[wgpu::BindGroupEntry {
+ binding: 0,
+ resource: ubuf.as_entire_binding(),
+ }],
+ });
+
+ Self {
+ pipeline,
+ vbuf,
+ ibuf,
+ index_count: indices.len() as u32,
+ ubuf,
+ bind_group,
+ model: Mat4::IDENTITY,
+ depth: None,
+ }
+ }
+
+ /// Ubica la malla en el mundo (`mvp = view_proj · model`). Default identidad.
+ pub fn set_model(&mut self, model: Mat4) {
+ self.model = model;
+ }
+
+ /// Reemplaza la geometría (recrea los buffers de vértices/índices). Pensado
+ /// para mallas que cambian cada frame — p.ej. un **muñeco articulado** cuya
+ /// pose se rehornea en CPU (limbos rotados) y se vuelve a subir. Las mallas
+ /// son chicas (decenas-cientos de vértices), así que recrear los buffers por
+ /// frame es despreciable; el pipeline/uniform/bind-group se conservan.
+ pub fn set_geometry(&mut self, device: &wgpu::Device, verts: &[Vertex3d], indices: &[u16]) {
+ let mut vbytes = Vec::with_capacity(verts.len() * Vertex3d::SIZE);
+ for v in verts {
+ v.write_to(&mut vbytes);
+ }
+ self.vbuf =
+ create_buffer_init(device, "llimphi-3d-vbuf", wgpu::BufferUsages::VERTEX, &vbytes);
+
+ let mut ibytes = Vec::with_capacity(indices.len() * 2);
+ for &i in indices {
+ ibytes.extend_from_slice(&i.to_ne_bytes());
+ }
+ self.ibuf =
+ create_buffer_init(device, "llimphi-3d-ibuf", wgpu::BufferUsages::INDEX, &ibytes);
+ self.index_count = indices.len() as u32;
+ }
+
+ /// Sube el uniform del frame (`mvp = view_proj · model`). Lo llama
+ /// [`Self::render`] y [`Scene3d`](crate::Scene3d). `aspect` = w/h.
+ pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) {
+ let mvp = camera.view_proj(aspect) * self.model;
+ // glam es column-major; el shader WGSL espera column-major → upload tal cual.
+ let mut ubytes = Vec::with_capacity(64);
+ for v in mvp.to_cols_array() {
+ ubytes.extend_from_slice(&v.to_ne_bytes());
+ }
+ queue.write_buffer(&self.ubuf, 0, &ubytes);
+ }
+
+ /// Dibuja la malla indexada en un pase **ya abierto** (color + depth). Lo usa
+ /// [`Scene3d`](crate::Scene3d) para compartir el pase con los voxels.
+ /// Requiere `upload` previo en el mismo frame.
+ pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
+ pass.set_pipeline(&self.pipeline);
+ pass.set_bind_group(0, &self.bind_group, &[]);
+ pass.set_vertex_buffer(0, self.vbuf.slice(..));
+ pass.set_index_buffer(self.ibuf.slice(..), wgpu::IndexFormat::Uint16);
+ pass.draw_indexed(0..self.index_count, 0, 0..1);
+ }
+
+ /// Dibuja la malla sola sobre `target` (camino standalone, depth propio).
+ /// Firma compatible con `View::gpu_paint_with`; color preservado
+ /// (`LoadOp::Load`), depth propio limpiado cada frame.
+ pub fn render(
+ &mut self,
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ encoder: &mut wgpu::CommandEncoder,
+ target: &wgpu::TextureView,
+ (w, h): (u32, u32),
+ camera: &Camera3d,
+ ) {
+ if w == 0 || h == 0 {
+ return;
+ }
+ self.upload(queue, w as f32 / h as f32, camera);
+ ensure_depth(&mut self.depth, device, w, h);
+ let depth_view = &self.depth.as_ref().unwrap().view;
+
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("llimphi-3d-pass"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: target,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Load,
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+ view: depth_view,
+ depth_ops: Some(wgpu::Operations {
+ load: wgpu::LoadOp::Clear(1.0),
+ store: wgpu::StoreOp::Store,
+ }),
+ stencil_ops: None,
+ }),
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ self.draw(&mut pass);
+ }
+}
+
+/// Crea un buffer ya inicializado con `data` (sin `wgpu::util::DeviceExt`, para
+/// no arrastrar la feature `util`): `mapped_at_creation` + copia + `unmap`.
+fn create_buffer_init(
+ device: &wgpu::Device,
+ label: &str,
+ usage: wgpu::BufferUsages,
+ data: &[u8],
+) -> wgpu::Buffer {
+ let buf = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some(label),
+ size: data.len() as u64,
+ usage,
+ mapped_at_creation: true,
+ });
+ buf.slice(..).get_mapped_range_mut().copy_from_slice(data);
+ buf.unmap();
+ buf
+}
+
+const WGSL: &str = r#"
+struct Uniforms { mvp: mat4x4 };
+@group(0) @binding(0) var u: Uniforms;
+
+struct VIn {
+ @location(0) pos: vec3,
+ @location(1) color: vec3,
+};
+struct VOut {
+ @builtin(position) clip: vec4,
+ @location(0) color: vec3,
+};
+
+@vertex
+fn vs(in: VIn) -> VOut {
+ var out: VOut;
+ out.clip = u.mvp * vec4(in.pos, 1.0);
+ out.color = in.color;
+ return out;
+}
+
+@fragment
+fn fs(in: VOut) -> @location(0) vec4 {
+ return vec4(in.color, 1.0);
+}
+"#;
diff --git a/llimphi-3d/src/scene.rs b/llimphi-3d/src/scene.rs
new file mode 100644
index 0000000..b436f22
--- /dev/null
+++ b/llimphi-3d/src/scene.rs
@@ -0,0 +1,186 @@
+//! `Scene3d` — orquestador de una escena 3D **general**: compone, en un único
+//! pase con **depth buffer compartido**, el render volumétrico de voxels
+//! ([`VoxelRenderer`](crate::VoxelRenderer)) y mallas de triángulos
+//! ([`Renderer3d`](crate::Renderer3d)). Es el keystone que vuelve a `llimphi-3d`
+//! un motor 3D general y no "sólo voxels": voxels y mallas se **ocluyen
+//! correctamente entre sí** porque ambos escriben/testean el mismo depth.
+//!
+//! La firma de [`Scene3d::render`] es compatible con la closure de
+//! `View::gpu_paint_with` (más los renderers a componer): el `Scene3d` posee el
+//! depth y abre el pase; cada renderer aporta su `upload` (uniforms) + `draw`
+//! (en el pase ya abierto).
+
+use crate::camera::Camera3d;
+use crate::renderer::Renderer3d;
+use crate::voxel_renderer::VoxelRenderer;
+
+/// Formato del depth buffer de toda la escena 3D (debe coincidir entre el
+/// pipeline de voxels, el de mallas y la textura de depth).
+pub(crate) const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
+
+/// Depth attachment cacheado, recreado cuando cambia el tamaño del viewport.
+pub(crate) struct DepthBuffer {
+ pub view: wgpu::TextureView,
+ w: u32,
+ h: u32,
+}
+
+/// Asegura que `slot` tenga un depth buffer de `w×h` (lo recrea si cambió).
+pub(crate) fn ensure_depth(
+ slot: &mut Option,
+ device: &wgpu::Device,
+ w: u32,
+ h: u32,
+) {
+ if matches!(slot, Some(d) if d.w == w && d.h == h) {
+ return;
+ }
+ let tex = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("llimphi-3d-depth"),
+ size: wgpu::Extent3d {
+ width: w,
+ height: h,
+ depth_or_array_layers: 1,
+ },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: DEPTH_FORMAT,
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
+ view_formats: &[],
+ });
+ let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
+ *slot = Some(DepthBuffer { view, w, h });
+}
+
+/// Escena 3D que comparte un depth buffer entre el pase de voxels y el de
+/// mallas. Sólo posee el depth; los renderers los aporta el llamador por
+/// referencia en cada frame (así la app conserva la propiedad y los muta).
+#[derive(Default)]
+pub struct Scene3d {
+ depth: Option,
+}
+
+impl Scene3d {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Compone la escena sobre `target` (textura intermedia del frame). Primero
+ /// ray-marchea los voxels (escriben color + profundidad), luego dibuja las
+ /// mallas (testean contra esa profundidad) — todo en un pase con el depth
+ /// compartido, limpiado a lejano (`1.0`) al abrirlo. El color se preserva
+ /// (`LoadOp::Load`) para no pisar la UI vello de abajo.
+ ///
+ /// Firma compatible con `View::gpu_paint_with` más los renderers a componer.
+ #[allow(clippy::too_many_arguments)]
+ pub fn render(
+ &mut self,
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ encoder: &mut wgpu::CommandEncoder,
+ target: &wgpu::TextureView,
+ (w, h): (u32, u32),
+ camera: &Camera3d,
+ voxel: Option<&VoxelRenderer>,
+ meshes: &[&Renderer3d],
+ ) {
+ // El caso por defecto: la escena ocupa todo el target.
+ self.render_in(
+ device,
+ queue,
+ encoder,
+ target,
+ (w, h),
+ (0.0, 0.0, w as f32, h as f32),
+ camera,
+ voxel,
+ meshes,
+ );
+ }
+
+ /// Como [`render`](Self::render) pero **confina** la escena a la sub-región
+ /// `rect = (x, y, w, h)` (en px del target, esquina sup-izq), vía
+ /// `set_viewport` + `set_scissor_rect`. Es lo que permite montar el 3D en un
+ /// **panel** de una UI (un canvas que no ocupa toda la ventana) sin pisar el
+ /// chrome alrededor: la pasada de ray-march/mallas pinta sólo dentro del rect,
+ /// con el aspect del rect (no el de la ventana). `target`/`viewport` siguen
+ /// siendo el frame completo (load-preserve del chrome ya rasterizado).
+ #[allow(clippy::too_many_arguments)]
+ pub fn render_in(
+ &mut self,
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ encoder: &mut wgpu::CommandEncoder,
+ target: &wgpu::TextureView,
+ (w, h): (u32, u32),
+ rect: (f32, f32, f32, f32),
+ camera: &Camera3d,
+ voxel: Option<&VoxelRenderer>,
+ meshes: &[&Renderer3d],
+ ) {
+ if w == 0 || h == 0 {
+ return;
+ }
+ let (rx, ry, rw, rh) = rect;
+ if rw < 1.0 || rh < 1.0 {
+ return;
+ }
+ // El aspect es el del rect (el viewport mapea NDC a esa sub-región).
+ let aspect = rw / rh;
+
+ // Subir uniforms antes de abrir el pase (queue.write_buffer se ordena
+ // antes del submit).
+ if let Some(v) = voxel {
+ v.upload(queue, aspect, camera);
+ }
+ for m in meshes {
+ m.upload(queue, aspect, camera);
+ }
+
+ ensure_depth(&mut self.depth, device, w, h);
+ let depth_view = &self.depth.as_ref().unwrap().view;
+
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("llimphi-3d-scene-pass"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: target,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Load,
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+ view: depth_view,
+ depth_ops: Some(wgpu::Operations {
+ load: wgpu::LoadOp::Clear(1.0),
+ store: wgpu::StoreOp::Store,
+ }),
+ stencil_ops: None,
+ }),
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+
+ // Viewport (mapeo NDC→rect) + scissor (recorte físico al rect, clampeado a
+ // los límites del attachment).
+ pass.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(w as f32 - sx)).max(0.0) as u32;
+ let sh = (rh.min(h as f32 - sy)).max(0.0) as u32;
+ if sw == 0 || sh == 0 {
+ return;
+ }
+ pass.set_scissor_rect(sx as u32, sy as u32, sw, sh);
+
+ if let Some(v) = voxel {
+ v.draw(&mut pass);
+ }
+ for m in meshes {
+ m.draw(&mut pass);
+ }
+ }
+}
diff --git a/llimphi-3d/src/voxel.rs b/llimphi-3d/src/voxel.rs
new file mode 100644
index 0000000..029dc0d
--- /dev/null
+++ b/llimphi-3d/src/voxel.rs
@@ -0,0 +1,296 @@
+//! `VoxelGrid` — grid de voxels denso y acotado (CPU side). Cada voxel es
+//! RGBA8: `rgb` = color, `a` = ocupación (`0` vacío, `>0` sólido). Se sube a
+//! una textura 3D de GPU que el shader ray-march recorre por DDA.
+//!
+//! M1 es **denso** a propósito (lo más simple que funciona). El salto a sparse
+//! (SVO/brickmap, saltar el aire) es M2 — ver `MOTOR-VOXEL.md` §11.2.
+//!
+//! M3 agrega **dirty tracking**: cada `set`/`clear` expande una caja AABB de la
+//! región cambiada. `VoxelRenderer::sync` sube sólo esa sub-caja (fina + bricks
+//! gruesos afectados) — la actualización incremental que reemplaza al re-mesh.
+
+/// Caja AABB de voxels cambiados desde el último `take_dirty`: `[xmin, ymin,
+/// zmin, xmax, ymax, zmax]` inclusiva.
+pub type DirtyBox = [u32; 6];
+
+/// Grid denso de voxels RGBA8. Índice lineal `x + y*dx + z*dx*dy` (x contiguo),
+/// que es justo el layout que espera `queue.write_texture` (filas en x, luego y,
+/// luego capas en z).
+#[derive(Clone)]
+pub struct VoxelGrid {
+ dim: [u32; 3],
+ data: Vec<[u8; 4]>,
+ /// AABB de voxels mutados desde el último `take_dirty`. `None` = sin cambios.
+ dirty: Option,
+}
+
+impl VoxelGrid {
+ /// Grid vacío de `dim = [dx, dy, dz]` voxels.
+ pub fn new(dim: [u32; 3]) -> Self {
+ let n = (dim[0] * dim[1] * dim[2]) as usize;
+ Self {
+ dim,
+ data: vec![[0, 0, 0, 0]; n],
+ dirty: None,
+ }
+ }
+
+ /// Dimensiones `[dx, dy, dz]`.
+ pub fn dim(&self) -> [u32; 3] {
+ self.dim
+ }
+
+ #[inline]
+ fn idx(&self, x: u32, y: u32, z: u32) -> usize {
+ (x + y * self.dim[0] + z * self.dim[0] * self.dim[1]) as usize
+ }
+
+ #[inline]
+ fn mark_dirty(&mut self, x: u32, y: u32, z: u32) {
+ match &mut self.dirty {
+ None => self.dirty = Some([x, y, z, x, y, z]),
+ Some(d) => {
+ d[0] = d[0].min(x);
+ d[1] = d[1].min(y);
+ d[2] = d[2].min(z);
+ d[3] = d[3].max(x);
+ d[4] = d[4].max(y);
+ d[5] = d[5].max(z);
+ }
+ }
+ }
+
+ /// Toma y limpia la caja de cambios pendientes. `VoxelRenderer::sync` la usa
+ /// para subir sólo lo mutado. `None` si no hubo cambios desde la última toma.
+ pub fn take_dirty(&mut self) -> Option {
+ self.dirty.take()
+ }
+
+ /// Descarta los cambios pendientes sin subirlos (tras un upload completo, el
+ /// estado inicial ya está en GPU).
+ pub fn reset_dirty(&mut self) {
+ self.dirty = None;
+ }
+
+ /// 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];
+ self.mark_dirty(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.
+ pub fn clear_all(&mut self) {
+ for px in &mut self.data {
+ *px = [0, 0, 0, 0];
+ }
+ self.dirty = Some([0, 0, 0, self.dim[0] - 1, self.dim[1] - 1, self.dim[2] - 1]);
+ }
+
+ /// Vacía un voxel.
+ pub fn clear(&mut self, x: u32, y: u32, z: u32) {
+ 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.mark_dirty(x, y, z);
+ }
+ }
+
+ #[inline]
+ fn solid(&self, x: u32, y: u32, z: u32) -> bool {
+ self.data[self.idx(x, y, z)][3] > 0
+ }
+
+ /// `true` si el voxel `(x,y,z)` es sólido. Fuera de rango → `false` (el
+ /// "afuera" del mundo es aire). Lo usa el raycast de `llimphi-voxel` para
+ /// picking/edición (mirar → bloque).
+ #[inline]
+ pub fn is_solid(&self, x: i32, y: i32, z: i32) -> bool {
+ if x < 0 || y < 0 || z < 0 {
+ return false;
+ }
+ let (x, y, z) = (x as u32, y as u32, z as u32);
+ x < self.dim[0] && y < self.dim[1] && z < self.dim[2] && self.solid(x, y, z)
+ }
+
+ /// Color RGBA del voxel `(x,y,z)`, o `None` fuera de rango. `a = 0` = aire.
+ pub fn get(&self, x: u32, y: u32, z: u32) -> Option<[u8; 4]> {
+ (x < self.dim[0] && y < self.dim[1] && z < self.dim[2]).then(|| self.data[self.idx(x, y, z)])
+ }
+
+ /// Altura del voxel sólido más alto en la columna `(x, z)` (escaneando de
+ /// arriba hacia abajo), o `None` si la columna está vacía. Útil para posar
+ /// una cámara/entidad sobre el terreno sin meterla dentro de la roca.
+ pub fn height_at(&self, x: u32, z: u32) -> Option {
+ if x >= self.dim[0] || z >= self.dim[2] {
+ return None;
+ }
+ (0..self.dim[1]).rev().find(|&y| self.solid(x, y, z))
+ }
+
+ /// Mapa de ocupación grueso por *bricks* de `brick³` voxels (M2): un texel
+ /// por brick, `255` si el brick contiene algún voxel sólido, `0` si está
+ /// todo vacío. El shader marcha primero esta grilla gruesa y se salta los
+ /// bricks vacíos enteros en un paso (empty-space skipping). Devuelve
+ /// `(dim_grueso, bytes R8)` con índice `cx + cy*cdx + cz*cdx*cdy`.
+ pub fn coarse_occupancy(&self, brick: u32) -> ([u32; 3], Vec) {
+ let b = brick.max(1);
+ let cdim = [
+ self.dim[0].div_ceil(b),
+ self.dim[1].div_ceil(b),
+ self.dim[2].div_ceil(b),
+ ];
+ let mut out = vec![0u8; (cdim[0] * cdim[1] * cdim[2]) as usize];
+ for z in 0..self.dim[2] {
+ for y in 0..self.dim[1] {
+ for x in 0..self.dim[0] {
+ if self.solid(x, y, z) {
+ let (cx, cy, cz) = (x / b, y / b, z / b);
+ out[(cx + cy * cdim[0] + cz * cdim[0] * cdim[1]) as usize] = 255;
+ }
+ }
+ }
+ }
+ (cdim, out)
+ }
+
+ /// `255` si el brick `(cx,cy,cz)` (tamaño `b`) tiene algún voxel sólido,
+ /// `0` si está todo vacío. Lo usa el brick pool para decidir si un brick
+ /// necesita slot.
+ pub fn brick_occupied(&self, b: u32, cx: u32, cy: u32, cz: u32) -> u8 {
+ let (x0, y0, z0) = (cx * b, cy * b, cz * b);
+ for z in z0..(z0 + b).min(self.dim[2]) {
+ for y in y0..(y0 + b).min(self.dim[1]) {
+ for x in x0..(x0 + b).min(self.dim[0]) {
+ if self.solid(x, y, z) {
+ return 255;
+ }
+ }
+ }
+ }
+ 0
+ }
+
+ /// Extrae los voxels de un brick `(cx,cy,cz)` de lado `brick` como RGBA
+ /// plano (`brick³` voxels, x contiguo), padeando con vacío los voxels fuera
+ /// del grid (bricks de borde cuando `dim` no es múltiplo de `brick`). Es la
+ /// unidad de subida al *pool* sparse (un slot del atlas = un brick).
+ pub fn extract_brick(&self, brick: u32, cx: u32, cy: u32, cz: u32) -> Vec {
+ let b = brick;
+ let mut out = vec![0u8; (b * b * b * 4) 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 px = self.data[self.idx(x, y, z)];
+ let o = ((lx + ly * b + lz * b * b) * 4) as usize;
+ out[o..o + 4].copy_from_slice(&px);
+ }
+ }
+ }
+ }
+ 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 {
+ let mut out = Vec::with_capacity((ext[0] * ext[1] * ext[2] * 4) as usize);
+ for z in origin[2]..origin[2] + ext[2] {
+ for y in origin[1]..origin[1] + ext[1] {
+ let row = self.idx(origin[0], y, z);
+ for i in 0..ext[0] as usize {
+ out.extend_from_slice(&self.data[row + i]);
+ }
+ }
+ }
+ out
+ }
+
+ /// Recalcula la ocupación gruesa de la caja de bricks `[cmin, cmin+cext)` y
+ /// la devuelve contigua (R8) para subir sólo esos bricks (M3).
+ pub fn coarse_region(&self, brick: u32, cmin: [u32; 3], cext: [u32; 3]) -> Vec {
+ let b = brick.max(1);
+ let mut out = Vec::with_capacity((cext[0] * cext[1] * cext[2]) as usize);
+ for cz in cmin[2]..cmin[2] + cext[2] {
+ for cy in cmin[1]..cmin[1] + cext[1] {
+ for cx in cmin[0]..cmin[0] + cext[0] {
+ out.push(self.brick_occupied(b, cx, cy, cz));
+ }
+ }
+ }
+ out
+ }
+
+ /// Bytes RGBA planos listos para `queue.write_texture`.
+ pub fn bytes(&self) -> &[u8] {
+ // `[u8;4]` es contiguo: reinterpretamos el Vec como bytes planos.
+ // SAFETY: `[u8;4]` no tiene padding; len*4 bytes válidos.
+ unsafe {
+ std::slice::from_raw_parts(self.data.as_ptr() as *const u8, self.data.len() * 4)
+ }
+ }
+
+ /// Escena de prueba para M1: un piso de 2 capas + una esfera coloreada por
+ /// posición flotando en el centro. Pone a prueba el DDA (atraviesa aire,
+ /// pega en piso y en esfera) y el sombreado por normal de cara.
+ pub fn demo_scene(dim: [u32; 3]) -> Self {
+ let mut g = Self::new(dim);
+ let [dx, dy, dz] = dim;
+
+ // Piso: 2 capas grises abajo, con un leve damero para leer la perspectiva.
+ for z in 0..dz {
+ for x in 0..dx {
+ let chk = ((x / 4 + z / 4) % 2) == 0;
+ let base = if chk { 70 } else { 95 };
+ for y in 0..2 {
+ g.set(x, y, z, [base, base + 8, base + 16]);
+ }
+ }
+ }
+
+ // Esfera centrada, color por posición normalizada.
+ let cx = dx as f32 / 2.0;
+ let cy = dy as f32 * 0.55;
+ let cz = dz as f32 / 2.0;
+ let r = (dx.min(dy).min(dz) as f32) * 0.3;
+ for z in 0..dz {
+ for y in 0..dy {
+ for x in 0..dx {
+ let (fx, fy, fz) = (x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5);
+ let d = ((fx - cx).powi(2) + (fy - cy).powi(2) + (fz - cz).powi(2)).sqrt();
+ if d <= r {
+ let rr = (fx / dx as f32 * 255.0) as u8;
+ let gg = (fy / dy as f32 * 255.0) as u8;
+ let bb = (fz / dz as f32 * 255.0) as u8;
+ g.set(x, y, z, [rr, gg, bb]);
+ }
+ }
+ }
+ }
+
+ // Pilares: dan rincones para el AO y proyectan/reciben sombras.
+ let pillars: [(u32, u32, u32, [u8; 3]); 3] = [
+ (dx / 5, dz / 4, dy * 7 / 10, [200, 120, 90]),
+ (dx * 4 / 5, dz / 3, dy / 2, [110, 170, 120]),
+ (dx / 3, dz * 4 / 5, dy * 3 / 5, [120, 130, 210]),
+ ];
+ for (px, pz, ph, col) in pillars {
+ for y in 2..(2 + ph).min(dy) {
+ for dxx in 0..3u32 {
+ for dzz in 0..3u32 {
+ g.set(px + dxx, y, pz + dzz, col);
+ }
+ }
+ }
+ }
+ // Estado inicial: el upload completo lo cubre, no es "mutación".
+ g.reset_dirty();
+ g
+ }
+}
diff --git a/llimphi-3d/src/voxel_renderer.rs b/llimphi-3d/src/voxel_renderer.rs
new file mode 100644
index 0000000..f64156b
--- /dev/null
+++ b/llimphi-3d/src/voxel_renderer.rs
@@ -0,0 +1,1320 @@
+//! `VoxelRenderer` — render por **ray-marching** de un [`VoxelGrid`], con
+//! almacenamiento **sparse de verdad** (brick pool).
+//!
+//! No mesha (ruta elegida en `MOTOR-VOXEL.md` §11.1): el rayo se marcha por la
+//! estructura y el color sale del voxel golpeado.
+//!
+//! Evolución de la memoria:
+//! - **M1** denso (toda la grilla, incluido el aire, en una textura 3D).
+//! - **M2** traversal sparse (DDA de dos niveles sobre un mapa grueso).
+//! - **Ahora (brick pool, prereq de M5/M6)**: la memoria también es sparse.
+//! Sólo los *bricks* (`BRICK³` voxels) que contienen algo se guardan, en un
+//! **atlas 3D** (el *pool*); una textura de **indirección** del tamaño grueso
+//! mapea cada celda → slot del pool (`0` = brick vacío, no ocupa memoria). El
+//! shader resuelve cada voxel: celda gruesa → slot → texel del atlas.
+//!
+//! El DDA de dos niveles usa la indirección como mapa de ocupación (skip de
+//! aire) Y como tabla de slots (lookup fino). Mutar voxels (M3) sigue siendo
+//! incremental: un slot map + free list permiten allocar/liberar bricks y subir
+//! sólo los bricks tocados.
+//!
+//! Entidades (M4) y la firma de [`VoxelRenderer::render`] (compatible con
+//! `View::gpu_paint_with`) intactas.
+
+use crate::camera::Camera3d;
+use crate::voxel::VoxelGrid;
+
+/// Tamaño de brick (voxels por lado).
+const BRICK: u32 = 8;
+
+/// Lado de brick (voxels) expuesto: el streaming toroidal exige orígenes de
+/// ventana alineados a este múltiplo (ver [`VoxelRenderer::scroll_to`]).
+pub const VOXEL_BRICK: u32 = BRICK;
+
+/// Tope de luces puntuales por frame, expuesto para que el caller no exceda.
+pub const VOXEL_MAX_LIGHTS: usize = MAX_LIGHTS;
+
+/// Máximo de entidades vivas por frame (cabe holgado en un uniform).
+const MAX_ENTITIES: usize = 64;
+
+/// Máximo de luces puntuales por frame (cabe en el uniform principal).
+const MAX_LIGHTS: usize = 4;
+
+/// Luz puntual coloreada (antorcha/lámpara): ilumina los voxels/entidades
+/// cercanos con caída suave por distancia. Posición en coordenadas de voxel
+/// `[0, dim]` (igual que las entidades), color RGB lineal (puede pasar de 1.0
+/// para un brillo intenso), `range` = radio de alcance en voxels.
+#[derive(Clone, Copy)]
+pub struct PointLight {
+ pub pos: [f32; 3],
+ pub color: [f32; 3],
+ pub range: f32,
+ /// Radio de la luz como **fuente de área** (en voxels). `0.0` = puntual exacta
+ /// → sombra dura (1 shadow ray). `> 0.0` = el shadow ray se reparte en varios
+ /// taps sobre un disco de este radio perpendicular a la dirección a la luz →
+ /// **penumbra** (sombra blanda): cuanto más lejos el ocluyente de la superficie,
+ /// más se abre el borde. Sólo aplica si `point_shadows` está activo.
+ pub radius: f32,
+}
+
+/// Una entidad (agente) — una caja analítica ray-marcheada en el mismo pase que
+/// los voxels (M4). Posición en coordenadas de voxel `[0, dim]` (sub-voxel, así
+/// se mueve suave), `half` = medio-tamaño por eje, color RGB.
+#[derive(Clone, Copy)]
+pub struct Entity3d {
+ pub pos: [f32; 3],
+ pub half: [f32; 3],
+ pub color: [u8; 3],
+}
+
+/// Atmósfera del mundo (primera rebanada de M6): cielo gradiente + niebla por
+/// distancia ("aerial perspective"). Editable antes de `render`.
+///
+/// `fog_density` controla todo el efecto: con `0.0` (default) el renderer se
+/// comporta como antes — los rayos que no pegan nada hacen `discard` (deja ver
+/// el fondo vello) y no hay niebla. Con `> 0.0` el motor pinta su **propio
+/// cielo** en los misses y desvanece lo lejano hacia el color del horizonte, que
+/// es lo que hace legible un mundo grande (sin esto, el borde lejano del terreno
+/// se ve como un muro recortado).
+#[derive(Clone, Copy)]
+pub struct Atmosphere {
+ /// Color del cielo en el cenit (mirando hacia arriba).
+ pub sky_zenith: [u8; 3],
+ /// Color del cielo en el horizonte — también el color hacia el que
+ /// desvanece la niebla.
+ pub sky_horizon: [u8; 3],
+ /// Densidad de niebla por unidad de voxel. `0.0` = desactivada (miss →
+ /// `discard`, sin niebla); valores típicos `0.002..0.02`.
+ pub fog_density: f32,
+}
+
+impl Default for Atmosphere {
+ fn default() -> Self {
+ Self {
+ sky_zenith: [70, 120, 200],
+ sky_horizon: [188, 208, 230],
+ fog_density: 0.0,
+ }
+ }
+}
+
+/// Renderer de voxels por ray-march de dos niveles sobre un brick pool sparse.
+pub struct VoxelRenderer {
+ pool: wgpu::Texture,
+ indir: wgpu::Texture,
+ bind_group: wgpu::BindGroup,
+ /// Layout del bind group, guardado para re-armar el bind group cuando el pool
+ /// crece (la textura del atlas cambia → su view también).
+ bgl: wgpu::BindGroupLayout,
+ pipeline: wgpu::RenderPipeline,
+ ubuf: wgpu::Buffer,
+ ubuf_ent: wgpu::Buffer,
+ dim: [u32; 3],
+ cdim: [u32; 3],
+ /// Slots del atlas por eje (cuántos bricks entran en cada dimensión).
+ atlas: [u32; 3],
+ /// Por celda gruesa: `slot + 1`, o `0` si el brick está vacío. Espeja la
+ /// textura de indirección en CPU (para el camino incremental).
+ slots: Vec,
+ /// Slots libres del pool (free list para allocar bricks nuevos).
+ free: Vec,
+ /// Origen de brick de la ventana (streaming toroidal): `slots`/indirección se
+ /// indexan por celda **física** = `(celda_lógica + brick_origin) mod cdim`.
+ /// `[0,0,0]` = sin scroll (lógica = física, camino clásico).
+ brick_origin: [i32; 3],
+ /// Dirección hacia el sol (normalizada). Editable antes de `render`.
+ pub sun_dir: [f32; 3],
+ /// Atmósfera (cielo + niebla). `fog_density = 0` → comportamiento clásico.
+ pub atmosphere: Atmosphere,
+ /// Depth buffer propio para el camino *standalone* ([`Self::render`]); en
+ /// `Scene3d` se usa el depth compartido y este queda sin tocar.
+ depth: Option,
+ /// Entidades vivas — se empacan y suben en cada `render`.
+ pub entities: Vec,
+ /// Luces puntuales coloreadas (≤ [`MAX_LIGHTS`]) — antorchas/lámparas que
+ /// iluminan voxels y entidades cercanos. Se empacan y suben en cada `render`.
+ pub lights: Vec,
+ /// Si las luces puntuales proyectan sombra (un shadow ray por luz hacia su
+ /// 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,
+}
+
+impl VoxelRenderer {
+ /// Crea el renderer y construye el brick pool a partir de `grid`.
+ pub fn new(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ color_format: wgpu::TextureFormat,
+ grid: &VoxelGrid,
+ ) -> Self {
+ let dim = grid.dim();
+ let cdim = [
+ dim[0].div_ceil(BRICK),
+ dim[1].div_ceil(BRICK),
+ dim[2].div_ceil(BRICK),
+ ];
+ let n_cells = (cdim[0] * cdim[1] * cdim[2]) as usize;
+
+ // Bricks ocupados → capacidad con holgura para crecer (M3).
+ let occupied: u32 = (0..cdim[2])
+ .flat_map(|cz| (0..cdim[1]).flat_map(move |cy| (0..cdim[0]).map(move |cx| (cx, cy, cz))))
+ .filter(|&(cx, cy, cz)| grid.brick_occupied(BRICK, cx, cy, cz) != 0)
+ .count() as u32;
+ let want = occupied + occupied / 2 + 64;
+ // Atlas cúbico-ish: ax·ay·az ≥ want.
+ let ax = ((want as f64).cbrt().ceil() as u32).max(1);
+ let ay = ax;
+ let az = want.div_ceil(ax * ay).max(1);
+ let capacity = ax * ay * az;
+ let atlas = [ax, ay, az];
+
+ let pool = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("llimphi-3d-voxel-pool"),
+ size: extent([ax * BRICK, ay * BRICK, az * BRICK]),
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D3,
+ format: wgpu::TextureFormat::Rgba8Unorm,
+ // COPY_SRC para poder copiar el atlas a uno más grande al crecer.
+ usage: wgpu::TextureUsages::TEXTURE_BINDING
+ | wgpu::TextureUsages::COPY_DST
+ | wgpu::TextureUsages::COPY_SRC,
+ view_formats: &[],
+ });
+ let pool_view = pool.create_view(&wgpu::TextureViewDescriptor::default());
+
+ let indir = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("llimphi-3d-voxel-indir"),
+ size: extent(cdim),
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D3,
+ format: wgpu::TextureFormat::R32Uint,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ });
+ let indir_view = indir.create_view(&wgpu::TextureViewDescriptor::default());
+
+ let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("llimphi-3d-voxel-shader"),
+ source: wgpu::ShaderSource::Wgsl(WGSL.into()),
+ });
+
+ let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("llimphi-3d-voxel-bgl"),
+ entries: &[
+ wgpu::BindGroupLayoutEntry {
+ binding: 0,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Texture {
+ sample_type: wgpu::TextureSampleType::Float { filterable: false },
+ view_dimension: wgpu::TextureViewDimension::D3,
+ multisampled: false,
+ },
+ count: None,
+ },
+ wgpu::BindGroupLayoutEntry {
+ binding: 1,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Texture {
+ sample_type: wgpu::TextureSampleType::Uint,
+ view_dimension: wgpu::TextureViewDimension::D3,
+ multisampled: false,
+ },
+ count: None,
+ },
+ uniform_entry(2),
+ uniform_entry(3),
+ ],
+ });
+
+ let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("llimphi-3d-voxel-ubuf"),
+ // inv_vp(64)+cam_eye(16)+grid_dim/brick(16)+sun(16)+cdim(16)+atlas(16)
+ // +sky_zenith/fog(16)+sky_horizon(16)+vp(64)+scroll(16)
+ // +n_lights(16)+lights(4×32=128)
+ size: 400,
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ let ubuf_ent = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("llimphi-3d-voxel-ubuf-ent"),
+ size: (16 + MAX_ENTITIES * 48) as u64,
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+
+ let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("llimphi-3d-voxel-bg"),
+ layout: &bgl,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: wgpu::BindingResource::TextureView(&pool_view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::TextureView(&indir_view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: ubuf.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 3,
+ resource: ubuf_ent.as_entire_binding(),
+ },
+ ],
+ });
+
+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("llimphi-3d-voxel-pl"),
+ bind_group_layouts: &[&bgl],
+ push_constant_ranges: &[],
+ });
+
+ let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("llimphi-3d-voxel-pipeline"),
+ layout: Some(&pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &shader,
+ entry_point: Some("vs"),
+ compilation_options: Default::default(),
+ buffers: &[],
+ },
+ primitive: wgpu::PrimitiveState {
+ topology: wgpu::PrimitiveTopology::TriangleList,
+ ..Default::default()
+ },
+ // Escribe profundidad (del voxel golpeado) → convive con mallas en
+ // un depth buffer compartido (`Scene3d`): el render volumétrico y el
+ // de triángulos se ocluyen correctamente entre sí.
+ depth_stencil: Some(wgpu::DepthStencilState {
+ format: crate::scene::DEPTH_FORMAT,
+ depth_write_enabled: true,
+ // LessEqual (no Less): el cielo en los misses escribe profundidad
+ // lejana (1.0) y debe pasar contra el clear 1.0; un `Less` lo
+ // rechazaría y dejaría ver el fondo negro. Sólo hay un fragmento
+ // de voxel por píxel (el del rayo), así que no hay z-fighting.
+ depth_compare: wgpu::CompareFunction::LessEqual,
+ stencil: wgpu::StencilState::default(),
+ bias: wgpu::DepthBiasState::default(),
+ }),
+ multisample: wgpu::MultisampleState::default(),
+ fragment: Some(wgpu::FragmentState {
+ module: &shader,
+ entry_point: Some("fs"),
+ compilation_options: Default::default(),
+ targets: &[Some(wgpu::ColorTargetState {
+ format: color_format,
+ blend: None,
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ }),
+ multiview: None,
+ cache: None,
+ });
+
+ let mut r = Self {
+ pool,
+ indir,
+ bind_group,
+ bgl,
+ pipeline,
+ ubuf,
+ ubuf_ent,
+ dim,
+ cdim,
+ atlas,
+ slots: vec![0u32; n_cells],
+ free: Vec::new(),
+ brick_origin: [0, 0, 0],
+ sun_dir: normalize3([0.5, 1.0, 0.35]),
+ atmosphere: Atmosphere::default(),
+ depth: None,
+ entities: Vec::new(),
+ lights: Vec::new(),
+ point_shadows: true,
+ };
+
+ // Poblar el pool: cada brick ocupado toma un slot incremental.
+ let mut next: u32 = 0;
+ for cz in 0..cdim[2] {
+ for cy in 0..cdim[1] {
+ for cx in 0..cdim[0] {
+ if grid.brick_occupied(BRICK, cx, cy, cz) != 0 {
+ let slot = next;
+ next += 1;
+ let idx = r.cell_idx(cx, cy, cz);
+ r.slots[idx] = slot + 1;
+ r.upload_brick(queue, slot, grid, cx, cy, cz);
+ }
+ }
+ }
+ }
+ r.free = (next..capacity).rev().collect();
+ r.upload_indirection_full(queue);
+ r
+ }
+
+ /// Bricks ocupados (slots usados) y total de celdas gruesas — para reportar
+ /// el ahorro de memoria del pool frente al denso.
+ pub fn brick_usage(&self) -> (u32, u32) {
+ let used = self.slots.iter().filter(|&&s| s != 0).count() as u32;
+ (used, (self.cdim[0] * self.cdim[1] * self.cdim[2]))
+ }
+
+ /// Bytes del pool (atlas) vs. lo que costaría el grid denso completo.
+ pub fn memory_bytes(&self) -> (u64, u64) {
+ let (used, _) = self.brick_usage();
+ let per_brick = (BRICK * BRICK * BRICK * 4) as u64;
+ let pool = used as u64 * per_brick;
+ let dense = (self.dim[0] * self.dim[1] * self.dim[2] * 4) as u64;
+ (pool, dense)
+ }
+
+ #[inline]
+ fn cell_idx(&self, cx: u32, cy: u32, cz: u32) -> usize {
+ (cx + cy * self.cdim[0] + cz * self.cdim[0] * self.cdim[1]) as usize
+ }
+
+ /// Índice **físico** (en `slots`/indirección) de la celda gruesa **lógica**
+ /// `c` (relativa a la ventana): `(c + brick_origin) mod cdim`. Espeja el
+ /// `slot_at` del shader. Con `brick_origin = 0` es `cell_idx(c)` directo.
+ #[inline]
+ fn phys_cell(&self, c: [i32; 3]) -> usize {
+ let p = [
+ floormod(c[0] + self.brick_origin[0], self.cdim[0] as i32) as u32,
+ floormod(c[1] + self.brick_origin[1], self.cdim[1] as i32) as u32,
+ floormod(c[2] + self.brick_origin[2], self.cdim[2] as i32) as u32,
+ ];
+ self.cell_idx(p[0], p[1], p[2])
+ }
+
+ /// Origen del slot en el atlas (en celdas de brick).
+ fn slot_origin(&self, slot: u32) -> [u32; 3] {
+ let ax = self.atlas[0];
+ let ay = self.atlas[1];
+ [slot % ax, (slot / ax) % ay, slot / (ax * ay)]
+ }
+
+ fn upload_brick(&self, queue: &wgpu::Queue, slot: u32, grid: &VoxelGrid, cx: u32, cy: u32, cz: u32) {
+ let data = grid.extract_brick(BRICK, cx, cy, cz);
+ let o = self.slot_origin(slot);
+ write_3d(
+ queue,
+ &self.pool,
+ [o[0] * BRICK, o[1] * BRICK, o[2] * BRICK],
+ [BRICK, BRICK, BRICK],
+ 4,
+ &data,
+ );
+ }
+
+ fn upload_indirection_full(&self, queue: &wgpu::Queue) {
+ let mut bytes = Vec::with_capacity(self.slots.len() * 4);
+ for &s in &self.slots {
+ bytes.extend_from_slice(&s.to_ne_bytes());
+ }
+ write_3d(queue, &self.indir, [0; 3], self.cdim, 4, &bytes);
+ }
+
+ /// **Actualización incremental (M3).** Sube sólo los bricks tocados por la
+ /// región mutada: re-sube cada brick afectado a su slot (allocando slots
+ /// nuevos para bricks que pasan de vacío→ocupado, liberándolos al revés) y
+ /// re-sube la sub-región de indirección. Devuelve los bytes subidos.
+ pub fn sync(&mut self, queue: &wgpu::Queue, grid: &mut VoxelGrid) -> u32 {
+ let Some(r) = grid.take_dirty() else {
+ return 0;
+ };
+ let cmin = [r[0] / BRICK, r[1] / BRICK, r[2] / BRICK];
+ let cmax = [r[3] / BRICK, r[4] / BRICK, r[5] / BRICK];
+ let mut uploaded = 0u32;
+ let per_brick = BRICK * BRICK * BRICK * 4;
+
+ for cz in cmin[2]..=cmax[2] {
+ for cy in cmin[1]..=cmax[1] {
+ for cx in cmin[0]..=cmax[0] {
+ // Celda FÍSICA (espeja el `slot_at` toroidal del shader); con
+ // `brick_origin = 0` es la celda lógica directa.
+ let idx = self.phys_cell([cx as i32, cy as i32, cz as i32]);
+ let occ = grid.brick_occupied(BRICK, cx, cy, cz) != 0;
+ let cur = self.slots[idx];
+ if occ {
+ let slot = if cur != 0 {
+ cur - 1
+ } else {
+ match self.free.pop() {
+ Some(s) => {
+ self.slots[idx] = s + 1;
+ s
+ }
+ None => {
+ // Pool lleno: el brick no entra (raro con la
+ // holgura inicial). Lo saltamos sin romper.
+ continue;
+ }
+ }
+ };
+ self.upload_brick(queue, slot, grid, cx, cy, cz);
+ uploaded += per_brick;
+ } else if cur != 0 {
+ // Brick vaciado: liberar el slot (el atlas queda
+ // huérfano pero la indirección en 0 lo hace invisible).
+ self.free.push(cur - 1);
+ self.slots[idx] = 0;
+ }
+ }
+ }
+ }
+
+ // Re-subir la indirección tocada. Sin scroll (caso común de edición), la
+ // física = la lógica → sub-región contigua (barato). Con scroll, las
+ // celdas físicas están envueltas (no contiguas) → re-subimos la
+ // indirección entera (es chica: cdim³ u32).
+ if self.brick_origin == [0, 0, 0] {
+ let cext = [
+ cmax[0] - cmin[0] + 1,
+ cmax[1] - cmin[1] + 1,
+ cmax[2] - cmin[2] + 1,
+ ];
+ let mut ind = Vec::with_capacity((cext[0] * cext[1] * cext[2] * 4) as usize);
+ for cz in cmin[2]..=cmax[2] {
+ for cy in cmin[1]..=cmax[1] {
+ for cx in cmin[0]..=cmax[0] {
+ ind.extend_from_slice(&self.slots[self.cell_idx(cx, cy, cz)].to_ne_bytes());
+ }
+ }
+ }
+ write_3d(queue, &self.indir, cmin, cext, 4, &ind);
+ uploaded + ind.len() as u32
+ } else {
+ self.upload_indirection_full(queue);
+ uploaded + (self.slots.len() * 4) as u32
+ }
+ }
+
+ /// **Streaming toroidal (M6).** Desliza la ventana a `origin_voxel` (esquina
+ /// local `(0,0,0)` en coordenadas de mundo, alineada a brick) re-subiendo
+ /// **sólo los bricks que entran** — la franja nueva — sin reconstruir el
+ /// renderer ni re-subir la ventana entera. `grid` es la ventana ya generada
+ /// en ese origen (local `[0,dim)`), de la que se extraen los bricks de la
+ /// franja. Los bricks que salen se reemplazan en su misma celda física (la
+ /// textura es un ring buffer: `world_brick mod cdim`). Devuelve los bytes
+ /// subidos (≈ tamaño de la franja, no de la ventana). Llamar con
+ /// `origin_voxel` múltiplo de [`VOXEL_BRICK`].
+ pub fn scroll_to(
+ &mut self,
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ origin_voxel: [i32; 3],
+ grid: &VoxelGrid,
+ ) -> u32 {
+ let b = BRICK as i32;
+ debug_assert!(
+ origin_voxel.iter().all(|v| v % b == 0),
+ "scroll_to: origin_voxel debe estar alineado a VOXEL_BRICK"
+ );
+ let new = [origin_voxel[0] / b, origin_voxel[1] / b, origin_voxel[2] / b];
+ let old = self.brick_origin;
+ if new == old {
+ return 0;
+ }
+ // El uniform de scroll se actualiza ANTES de poblar para que `phys_cell`
+ // calcule las celdas físicas del nuevo origen.
+ self.brick_origin = new;
+
+ let cd = [self.cdim[0] as i32, self.cdim[1] as i32, self.cdim[2] as i32];
+ let mut uploaded = 0u32;
+ let per_brick = BRICK * BRICK * BRICK * 4;
+
+ let entered = |ccx: i32, ccy: i32, ccz: i32| -> bool {
+ let wb = [ccx + new[0], ccy + new[1], ccz + new[2]];
+ !((0..cd[0]).contains(&(wb[0] - old[0]))
+ && (0..cd[1]).contains(&(wb[1] - old[1]))
+ && (0..cd[2]).contains(&(wb[2] - old[2])))
+ };
+
+ // **Pre-crecer el pool si hace falta, ANTES de subir nada** (la copia del
+ // atlas viejo es estable, sin carrera con write_texture en vuelo). Cota
+ // segura del pico de slots: los usados ahora + los bricks que entran
+ // ocupados (los que salen aún no se liberaron). Crecer una vez al inicio.
+ let mut entered_occ = 0u32;
+ for ccz in 0..cd[2] {
+ for ccy in 0..cd[1] {
+ for ccx in 0..cd[0] {
+ if entered(ccx, ccy, ccz)
+ && grid.brick_occupied(BRICK, ccx as u32, ccy as u32, ccz as u32) != 0
+ {
+ entered_occ += 1;
+ }
+ }
+ }
+ }
+ let used_now = self.slots.iter().filter(|&&s| s != 0).count() as u32;
+ let need = used_now + entered_occ;
+ while self.pool_capacity() < need {
+ self.grow_layers(device, queue);
+ }
+
+ // Recorre las celdas LÓGICAS de la ventana nueva; procesa sólo las que
+ // ENTRARON (su brick de mundo no estaba en la ventana vieja). El bulk
+ // (presente en ambas) conserva su contenido físico intacto.
+ for ccz in 0..cd[2] {
+ for ccy in 0..cd[1] {
+ for ccx in 0..cd[0] {
+ if !entered(ccx, ccy, ccz) {
+ continue;
+ }
+ // Celda física (= celda vieja que sale, por el ring buffer).
+ let idx = self.phys_cell([ccx, ccy, ccz]);
+ let (lx, ly, lz) = (ccx as u32, ccy as u32, ccz as u32);
+ let occ = grid.brick_occupied(BRICK, lx, ly, lz) != 0;
+ let cur = self.slots[idx];
+ if occ {
+ let slot = if cur != 0 {
+ cur - 1
+ } else {
+ let s = self.free.pop().expect("capacidad pre-crecida");
+ self.slots[idx] = s + 1;
+ s
+ };
+ self.upload_brick(queue, slot, grid, lx, ly, lz);
+ uploaded += per_brick;
+ } else if cur != 0 {
+ self.free.push(cur - 1);
+ self.slots[idx] = 0;
+ }
+ }
+ }
+ }
+
+ self.upload_indirection_full(queue);
+ uploaded + (self.slots.len() * 4) as u32
+ }
+
+ /// Capacidad del pool en slots de brick (`atlas.x·y·z`). Crece con
+ /// [`grow_layers`](Self::grow_layers).
+ pub fn pool_capacity(&self) -> u32 {
+ self.atlas[0] * self.atlas[1] * self.atlas[2]
+ }
+
+ /// **Crece el brick pool** agregando capas `z` al atlas (×1.5, mín +8 slots).
+ /// Sólo crece `atlas.z` para no remapear los slots existentes (`slot_origin`
+ /// depende de `atlas.x/y`, que quedan fijos): copia el atlas viejo al nuevo,
+ /// re-arma el bind group (la view del pool cambió) y agrega los slots nuevos a
+ /// la free list. Lo dispara `scroll_to` cuando la franja que entra no tiene
+ /// slots libres (ventana más densa que la inicial).
+ fn grow_layers(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
+ let [ax, ay, az] = self.atlas;
+ let new_az = az + (az / 2).max(8);
+ let new_pool = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("llimphi-3d-voxel-pool"),
+ size: extent([ax * BRICK, ay * BRICK, new_az * BRICK]),
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D3,
+ format: wgpu::TextureFormat::Rgba8Unorm,
+ 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.
+ 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]),
+ );
+ queue.submit(std::iter::once(enc.finish()));
+
+ // Re-armar el bind group con la view del pool nuevo (indir/ubufs intactos).
+ let pool_view = new_pool.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"),
+ layout: &self.bgl,
+ entries: &[
+ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&pool_view) },
+ 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() },
+ ],
+ });
+
+ let old_cap = ax * ay * az;
+ let new_cap = ax * ay * new_az;
+ self.pool = new_pool;
+ self.atlas[2] = new_az;
+ self.free.extend((old_cap..new_cap).rev());
+ }
+
+ /// Sube los uniforms (cámara/atmósfera/entidades) del frame. Lo llama tanto
+ /// [`Self::render`] (standalone) como [`Scene3d`](crate::Scene3d) antes de
+ /// abrir el pase compartido. `aspect` = w/h del viewport.
+ pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) {
+ let vp = camera.view_proj(aspect);
+ let inv_vp = vp.inverse();
+ let mut u = Vec::with_capacity(400);
+ for v in inv_vp.to_cols_array() {
+ u.extend_from_slice(&v.to_ne_bytes());
+ }
+ for v in [camera.eye.x, camera.eye.y, camera.eye.z, 0.0] {
+ u.extend_from_slice(&v.to_ne_bytes());
+ }
+ for v in [self.dim[0] as f32, self.dim[1] as f32, self.dim[2] as f32, BRICK as f32] {
+ u.extend_from_slice(&v.to_ne_bytes());
+ }
+ let s = normalize3(self.sun_dir);
+ for v in [s[0], s[1], s[2], 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] {
+ 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] {
+ u.extend_from_slice(&v.to_ne_bytes());
+ }
+ let a = &self.atmosphere;
+ for v in [
+ a.sky_zenith[0] as f32 / 255.0,
+ a.sky_zenith[1] as f32 / 255.0,
+ a.sky_zenith[2] as f32 / 255.0,
+ a.fog_density.max(0.0),
+ ] {
+ u.extend_from_slice(&v.to_ne_bytes());
+ }
+ 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,
+ ] {
+ u.extend_from_slice(&v.to_ne_bytes());
+ }
+ // Matriz forward (world→clip) para escribir frag_depth del voxel golpeado.
+ for v in vp.to_cols_array() {
+ u.extend_from_slice(&v.to_ne_bytes());
+ }
+ // Origen de brick (streaming toroidal): el shader envuelve la celda lógica.
+ // 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.
+ 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,
+ ] {
+ 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] {
+ u.extend_from_slice(&v.to_ne_bytes());
+ }
+ for i in 0..MAX_LIGHTS {
+ let l = self.lights.get(i).copied().unwrap_or(PointLight {
+ pos: [0.0; 3],
+ color: [0.0; 3],
+ range: 1.0,
+ radius: 0.0,
+ });
+ for v in [l.pos[0], l.pos[1], l.pos[2], l.range] {
+ u.extend_from_slice(&v.to_ne_bytes());
+ }
+ // color.w lleva el radio de área (penumbra); 0 = sombra dura.
+ for v in [l.color[0], l.color[1], l.color[2], l.radius.max(0.0)] {
+ u.extend_from_slice(&v.to_ne_bytes());
+ }
+ }
+ queue.write_buffer(&self.ubuf, 0, &u);
+
+ // Entidades: count (vec4) + array de [pos, half, color] (3×vec4 c/u).
+ let n = self.entities.len().min(MAX_ENTITIES);
+ let mut e = Vec::with_capacity(16 + MAX_ENTITIES * 48);
+ for v in [n as f32, 0.0, 0.0, 0.0] {
+ e.extend_from_slice(&v.to_ne_bytes());
+ }
+ for i in 0..MAX_ENTITIES {
+ let ent = self.entities.get(i).copied().unwrap_or(Entity3d {
+ pos: [0.0; 3],
+ half: [0.0; 3],
+ color: [0, 0, 0],
+ });
+ for v in [ent.pos[0], ent.pos[1], ent.pos[2], 0.0] {
+ e.extend_from_slice(&v.to_ne_bytes());
+ }
+ for v in [ent.half[0], ent.half[1], ent.half[2], 0.0] {
+ e.extend_from_slice(&v.to_ne_bytes());
+ }
+ for v in [
+ ent.color[0] as f32 / 255.0,
+ ent.color[1] as f32 / 255.0,
+ ent.color[2] as f32 / 255.0,
+ 0.0,
+ ] {
+ e.extend_from_slice(&v.to_ne_bytes());
+ }
+ }
+ queue.write_buffer(&self.ubuf_ent, 0, &e);
+ }
+
+ /// Dibuja el fullscreen-triangle del ray-march en un pase **ya abierto** (con
+ /// color + depth). Lo usa [`Scene3d`](crate::Scene3d) para compartir el pase
+ /// con las mallas. Requiere `upload` previo en el mismo frame.
+ pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
+ pass.set_pipeline(&self.pipeline);
+ pass.set_bind_group(0, &self.bind_group, &[]);
+ pass.draw(0..3, 0..1);
+ }
+
+ /// Ray-marchea la grilla vista desde `camera` sobre `target` (camino
+ /// *standalone*, con depth propio). Color `LoadOp::Load`; misses por
+ /// `discard` (o cielo, con niebla). Grilla centrada en el origen.
+ pub fn render(
+ &mut self,
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ encoder: &mut wgpu::CommandEncoder,
+ target: &wgpu::TextureView,
+ (w, h): (u32, u32),
+ camera: &Camera3d,
+ ) {
+ if w == 0 || h == 0 {
+ return;
+ }
+ self.upload(queue, w as f32 / h as f32, camera);
+ crate::scene::ensure_depth(&mut self.depth, device, w, h);
+ let depth_view = &self.depth.as_ref().unwrap().view;
+
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("llimphi-3d-voxel-pass"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: target,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Load,
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+ view: depth_view,
+ depth_ops: Some(wgpu::Operations {
+ load: wgpu::LoadOp::Clear(1.0),
+ store: wgpu::StoreOp::Store,
+ }),
+ stencil_ops: None,
+ }),
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ self.draw(&mut pass);
+ }
+}
+
+fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
+ wgpu::BindGroupLayoutEntry {
+ binding,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Buffer {
+ ty: wgpu::BufferBindingType::Uniform,
+ has_dynamic_offset: false,
+ min_binding_size: None,
+ },
+ count: None,
+ }
+}
+
+fn extent(dim: [u32; 3]) -> wgpu::Extent3d {
+ wgpu::Extent3d {
+ width: dim[0],
+ height: dim[1],
+ depth_or_array_layers: dim[2],
+ }
+}
+
+fn write_3d(
+ queue: &wgpu::Queue,
+ tex: &wgpu::Texture,
+ origin: [u32; 3],
+ ext: [u32; 3],
+ bpp: u32,
+ data: &[u8],
+) {
+ queue.write_texture(
+ wgpu::TexelCopyTextureInfo {
+ texture: tex,
+ mip_level: 0,
+ origin: wgpu::Origin3d {
+ x: origin[0],
+ y: origin[1],
+ z: origin[2],
+ },
+ aspect: wgpu::TextureAspect::All,
+ },
+ data,
+ wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(ext[0] * bpp),
+ rows_per_image: Some(ext[1]),
+ },
+ extent(ext),
+ );
+}
+
+fn normalize3(v: [f32; 3]) -> [f32; 3] {
+ let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6);
+ [v[0] / l, v[1] / l, v[2] / l]
+}
+
+/// Módulo con resultado siempre en `[0, m)` (maneja `a` negativo). Espeja el
+/// `((x % m) + m) % m` del shader para el direccionamiento toroidal.
+#[inline]
+fn floormod(a: i32, m: i32) -> i32 {
+ ((a % m) + m) % m
+}
+
+const WGSL: &str = r#"
+struct U {
+ inv_vp: mat4x4,
+ cam_eye: vec4,
+ grid_dim: vec4, // xyz = dim fino, w = brick size
+ sun_dir: vec4, // xyz = dirección hacia el sol (normalizada)
+ cdim: vec4, // xyz = dim grueso (celdas de brick)
+ atlas: vec4, // xyz = slots por eje en el atlas del pool
+ sky_zenith: vec4, // xyz = color cenit, w = densidad de niebla (0 = off)
+ sky_horizon: vec4,// xyz = color horizonte / hacia el que niebla desvanece
+ vp: mat4x4, // world→clip (forward) para escribir frag_depth
+ scroll: vec4, // xyz = origen de brick (streaming toroidal); 0 = sin scroll
+ n_lights: vec4, // x = cantidad de luces puntuales, y = sombras on/off
+ lights: array, 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, 8>(
+ vec2( 0.35, 0.0), vec2(-0.35, 0.0),
+ vec2( 0.0, 0.35), vec2( 0.0, -0.35),
+ vec2( 0.7, 0.7), vec2(-0.7, 0.7),
+ vec2( 0.7, -0.7), vec2(-0.7, -0.7),
+);
+struct Entity {
+ pos: vec4,
+ half: vec4,
+ color: vec4,
+};
+struct EntU {
+ count: vec4,
+ ents: array,
+};
+@group(0) @binding(0) var pool: texture_3d;
+@group(0) @binding(1) var indir: texture_3d;
+@group(0) @binding(2) var u: U;
+@group(0) @binding(3) var ent: EntU;
+
+struct VOut {
+ @builtin(position) clip: vec4,
+ @location(0) ndc: vec2,
+};
+
+@vertex
+fn vs(@builtin(vertex_index) vi: u32) -> VOut {
+ var p = array, 3>(
+ vec2(-1.0, -1.0),
+ vec2( 3.0, -1.0),
+ vec2(-1.0, 3.0),
+ );
+ var out: VOut;
+ out.clip = vec4(p[vi], 0.0, 1.0);
+ out.ndc = p[vi];
+ return out;
+}
+
+fn ray_box(ro: vec3, inv_rd: vec3, bmin: vec3, bmax: vec3) -> vec2 {
+ let t0 = (bmin - ro) * inv_rd;
+ let t1 = (bmax - ro) * inv_rd;
+ let tmin = min(t0, t1);
+ let tmax = max(t0, t1);
+ return vec2(max(max(tmin.x, tmin.y), tmin.z), min(min(tmax.x, tmax.y), tmax.z));
+}
+
+// Slot del brick que contiene la celda gruesa LÓGICA `cc` (0 = vacío).
+// Streaming toroidal: la textura de indirección es un ring buffer indexado por
+// `world_brick mod cdim`. La celda lógica `cc` (relativa a la ventana, [0,cdim))
+// se traduce a su celda FÍSICA sumando el origen de brick y envolviendo. Sin
+// scroll (`scroll = 0`) la física = la lógica (camino clásico, sin cambios).
+fn slot_at(cc: vec3) -> u32 {
+ if (any(cc < vec3(0)) || any(vec3(cc) >= u.cdim.xyz)) { return 0u; }
+ let cd = vec3(u.cdim.xyz);
+ let bo = vec3(u.scroll.xyz);
+ let phys = ((cc + bo) % cd + cd) % cd; // floormod (maneja origen negativo)
+ return textureLoad(indir, phys, 0).r;
+}
+
+// Voxel fino vía indirección → pool. `.a > 0.5` = sólido.
+fn voxel_at(voxel: vec3) -> vec4 {
+ let vi = vec3(voxel);
+ if (any(vi < vec3(0)) || any(vec3(vi) >= u.grid_dim.xyz)) { return vec4(0.0); }
+ let bu = i32(u.grid_dim.w);
+ let cc = vi / bu;
+ let s = slot_at(cc);
+ if (s == 0u) { return vec4(0.0); }
+ let slot = i32(s - 1u);
+ let ax = i32(u.atlas.x);
+ let ay = i32(u.atlas.y);
+ let acell = vec3(slot % ax, (slot / ax) % ay, slot / (ax * ay));
+ let local = vi - cc * bu;
+ return textureLoad(pool, acell * bu + local, 0);
+}
+
+struct Hit {
+ hit: bool,
+ vox: vec3,
+ normal: vec3,
+ t: f32,
+};
+
+// 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, rd_in: vec3, dim: vec3, B: f32) -> Hit {
+ var h: Hit;
+ h.hit = false;
+
+ let safe_rd = vec3(
+ 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(0.0), dim);
+ if (tb.x > tb.y || tb.y < 0.0) { return h; }
+ let t_enter = max(tb.x, 0.0);
+
+ let te0 = (vec3(0.0) - ro) * inv_rd;
+ let te1 = (dim - ro) * inv_rd;
+ let temin = min(te0, te1);
+ var box_n = vec3(0.0, 0.0, -step.z);
+ if (tb.x == temin.x) { box_n = vec3(-step.x, 0.0, 0.0); }
+ else if (tb.x == temin.y) { box_n = vec3(0.0, -step.y, 0.0); }
+
+ let cdim = ceil(dim / B);
+ let p_enter = ro + safe_rd * t_enter;
+ var cc = clamp(floor(p_enter / B), vec3(0.0), cdim - 1.0);
+ let t_delta_c = abs(B * inv_rd);
+ var t_max_c = ((cc + max(step, vec3(0.0))) * B - ro) * inv_rd;
+ var t_cell = t_enter;
+ var cnorm = box_n;
+
+ 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(cc)) != 0u) {
+ var voxel = clamp(floor(ro + safe_rd * (t_cell + 1e-4)), vec3(0.0), dim - 1.0);
+ var t_max_f = ((voxel + max(step, vec3(0.0))) - ro) * inv_rd;
+ let t_delta_f = abs(inv_rd);
+ var fnorm = cnorm;
+ var t_vox = t_cell;
+ let max_fine = i32(B) * 3 + 3;
+ for (var fi = 0; fi < max_fine; fi = fi + 1) {
+ if (any(voxel < vec3(0.0)) || any(voxel >= dim)) { return h; }
+ if (any(floor(voxel / B) != cc)) { break; }
+ let c = voxel_at(voxel);
+ if (c.a > 0.5) {
+ h.hit = true;
+ h.vox = voxel;
+ h.normal = fnorm;
+ h.t = t_vox;
+ return h;
+ }
+ if (t_max_f.x < t_max_f.y && t_max_f.x < t_max_f.z) {
+ voxel.x = voxel.x + step.x;
+ t_vox = t_max_f.x;
+ t_max_f.x = t_max_f.x + t_delta_f.x;
+ fnorm = vec3(-step.x, 0.0, 0.0);
+ } else if (t_max_f.y < t_max_f.z) {
+ voxel.y = voxel.y + step.y;
+ t_vox = t_max_f.y;
+ t_max_f.y = t_max_f.y + t_delta_f.y;
+ fnorm = vec3(0.0, -step.y, 0.0);
+ } else {
+ voxel.z = voxel.z + step.z;
+ t_vox = t_max_f.z;
+ t_max_f.z = t_max_f.z + t_delta_f.z;
+ fnorm = vec3(0.0, 0.0, -step.z);
+ }
+ }
+ }
+ 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;
+ cnorm = vec3(-step.x, 0.0, 0.0);
+ } 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;
+ cnorm = vec3(0.0, -step.y, 0.0);
+ } 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;
+ cnorm = vec3(0.0, 0.0, -step.z);
+ }
+ if (any(cc < vec3(0.0)) || any(cc >= cdim)) { return h; }
+ }
+ return h;
+}
+
+fn occ_at(p: vec3, dim: vec3) -> f32 {
+ return select(0.0, 1.0, voxel_at(vec3(p)).a > 0.5);
+}
+
+fn vertex_ao(s1: f32, s2: f32, c: f32) -> f32 {
+ if (s1 > 0.5 && s2 > 0.5) { return 0.0; }
+ return (3.0 - (s1 + s2 + c)) / 3.0;
+}
+
+fn compute_ao(voxel: vec3, normal: vec3, p: vec3, dim: vec3) -> f32 {
+ var t1 = vec3(0, 1, 0);
+ var t2 = vec3(0, 0, 1);
+ if (abs(normal.y) > 0.5) { t1 = vec3(1, 0, 0); t2 = vec3(0, 0, 1); }
+ else if (abs(normal.z) > 0.5) { t1 = vec3(1, 0, 0); t2 = vec3(0, 1, 0); }
+
+ let base = vec3(voxel) + vec3(normal);
+ let s1m = occ_at(base - t1, dim);
+ let s1p = occ_at(base + t1, dim);
+ let s2m = occ_at(base - t2, dim);
+ let s2p = occ_at(base + t2, dim);
+ let ao_mm = vertex_ao(s1m, s2m, occ_at(base - t1 - t2, dim));
+ let ao_pm = vertex_ao(s1p, s2m, occ_at(base + t1 - t2, dim));
+ let ao_mp = vertex_ao(s1m, s2p, occ_at(base - t1 + t2, dim));
+ let ao_pp = vertex_ao(s1p, s2p, occ_at(base + t1 + t2, dim));
+
+ let fp = fract(p);
+ let uu = dot(vec3(t1), fp);
+ let vv = dot(vec3(t2), fp);
+ return mix(mix(ao_mm, ao_pm, uu), mix(ao_mp, ao_pp, uu), vv);
+}
+
+struct EHit {
+ hit: bool,
+ t: f32,
+ normal: vec3,
+ color: vec3,
+};
+
+fn trace_entities(ro: vec3, rd: vec3, max_t: f32) -> EHit {
+ var best: EHit;
+ best.hit = false;
+ best.t = max_t;
+ let safe_rd = vec3(
+ select(rd.x, 1e-6, abs(rd.x) < 1e-6),
+ select(rd.y, 1e-6, abs(rd.y) < 1e-6),
+ select(rd.z, 1e-6, abs(rd.z) < 1e-6),
+ );
+ let inv_rd = 1.0 / safe_rd;
+ let n = i32(ent.count.x);
+ for (var i = 0; i < n; i = i + 1) {
+ let e = ent.ents[i];
+ let bmin = e.pos.xyz - e.half.xyz;
+ let bmax = e.pos.xyz + e.half.xyz;
+ let tb = ray_box(ro, inv_rd, bmin, bmax);
+ if (tb.x <= tb.y && tb.x > 1e-3 && tb.x < best.t) {
+ best.hit = true;
+ best.t = tb.x;
+ best.color = e.color.rgb;
+ let p = ro + rd * tb.x;
+ let c = (bmin + bmax) * 0.5;
+ let d = max((bmax - bmin) * 0.5, vec3(1e-4));
+ let q = (p - c) / d;
+ let aq = abs(q);
+ if (aq.x >= aq.y && aq.x >= aq.z) { best.normal = vec3(sign(q.x), 0.0, 0.0); }
+ else if (aq.y >= aq.z) { best.normal = vec3(0.0, sign(q.y), 0.0); }
+ else { best.normal = vec3(0.0, 0.0, sign(q.z)); }
+ }
+ }
+ return best;
+}
+
+// Cielo procedural: gradiente horizonte→cenit por la altura del rayo, con un
+// disco solar y su halo. Es también el color al que desvanece la niebla.
+fn sky_color(rd: vec3) -> vec3 {
+ let t = clamp(rd.y * 0.5 + 0.5, 0.0, 1.0);
+ var c = mix(u.sky_horizon.xyz, u.sky_zenith.xyz, pow(t, 0.55));
+ let s = max(dot(rd, u.sun_dir.xyz), 0.0);
+ c = c + vec3(1.0, 0.96, 0.84) * pow(s, 260.0) * 1.6; // disco
+ c = c + vec3(1.0, 0.90, 0.72) * pow(s, 9.0) * 0.16; // halo
+ return c;
+}
+
+// Profundidad NDC (0..1, wgpu) del punto golpeado `p` (en espacio de grilla):
+// se lleva a mundo (la grilla está centrada en el origen → world = p - dim/2) y
+// se proyecta con la matriz forward. Permite que las mallas se ocluyan con los
+// voxels en el depth buffer compartido de `Scene3d`.
+fn frag_depth(p: vec3, dim: vec3) -> f32 {
+ let clip = u.vp * vec4(p - dim * 0.5, 1.0);
+ return clip.z / clip.w;
+}
+
+struct FOut {
+ @location(0) color: vec4,
+ @builtin(frag_depth) depth: f32,
+};
+
+@fragment
+fn fs(in: VOut) -> FOut {
+ let p_near = u.inv_vp * vec4(in.ndc, 0.0, 1.0);
+ let p_far = u.inv_vp * vec4(in.ndc, 1.0, 1.0);
+ let ro_world = u.cam_eye.xyz;
+ let rd = normalize(p_far.xyz / p_far.w - p_near.xyz / p_near.w);
+
+ let dim = u.grid_dim.xyz;
+ let B = u.grid_dim.w;
+ let ro = ro_world + dim * 0.5;
+
+ let h = trace(ro, rd, dim, B);
+ let t_vox = select(1e30, h.t, h.hit);
+ let eh = trace_entities(ro, rd, t_vox);
+
+ let fog_density = u.sky_zenith.w;
+
+ var albedo: vec3;
+ var normal: vec3;
+ var p: vec3;
+ var ao: f32;
+ var t_hit: f32;
+ if (eh.hit) {
+ albedo = eh.color;
+ normal = eh.normal;
+ p = ro + rd * eh.t;
+ ao = 1.0;
+ t_hit = eh.t;
+ } else if (h.hit) {
+ albedo = voxel_at(h.vox).rgb;
+ normal = h.normal;
+ p = ro + rd * h.t;
+ ao = compute_ao(h.vox, h.normal, p, dim);
+ t_hit = h.t;
+ } else {
+ // Sin impacto: con niebla activa pintamos cielo propio (a profundidad
+ // lejana, así una malla por delante igual se dibuja); sin niebla,
+ // descartamos para dejar ver el fondo vello (comportamiento clásico).
+ if (fog_density > 0.0) {
+ var sky: FOut;
+ sky.color = vec4(sky_color(rd), 1.0);
+ sky.depth = 1.0;
+ return sky;
+ }
+ discard;
+ }
+
+ 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);
+ let sh_e = trace_entities(so, ldir, 1e30);
+ let shadow = select(1.0, 0.25, sh_v.hit || sh_e.hit);
+
+ // Luz CON COLOR (look cinematográfico) sin uniforms nuevos: el color del sol
+ // sale de su elevación (cálido al ras del horizonte → blanco en lo alto) y el
+ // 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;
+ let sun_h = clamp(u.sun_dir.y, 0.0, 1.0);
+ let sun_col = mix(vec3(1.0, 0.52, 0.24), vec3(1.0, 0.97, 0.9), sun_h);
+ // Ambiente tintado por el cielo pero con ~la misma luminancia que el flat 0.32
+ // de antes (no oscurece las caras que no ven el sol).
+ let amb_col = mix(vec3(0.45), u.sky_zenith.xyz, 0.45) * 0.70;
+ var light = amb_col + sun_col * (0.78 * diff * shadow);
+
+ // Luces puntuales coloreadas (antorchas/lámparas): caída suave por distancia
+ // + sombra dura opcional (un shadow ray hacia la luz, acotado a la distancia
+ // a la luz: si un voxel/entidad intercepta *antes* de llegar, la luz no llega).
+ // `p` está en espacio de voxel, igual que `light.pos`.
+ let nlights = i32(u.n_lights.x);
+ let pt_shadows = u.n_lights.y > 0.5;
+ for (var li = 0; li < nlights; li = li + 1) {
+ let lp = u.lights[2 * li];
+ let lc = u.lights[2 * li + 1];
+ let to = lp.xyz - p;
+ let d = length(to);
+ let range = max(lp.w, 1e-3);
+ var att = clamp(1.0 - d / range, 0.0, 1.0);
+ att = att * att; // caída cuadrática suave
+ let ldir2 = to / max(d, 1e-3);
+ let ndl = max(dot(normal, ldir2), 0.0);
+ var vis = 1.0;
+ if (pt_shadows && att > 0.0 && ndl > 0.0) {
+ // Sale de la superficie un pelo hacia la luz para no auto-sombrearse.
+ let lso = p + normal * 0.5 + ldir2 * 0.01;
+ let bias = 0.75; // tolerancia: no contar el propio voxel ni la luz.
+ 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 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);
+ } else {
+ // Sombra blanda: la luz es un disco de radio `lrad`. Se reparten
+ // varios shadow rays hacia puntos del disco (perpendicular a la
+ // dirección a la luz) y se promedia la visibilidad → penumbra.
+ // Base ortonormal del plano del disco.
+ var up = vec3(0.0, 1.0, 0.0);
+ if (abs(ldir2.y) > 0.9) { up = vec3(1.0, 0.0, 0.0); }
+ let tx = normalize(cross(up, ldir2));
+ let ty = cross(ldir2, tx);
+ var occ = 0.0;
+ for (var s = 0; s < SOFT_TAPS; s = s + 1) {
+ let o = SOFT_DISK[s] * lrad;
+ let lp2 = lp.xyz + tx * o.x + ty * o.y;
+ let to2 = lp2 - p;
+ let d2 = length(to2);
+ let dir2 = to2 / max(d2, 1e-3);
+ let hv = trace(lso, dir2, dim, B);
+ let bv = hv.hit && hv.t < d2 - bias;
+ let he = trace_entities(lso, dir2, d2 - bias);
+ if (bv || he.hit) { occ = occ + 1.0; }
+ }
+ vis = 1.0 - occ / f32(SOFT_TAPS);
+ }
+ }
+ light = light + lc.rgb * (att * ndl * vis);
+ }
+
+ var color = albedo * light * ao_term;
+
+ // 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) {
+ let f = 1.0 - exp(-t_hit * fog_density);
+ color = mix(color, sky_color(rd), f);
+ }
+
+ var out: FOut;
+ out.color = vec4(color, 1.0);
+ out.depth = frag_depth(p, dim);
+ return out;
+}
+"#;
diff --git a/llimphi-compositor/Cargo.toml b/llimphi-compositor/Cargo.toml
index 49bcc92..c3b0282 100644
--- a/llimphi-compositor/Cargo.toml
+++ b/llimphi-compositor/Cargo.toml
@@ -14,3 +14,21 @@ vello = { workspace = true }
# Sólo para los tipos de la firma de GpuPaintFn (Device/Queue/Encoder/View).
# wgpu NO depende de winit — el compositor sigue libre de windowing.
wgpu = { workspace = true }
+
+[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" }
+png = { workspace = true }
+pollster = { workspace = true }
+# Widgets REALES para el showreel: el `View` 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" }
diff --git a/llimphi-compositor/examples/anim_demo.rs b/llimphi-compositor/examples/anim_demo.rs
new file mode 100644
index 0000000..5a0d52e
--- /dev/null
+++ b/llimphi-compositor/examples/anim_demo.rs
@@ -0,0 +1,263 @@
+//! Filmstrip headless de **animaciones implícitas**, tres filas:
+//!
+//! - **Fila 1** — `View::animated`: el mismo nodo cuyo `fill` cambia de rojo a
+//! azul, reconciliado a 6 instantes crecientes — crossfade rojo→púrpura→azul.
+//! - **Fila 2** — `View::animated_enter`: el fade-in de ENTRADA de un nodo, de
+//! opacidad 0 a opaco, a los mismos 6 progresos.
+//! - **Fila 3** — `View::animated_exit`: el fade-out de SALIDA de un nodo
+//! (capturado mientras vivía, reproducido como fantasma), de opaco a 0.
+//!
+//! Prueba el camino completo View.animated[_enter|_exit] → AnimRegistry →
+//! paint/paint_range/replay_ghosts → píxeles.
+//!
+//! `cargo run -p llimphi-compositor --example anim_demo -- [out.png]`
+
+use std::fs::File;
+use std::io::BufWriter;
+use std::time::{Duration, Instant};
+
+use llimphi_compositor::{mount, paint, paint_range, AnimRegistry, View};
+use llimphi_hal::{wgpu, Hal};
+use llimphi_layout::taffy::prelude::{length, FlexDirection, Size, Style};
+use llimphi_layout::taffy::{AlignItems, JustifyContent};
+use llimphi_layout::LayoutTree;
+use llimphi_raster::peniko::Color;
+use llimphi_raster::{vello, Renderer};
+use llimphi_text::{Alignment, Typesetter};
+use vello::kurbo::Affine;
+
+const W: u32 = 1180;
+const H: u32 = 580;
+/// Y de la fila de crossfade, de fade-in de entrada y de fade-out de salida.
+const ROW_FADE_Y: f64 = 40.0;
+const ROW_ENTER_Y: f64 = 220.0;
+const ROW_EXIT_Y: f64 = 400.0;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+const FRAMES: usize = 6;
+const DUR: Duration = Duration::from_millis(500);
+
+fn rgb(r: u8, g: u8, b: u8) -> Color {
+ Color::from_rgba8(r, g, b, 255)
+}
+
+/// Una tarjeta animada (key=1) con `fill`, transladada (vía `transform`) a su
+/// columna `i` y con el `fill` que la `view` "quiere" este frame.
+fn card_shell(col: usize, row_y: f64, label: &str, fg: Color) -> View<()> {
+ View::<()>::new(Style {
+ size: Size { width: length(170.0), height: length(140.0) },
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ flex_direction: FlexDirection::Column,
+ ..Default::default()
+ })
+ .transform(Affine::translate((20.0 + col as f64 * 190.0, row_y)))
+ .radius(18.0)
+ .children(vec![View::<()>::new(Style {
+ size: Size { width: length(150.0), height: length(20.0) },
+ ..Default::default()
+ })
+ .text_aligned(label.to_string(), 13.0, fg, Alignment::Center)])
+}
+
+fn card(fill: Color, col: usize, label: &str, fg: Color) -> View<()> {
+ card_shell(col, ROW_FADE_Y, label, fg).fill(fill).animated(1, DUR)
+}
+
+/// Tarjeta con animación de ENTRADA: su primera aparición sube de opacidad 0
+/// a opaco. La key se varía por columna (key=10+col) para que cada registro la
+/// trate como una entrada nueva e independiente.
+fn card_enter(col: usize, label: &str, fg: Color) -> View<()> {
+ card_shell(col, ROW_ENTER_Y, label, fg)
+ .fill(rgb(60, 90, 220))
+ .animated_enter(10 + col as u64, DUR)
+}
+
+/// Tarjeta con animación de SALIDA: cuando desaparece, el runtime reproduce su
+/// subescena capturada con opacidad decreciente. Verde para distinguir la fila.
+fn card_exit(col: usize, label: &str, fg: Color) -> View<()> {
+ card_shell(col, ROW_EXIT_Y, label, fg)
+ .fill(rgb(40, 160, 90))
+ .animated_exit(20 + col as u64, DUR)
+}
+
+fn main() {
+ let out = std::env::args().nth(1).unwrap_or_else(|| "anim.png".to_string());
+ let red = rgb(220, 60, 60);
+ let blue = rgb(60, 90, 220);
+ let white = rgb(245, 245, 250);
+
+ // Un registro por columna: cada uno se asienta en rojo y arranca el tween
+ // a azul en t0, pero se OBSERVA a un instante distinto (i * paso). Así el
+ // filmstrip muestra la misma transición a 6 progresos.
+ let t0 = Instant::now();
+ let step = DUR / (FRAMES as u32 - 1);
+
+ let mut cards = Vec::new();
+ for i in 0..FRAMES {
+ let mut reg = AnimRegistry::new();
+ // Frame de asentamiento (rojo) en t0.
+ {
+ let mut layout = LayoutTree::new();
+ let mut m = mount(&mut layout, card(red, i, "", white));
+ reg.reconcile(&mut m, t0);
+ }
+ // Frame de detección del cambio a azul (arranca el reloj en t0).
+ {
+ let mut layout = LayoutTree::new();
+ let mut m = mount(&mut layout, card(blue, i, "", white));
+ reg.reconcile(&mut m, t0);
+ }
+ // Frame de observación: el nodo `card` se reconcilia a t0 + i*paso y su
+ // `fill` queda con el valor interpolado. Lo dejamos para pintar.
+ let now = t0 + step * i as u32;
+ let pct = (i as f32 / (FRAMES as f32 - 1.0) * 100.0).round() as i32;
+ let mut layout = LayoutTree::new();
+ let mut m = mount(&mut layout, card(blue, i, &format!("{pct}%"), white));
+ let computed = layout.compute(m.root, (W as f32, H as f32)).expect("layout");
+ reg.reconcile(&mut m, now);
+ cards.push((m, computed));
+
+ // Fila de entrada: la PRIMERA aparición ARRANCA el tween (en t0), así
+ // que el frame de observación a `now` ve el progreso correcto. Si se
+ // reconciliara una sola vez en `now`, el tween arrancaría y se
+ // observaría en el mismo instante → siempre t=0 (invisible).
+ let mut reg_enter = AnimRegistry::new();
+ {
+ let mut layout = LayoutTree::new();
+ let mut me = mount(&mut layout, card_enter(i, "", white));
+ reg_enter.reconcile(&mut me, t0);
+ }
+ let mut layout = LayoutTree::new();
+ let mut me = mount(&mut layout, card_enter(i, &format!("{pct}%"), white));
+ let computed = layout.compute(me.root, (W as f32, H as f32)).expect("layout");
+ reg_enter.reconcile(&mut me, now);
+ cards.push((me, computed));
+ }
+
+ // Fila de SALIDA: cada columna corre el ciclo real captura→fantasma→replay.
+ // (1) frame VIVO en t0: se reconcilia y se captura su subescena con
+ // `paint_range`. (2) frame AUSENTE en t0: la key desaparece → se promueve a
+ // fantasma (start=t0). (3) `replay_ghosts` a t0+paso·i lo pinta con la
+ // opacidad decreciente sobre `ghost_scene`, que luego se compone.
+ let mut ts_exit = Typesetter::new();
+ let mut ghost_scene = vello::Scene::new();
+ for i in 0..FRAMES {
+ let mut reg = AnimRegistry::new();
+ // (1) Vivo: reconcilia y captura su subárbol (root = idx 0).
+ {
+ let mut layout = LayoutTree::new();
+ let mut mv = mount(&mut layout, card_exit(i, "", white));
+ let computed = layout.compute(mv.root, (W as f32, H as f32)).expect("layout");
+ reg.reconcile(&mut mv, t0);
+ let n = mv.nodes.len();
+ let mut sub = vello::Scene::new();
+ paint_range(&mut sub, &mv, &computed, &mut ts_exit, None, None, 0, n, Affine::IDENTITY);
+ reg.store_live_exit(20 + i as u64, sub, DUR, llimphi_compositor::ease_out_cubic);
+ }
+ // (2) Ausente: la key se va → fantasma con start=t0.
+ {
+ let mut layout = LayoutTree::new();
+ let mut empty = mount(&mut layout, card_shell(i, ROW_EXIT_Y, "", white));
+ layout.compute(empty.root, (W as f32, H as f32)).expect("layout");
+ reg.reconcile(&mut empty, t0);
+ }
+ // (3) Observación: el fantasma se reproduce al progreso `now`.
+ let now = t0 + step * i as u32;
+ let pct = (i as f32 / (FRAMES as f32 - 1.0) * 100.0).round() as i32;
+ reg.replay_ghosts(&mut ghost_scene, now, W as f32, H as f32);
+ // Rótulo del progreso sobre la tarjeta (fuera del fantasma, siempre nítido).
+ let mut layout = LayoutTree::new();
+ let mut lbl = mount(&mut layout, card_shell(i, ROW_EXIT_Y, &format!("{pct}%"), rgb(40, 50, 60)));
+ let lc = layout.compute(lbl.root, (W as f32, H as f32)).expect("layout");
+ // Sólo el texto (sin fill): reusa la tarjeta vacía como portador del label.
+ lbl.nodes[0].fill = None;
+ cards.push((lbl, lc));
+ }
+
+ // Pinta las columnas (cada una su árbol ya reconciliado) en una escena, y
+ // por debajo de los rótulos compone los fantasmas de la fila de salida.
+ let mut ts = Typesetter::new();
+ let mut scene = vello::Scene::new();
+ scene.append(&ghost_scene, None);
+ for (m, computed) in &cards {
+ paint(&mut scene, m, computed, &mut ts, None, None);
+ }
+
+ // Volcado a PNG.
+ 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("dump-anim"),
+ 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());
+ let bg = rgb(244, 245, 248);
+ renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render_to_view");
+ write_png(&hal, &target, &out);
+ eprintln!(
+ "anim_demo: escrito {out} ({W}x{H}) — fila 1: crossfade rojo→azul · \
+ fila 2: fade-in de entrada · fila 3: fade-out de salida · {FRAMES} pasos"
+ );
+}
+
+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);
+ });
+ 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();
+}
diff --git a/llimphi-compositor/examples/animated_size_demo.rs b/llimphi-compositor/examples/animated_size_demo.rs
new file mode 100644
index 0000000..04872e0
--- /dev/null
+++ b/llimphi-compositor/examples/animated_size_demo.rs
@@ -0,0 +1,279 @@
+//! Filmstrip headless de **animateContentSize** (Bloque 15 de
+//! PARIDAD-FLUTTER): un card con `View::animated_size(key, dur)`
+//! arranca con tamaño 80×40 y, tras el primer frame, se reasigna a
+//! 320×120. Renderizamos cinco frames simulando `Instant::now()` a
+//! 0/60/120/180/240 ms — los del medio muestran el tween en curso, el
+//! último ya está asentado.
+//!
+//! 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
+//! `gap`; el segundo hijo se va corriendo según crece el primero).
+//!
+//! `cargo run -p llimphi-compositor --example animated_size_demo -- [out.png]`
+
+use std::fs::File;
+use std::io::BufWriter;
+use std::time::{Duration, Instant};
+
+use llimphi_compositor::{
+ measure_text_node, mount, paint, reconcile_size_anim, SizeAnimRegistry, 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};
+
+const FRAME_W: u32 = 360;
+const FRAME_H: u32 = 200;
+const NUM_FRAMES: u32 = 5;
+const W: u32 = FRAME_W * NUM_FRAMES;
+const H: u32 = FRAME_H;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+const KEY: u64 = 1;
+const DUR_MS: u64 = 200;
+const FRAME_STEP_MS: u64 = 60;
+
+fn rgb(r: u8, g: u8, b: u8) -> Color {
+ Color::from_rgba8(r, g, b, 255)
+}
+
+/// Card animable cuya `target_size` se elige según el frame (frame 0 =
+/// 80×40, resto = 320×120). El gap del row y el segundo hijo (un fixed
+/// 60×40) garantizan que el sibling reflowee al crecer el card.
+fn build_view(target_size: (f32, f32), accent: Color, fg: Color, panel: Color) -> View<()> {
+ let card = View::<()>::new(Style {
+ size: Size {
+ width: length(target_size.0),
+ height: length(target_size.1),
+ },
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ ..Default::default()
+ })
+ .fill(accent)
+ .radius(12.0)
+ .text_aligned("animated", 14.0, panel, Alignment::Center)
+ .animated_size(KEY, Duration::from_millis(DUR_MS));
+
+ let companion = View::<()>::new(Style {
+ size: Size {
+ width: length(60.0_f32),
+ height: length(40.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ ..Default::default()
+ })
+ .fill(panel)
+ .radius(8.0)
+ .border(1.0, rgb(180, 184, 196))
+ .text_aligned("sib", 11.0, fg, Alignment::Center);
+
+ View::<()>::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::FlexStart),
+ gap: Size {
+ width: length(12.0_f32),
+ height: length(0.0_f32),
+ },
+ padding: Rect {
+ left: length(16.0_f32),
+ right: length(16.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(rgb(245, 247, 250))
+ .children(vec![card, companion])
+}
+
+fn main() {
+ let out = std::env::args()
+ .nth(1)
+ .unwrap_or_else(|| "animated_size.png".to_string());
+
+ let theme = llimphi_theme::Theme::light();
+ let accent = theme.accent;
+ let fg = Color::from_rgba8(30, 34, 44, 255);
+ let panel = theme.bg_panel;
+
+ let mut reg = SizeAnimRegistry::new();
+ let t0 = Instant::now();
+
+ 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("dump-animated-size"),
+ 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_tex = target.create_view(&wgpu::TextureViewDescriptor::default());
+ let [r, g, b, _] = theme.bg_app.components;
+ let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
+
+ // Componemos UNA scene grande con los 5 frames lado-a-lado. Cada
+ // frame es un sub-tree de `View` posicionado con offset horizontal
+ // vía translate del paint — más simple: para cada sub-scene
+ // renderizamos a un buffer y lo blitteamos? Lo más directo: armamos
+ // un root flex Row de 5 frames con un divider mínimo.
+ let mut frames: Vec> = Vec::with_capacity(NUM_FRAMES as usize);
+ let mut ts = Typesetter::new();
+ for i in 0..NUM_FRAMES {
+ // Target size: frame 0 = 80×40 (asentado); resto = 320×120 (target nuevo).
+ let target_size = if i == 0 { (80.0, 40.0) } else { (320.0, 120.0) };
+ let mut frame_view = build_view(target_size, accent, fg, panel);
+ let when = t0 + Duration::from_millis(i as u64 * FRAME_STEP_MS);
+ // Reconcilá el size en el árbol del frame. Después del frame 0
+ // el registry conoce target=80×40. En el frame 1 el target nuevo
+ // arranca el tween; los frames 2-4 lo continúan.
+ let animating = reconcile_size_anim(&mut frame_view, &mut reg, when);
+ // Pintamos cada frame en una columna fija dentro de un row root.
+ // El alto fijo + width fijo hace que el rect del frame esté
+ // delimitado; el contenido del frame ocupa todo el alto.
+ let frame_box = View::<()>::new(Style {
+ size: Size {
+ width: length(FRAME_W as f32),
+ height: length(FRAME_H as f32),
+ },
+ flex_direction: FlexDirection::Column,
+ ..Default::default()
+ })
+ .fill(rgb(228, 232, 240))
+ .children(vec![
+ View::<()>::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(20.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(
+ format!(
+ "t = {} ms{}",
+ i as u64 * FRAME_STEP_MS,
+ if animating { " (animando)" } else { "" }
+ ),
+ 11.0,
+ fg,
+ Alignment::Center,
+ ),
+ View::<()>::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length((FRAME_H - 20) as f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![frame_view]),
+ ]);
+ frames.push(frame_box);
+ }
+ let root = View::<()>::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: length(W as f32),
+ height: length(H as f32),
+ },
+ ..Default::default()
+ })
+ .children(frames);
+
+ let mut layout = LayoutTree::new();
+ let mounted = mount(&mut layout, root);
+ 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);
+ renderer
+ .render_to_view(&hal, &scene, &view_tex, W, H, bg)
+ .expect("render_to_view");
+
+ write_png(&hal, &target, &out);
+ eprintln!(
+ "animated_size_demo: escrito {out} ({W}x{H}) — 5 frames del card que \
+ crece de 80x40 a 320x120 en 200 ms. El sibling (cuadrado 'sib') se \
+ corre hacia la derecha por el gap del row a medida que el card crece.",
+ );
+}
+
+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);
+ });
+ 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();
+}
diff --git a/llimphi-compositor/examples/backdrop_blur_demo.rs b/llimphi-compositor/examples/backdrop_blur_demo.rs
new file mode 100644
index 0000000..3ef6e1d
--- /dev/null
+++ b/llimphi-compositor/examples/backdrop_blur_demo.rs
@@ -0,0 +1,248 @@
+//! Filmstrip headless del **backdrop blur** (Bloque 11 de PARIDAD-FLUTTER):
+//! sobre un fondo con franjas de colores fuertes, una fila de cuatro paneles
+//! `.backdrop_blur(σ)` con `σ ∈ {0, 4, 8, 16}` — el primero es la referencia
+//! sin blur, el resto muestra el Gauss separable cada vez más fuerte.
+//!
+//! Prueba el camino `View::backdrop_blur` → `collect_backdrop_blurs` →
+//! `BlurCompositor::blur` (post-pasada wgpu sobre la intermediate). Render
+//! headless: vello pinta a una textura, el compositor de blur la modifica
+//! in-place sobre los rects de cada panel, y volcamos a PNG.
+//!
+//! `cargo run -p llimphi-compositor --example backdrop_blur_demo -- [out.png]`
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use llimphi_compositor::{collect_backdrop_blurs, mount, paint, View};
+use llimphi_hal::{wgpu, BlurCompositor, Hal};
+use llimphi_layout::taffy::prelude::{auto, length, percent, FlexDirection, Size, Style};
+use llimphi_layout::taffy::{Position, Rect};
+use llimphi_layout::LayoutTree;
+use llimphi_raster::peniko::Color;
+use llimphi_raster::{vello, Renderer};
+use llimphi_text::Typesetter;
+
+const W: u32 = 1200;
+const H: u32 = 360;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+const SIGMAS: [f32; 4] = [0.0, 4.0, 8.0, 16.0];
+
+fn rgb(r: u8, g: u8, b: u8) -> Color {
+ Color::from_rgba8(r, g, b, 255)
+}
+
+fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color {
+ Color::from_rgba8(r, g, b, a)
+}
+
+fn main() {
+ let out = std::env::args()
+ .nth(1)
+ .unwrap_or_else(|| "backdrop_blur.png".to_string());
+
+ // Fondo: cuatro franjas verticales saturadas — el blur tiene que mezclar
+ // los bordes entre franjas, así el efecto se ve aun sin texto/detalle.
+ let franjas: Vec> = [
+ rgb(231, 76, 60),
+ rgb(241, 196, 15),
+ rgb(46, 204, 113),
+ rgb(52, 152, 219),
+ ]
+ .iter()
+ .map(|c| {
+ View::<()>::new(Style {
+ size: Size {
+ width: percent(0.25),
+ height: percent(1.0),
+ },
+ ..Default::default()
+ })
+ .fill(*c)
+ })
+ .collect();
+ let fondo = View::<()>::new(Style {
+ size: Size {
+ width: percent(1.0),
+ height: percent(1.0),
+ },
+ position: Position::Absolute,
+ inset: Rect {
+ left: length(0.0),
+ top: length(0.0),
+ right: auto(),
+ bottom: auto(),
+ },
+ flex_direction: FlexDirection::Row,
+ ..Default::default()
+ })
+ .children(franjas);
+
+ // Fila de paneles "vidrio": cada uno apunta a un σ distinto. Todos
+ // `Position::Absolute` con inset calculado para superponerse al fondo.
+ // El panel es un rect translúcido sin contenido propio (el blur post-
+ // pasada borronea TODO lo que está dentro del rect, así que un texto
+ // *dentro* del panel saldría borroso — limitación documentada del v1).
+ let panel_w = 240.0_f32;
+ let panel_h = 220.0_f32;
+ let gap = 24.0_f32;
+ let fila_w = SIGMAS.len() as f32 * panel_w + (SIGMAS.len() as f32 - 1.0) * gap;
+ let inicio_x = (W as f32 - fila_w) * 0.5;
+ let panel_y = (H as f32 - panel_h) * 0.5;
+
+ let mut hijos: Vec> = vec![fondo];
+ for (i, &sigma) in SIGMAS.iter().enumerate() {
+ let x = inicio_x + i as f32 * (panel_w + gap);
+ let panel = View::<()>::new(Style {
+ position: Position::Absolute,
+ inset: Rect {
+ left: length(x),
+ top: length(panel_y),
+ right: auto(),
+ bottom: auto(),
+ },
+ size: Size {
+ width: length(panel_w),
+ height: length(panel_h),
+ },
+ ..Default::default()
+ })
+ .fill(rgba(255, 255, 255, 96))
+ .radius(20.0)
+ .border(1.5, rgba(255, 255, 255, 180))
+ .backdrop_blur(sigma);
+ hijos.push(panel);
+ }
+
+ let root = View::<()>::new(Style {
+ size: Size {
+ width: length(W as f32),
+ height: length(H as f32),
+ },
+ ..Default::default()
+ })
+ .children(hijos);
+
+ let mut layout = LayoutTree::new();
+ let mounted = mount(&mut layout, root);
+ let computed = layout
+ .compute(mounted.root, (W as f32, H as f32))
+ .expect("layout");
+
+ let mut ts = Typesetter::new();
+ let mut scene = vello::Scene::new();
+ paint(&mut scene, &mounted, &computed, &mut ts, None, None);
+
+ // Render + post-pase de blur con BlurCompositor — el mismo camino que
+ // toma `llimphi-ui` durante un redraw real.
+ let hal = pollster::block_on(Hal::new(None)).expect("hal");
+ let mut renderer = Renderer::new(&hal).expect("renderer");
+ let mut blur = BlurCompositor::new(&hal.device);
+ let target = hal.device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("dump-blur"),
+ 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 view = target.create_view(&wgpu::TextureViewDescriptor::default());
+ renderer
+ .render_to_view(&hal, &scene, &view, W, H, Color::BLACK)
+ .expect("render_to_view");
+
+ let blurs = collect_backdrop_blurs(&mounted, &computed);
+ let mut encoder = hal
+ .device
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor {
+ label: Some("blur-demo-encoder"),
+ });
+ for b in &blurs {
+ blur.blur(
+ &hal.device,
+ &hal.queue,
+ &mut encoder,
+ &view,
+ (W, H),
+ b.rect,
+ b.sigma,
+ );
+ }
+ hal.queue.submit(std::iter::once(encoder.finish()));
+
+ write_png(&hal, &target, &out);
+ eprintln!(
+ "backdrop_blur_demo: escrito {out} ({W}x{H}) — {} paneles σ={:?}; \
+ σ=0 queda nítido (el compositor no-op'ea); los demás muestran el \
+ Gauss separable sobre las franjas. {} blur node(s) detectado(s).",
+ SIGMAS.len(),
+ SIGMAS,
+ blurs.len(),
+ );
+}
+
+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);
+ });
+ 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();
+}
diff --git a/llimphi-compositor/examples/filter_demo.rs b/llimphi-compositor/examples/filter_demo.rs
new file mode 100644
index 0000000..e4ca5f6
--- /dev/null
+++ b/llimphi-compositor/examples/filter_demo.rs
@@ -0,0 +1,281 @@
+//! Filmstrip headless de la **familia `filter`** (Fases 7.1232–7.1235): sobre un
+//! fondo a franjas, una fila de tiles iguales, cada uno con un `filter` distinto
+//! — referencia, `blur`, `grayscale`, `invert`, `sepia` y `drop-shadow`.
+//!
+//! Ejercita el camino completo `View::filter` → `collect_filters` → post-pasada
+//! GPU (`BlurCompositor` para `blur`, `ColorFilterCompositor` para las matrices
+//! de color), más el `drop-shadow` que se pinta inline en vello (no es
+//! post-pasada). Es el mismo orden que toma `llimphi-ui` en un redraw real.
+//! Render headless: vello pinta a una textura, los compositores la modifican
+//! in-place sobre el rect de cada tile, y volcamos a PNG.
+//!
+//! `cargo run -p llimphi-compositor --example filter_demo -- [out.png]`
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use llimphi_compositor::{collect_filters, mount, paint, FilterOp, Shadow, View};
+use llimphi_hal::{wgpu, BlurCompositor, ColorFilterCompositor, Hal};
+use llimphi_layout::taffy::prelude::{auto, length, percent, FlexDirection, Size, Style};
+use llimphi_layout::taffy::{Position, Rect};
+use llimphi_layout::LayoutTree;
+use llimphi_raster::peniko::Color;
+use llimphi_raster::{vello, Renderer};
+use llimphi_text::Typesetter;
+
+const W: u32 = 1320;
+const H: u32 = 320;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+fn rgb(r: u8, g: u8, b: u8) -> Color {
+ Color::from_rgba8(r, g, b, 255)
+}
+
+// Matriz identidad 4×5 (referencia, sin efecto).
+const IDENTITY: [f32; 20] = [
+ 1., 0., 0., 0., 0., //
+ 0., 1., 0., 0., 0., //
+ 0., 0., 1., 0., 0., //
+ 0., 0., 0., 1., 0.,
+];
+
+// grayscale(1): luminancia Rec.709 en las tres filas.
+const GRAYSCALE: [f32; 20] = [
+ 0.2126, 0.7152, 0.0722, 0., 0., //
+ 0.2126, 0.7152, 0.0722, 0., 0., //
+ 0.2126, 0.7152, 0.0722, 0., 0., //
+ 0., 0., 0., 1., 0.,
+];
+
+// invert(1): out = 1 - in.
+const INVERT: [f32; 20] = [
+ -1., 0., 0., 0., 1., //
+ 0., -1., 0., 0., 1., //
+ 0., 0., -1., 0., 1., //
+ 0., 0., 0., 1., 0.,
+];
+
+// sepia(1): matriz fija de la spec.
+const SEPIA: [f32; 20] = [
+ 0.393, 0.769, 0.189, 0., 0., //
+ 0.349, 0.686, 0.168, 0., 0., //
+ 0.272, 0.534, 0.131, 0., 0., //
+ 0., 0., 0., 1., 0.,
+];
+
+fn main() {
+ let out = std::env::args()
+ .nth(1)
+ .unwrap_or_else(|| "filter.png".to_string());
+
+ // Etiqueta + FilterOp de cada tile. El primero es la referencia sin filtro.
+ let tiles: Vec<(&str, Option)> = vec![
+ ("ref", None),
+ ("blur", Some(FilterOp::Blur(6.0))),
+ ("grayscale", Some(FilterOp::ColorMatrix(GRAYSCALE))),
+ ("invert", Some(FilterOp::ColorMatrix(INVERT))),
+ ("sepia", Some(FilterOp::ColorMatrix(SEPIA))),
+ (
+ "drop-shadow",
+ Some(FilterOp::DropShadow(Shadow {
+ color: Color::from_rgba8(0, 0, 0, 160),
+ blur: 12.0,
+ dx: 8.0,
+ dy: 10.0,
+ spread: 0.0,
+ })),
+ ),
+ ];
+ let _ = IDENTITY; // referencia documentada arriba.
+
+ // Fondo a franjas para que el blur tenga bordes que mezclar.
+ let franjas: Vec> = [
+ rgb(231, 76, 60),
+ rgb(241, 196, 15),
+ rgb(46, 204, 113),
+ rgb(52, 152, 219),
+ rgb(155, 89, 182),
+ ]
+ .iter()
+ .map(|c| {
+ View::<()>::new(Style {
+ size: Size { width: percent(0.2), height: percent(1.0) },
+ ..Default::default()
+ })
+ .fill(*c)
+ })
+ .collect();
+ let fondo = View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: percent(1.0) },
+ position: Position::Absolute,
+ inset: Rect { left: length(0.0), top: length(0.0), right: auto(), bottom: auto() },
+ flex_direction: FlexDirection::Row,
+ ..Default::default()
+ })
+ .children(franjas);
+
+ let tile_w = 180.0_f32;
+ let tile_h = 180.0_f32;
+ let gap = 24.0_f32;
+ let n = tiles.len() as f32;
+ let fila_w = n * tile_w + (n - 1.0) * gap;
+ let inicio_x = (W as f32 - fila_w) * 0.5;
+ let tile_y = (H as f32 - tile_h) * 0.5;
+
+ let mut hijos: Vec> = vec![fondo];
+ for (i, (_, op)) in tiles.iter().enumerate() {
+ let x = inicio_x + i as f32 * (tile_w + gap);
+ // Cada tile: un rect blanco con un bloque interno multicolor, para que
+ // las matrices de color tengan algo que transformar.
+ let interno = View::<()>::new(Style {
+ size: Size { width: percent(0.7), height: percent(0.7) },
+ margin: Rect {
+ left: percent(0.15),
+ top: percent(0.15),
+ right: auto(),
+ bottom: auto(),
+ },
+ ..Default::default()
+ })
+ .fill(rgb(255, 140, 0))
+ .radius(14.0);
+ let mut tile = View::<()>::new(Style {
+ position: Position::Absolute,
+ inset: Rect {
+ left: length(x),
+ top: length(tile_y),
+ right: auto(),
+ bottom: auto(),
+ },
+ size: Size { width: length(tile_w), height: length(tile_h) },
+ ..Default::default()
+ })
+ .fill(rgb(245, 245, 245))
+ .radius(18.0)
+ .children(vec![interno]);
+ if let Some(op) = op {
+ tile = tile.filter(vec![op.clone()]);
+ }
+ hijos.push(tile);
+ }
+
+ let root = View::<()>::new(Style {
+ size: Size { width: length(W as f32), height: length(H as f32) },
+ ..Default::default()
+ })
+ .children(hijos);
+
+ let mut layout = LayoutTree::new();
+ let mounted = mount(&mut layout, root);
+ let computed = layout
+ .compute(mounted.root, (W as f32, H as f32))
+ .expect("layout");
+
+ let mut ts = Typesetter::new();
+ let mut scene = vello::Scene::new();
+ // `paint` ya pinta los drop-shadow inline (no son post-pasada).
+ 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 mut blur = BlurCompositor::new(&hal.device);
+ let mut color = ColorFilterCompositor::new(&hal.device);
+
+ let target = hal.device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("dump-filter"),
+ 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 view = target.create_view(&wgpu::TextureViewDescriptor::default());
+ renderer
+ .render_to_view(&hal, &scene, &view, W, H, Color::BLACK)
+ .expect("render_to_view");
+
+ // Post-pasadas de filtro: el mismo camino que `llimphi-ui::redraw`.
+ let passes = collect_filters(&mounted, &computed);
+ let mut encoder = hal
+ .device
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("filter-demo") });
+ for p in &passes {
+ match &p.op {
+ FilterOp::Blur(sigma) => {
+ blur.blur(&hal.device, &hal.queue, &mut encoder, &view, (W, H), p.rect, *sigma);
+ }
+ FilterOp::ColorMatrix(m) => {
+ color.apply(&hal.device, &hal.queue, &mut encoder, &view, (W, H), p.rect, *m);
+ }
+ FilterOp::DropShadow(_) => {} // ya pintado por vello en `paint`.
+ }
+ }
+ hal.queue.submit(std::iter::once(encoder.finish()));
+
+ write_png(&hal, &target, &out);
+ eprintln!(
+ "filter_demo: escrito {out} ({W}x{H}) — tiles {:?}; {} post-pasada(s) de \
+ filtro (blur+color), drop-shadow pintado inline. ref queda sin tocar.",
+ tiles.iter().map(|(l, _)| *l).collect::>(),
+ passes.len(),
+ );
+}
+
+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();
+}
diff --git a/llimphi-compositor/examples/gpu_primitivos_demo.rs b/llimphi-compositor/examples/gpu_primitivos_demo.rs
new file mode 100644
index 0000000..7273dec
--- /dev/null
+++ b/llimphi-compositor/examples/gpu_primitivos_demo.rs
@@ -0,0 +1,264 @@
+//! Demo headless de los dos primitivos GPU nuevos de Llimphi:
+//!
+//! - **Primitivo A** — disco/círculo relleno con AA por SDF en el shader
+//! (`GpuBatch::add_disc` / `add_ring` en `llimphi-raster`). Esta demo
+//! pinta por GPU directo una grilla de *rects instanciados* + una grilla
+//! de *discos AA* sobre la misma pasada `GpuBatch::flush`.
+//! - **Primitivo B** — over-layer: una escena vello que se rasteriza
+//! DESPUÉS del pase GPU y se compone con alpha encima (un disco vello
+//! grande + el rótulo "OVER" que deben quedar SOBRE los rects/discos
+//! GPU). Replica exactamente el orden que el eventloop de `llimphi-ui`
+//! aplica para `View::paint_over`: `[vello base] → [GPU] → [vello over]`.
+//!
+//! No abre ventana: compone sobre una textura intermedia `Rgba8Unorm`
+//! (misma mecánica que el frame real) y vuelca el resultado a PNG.
+//!
+//! `cargo run -p llimphi-compositor --example gpu_primitivos_demo --release -- [out.png]`
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use llimphi_hal::{wgpu, Hal, OverlayCompositor};
+use llimphi_raster::gpu::{GpuBatch, GpuPipelines};
+use llimphi_raster::peniko::{Color, Fill};
+use llimphi_raster::{vello, Renderer};
+use llimphi_text::{draw_block, TextBlock, Typesetter};
+use vello::kurbo::{Affine, Circle};
+
+const W: u32 = 720;
+const H: u32 = 480;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+fn main() {
+ let out = std::env::args()
+ .nth(1)
+ .unwrap_or_else(|| "gpu_primitivos_demo.png".to_string());
+
+ let hal = pollster::block_on(Hal::new(None)).expect("hal");
+ let mut renderer = Renderer::new(&hal).expect("renderer");
+ let pipelines = GpuPipelines::new(&hal.device, FMT);
+ let overlay = OverlayCompositor::new(&hal.device);
+
+ // ── Textura intermedia (donde se compone todo) ──────────────────────
+ let inter = make_tex(
+ &hal.device,
+ wgpu::TextureUsages::STORAGE_BINDING
+ | wgpu::TextureUsages::TEXTURE_BINDING
+ | wgpu::TextureUsages::RENDER_ATTACHMENT
+ | wgpu::TextureUsages::COPY_SRC,
+ );
+ let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
+
+ // ── (1) vello base: fondo + rótulo "base vello" ─────────────────────
+ // render_to_view limpia con base_color y escribe todos los píxeles.
+ let mut base = vello::Scene::new();
+ let mut ts = Typesetter::new();
+ draw_label(
+ &mut base,
+ &mut ts,
+ 16.0,
+ 24.0,
+ "base vello (fondo)",
+ 18.0,
+ Color::from_rgba8(120, 130, 150, 255),
+ );
+ renderer
+ .render_to_view(
+ &hal,
+ &base,
+ &inter_view,
+ W,
+ H,
+ Color::from_rgba8(16, 20, 30, 255),
+ )
+ .expect("render base");
+
+ // ── (2) pase GPU directo: grilla de rects + grilla de discos AA ─────
+ // Un solo GpuBatch → un flush con LoadOp::Load (preserva el fondo vello).
+ let mut batch = GpuBatch::new(&pipelines);
+ // Grilla de rects instanciados (mitad izquierda).
+ for j in 0..6 {
+ for i in 0..6 {
+ let x = 40.0 + i as f32 * 46.0;
+ let y = 70.0 + j as f32 * 46.0;
+ let c = Color::from_rgba8(
+ 60 + (i * 30) as u8,
+ 90 + (j * 24) as u8,
+ 200,
+ 255,
+ );
+ batch.add_rect(x, y, 36.0, 36.0, c);
+ }
+ }
+ // Grilla de discos AA (mitad derecha) — radios variables para ver el
+ // suavizado del borde a distintas escalas.
+ for j in 0..6 {
+ for i in 0..6 {
+ let cx = 400.0 + i as f32 * 46.0;
+ let cy = 88.0 + j as f32 * 46.0;
+ let r = 8.0 + (i + j) as f32 * 1.4;
+ let c = Color::from_rgba8(
+ 240,
+ 120 + (i * 18) as u8,
+ 60 + (j * 24) as u8,
+ 255,
+ );
+ batch.add_disc(cx, cy, r, c);
+ }
+ }
+ // Un anillo grande para ejercitar add_ring (borde interno + externo AA).
+ batch.add_ring(180.0, 380.0, 46.0, 10.0, Color::from_rgba8(120, 240, 200, 255));
+
+ let mut enc = hal
+ .device
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor {
+ label: Some("gpu-prim-pass"),
+ });
+ batch.flush(
+ &hal.device,
+ &hal.queue,
+ &mut enc,
+ &inter_view,
+ (W as f32, H as f32),
+ wgpu::LoadOp::Load,
+ );
+ hal.queue.submit(std::iter::once(enc.finish()));
+ let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
+
+ // ── (3) over-layer vello: disco grande + rótulo "OVER" ──────────────
+ // Se rasteriza en una scratch transparente y se compone con alpha
+ // sobre la intermedia DESPUÉS del pase GPU → queda ENCIMA de los
+ // rects/discos GPU. Espejo exacto del camino de redraw.rs.
+ let scratch = make_tex(
+ &hal.device,
+ wgpu::TextureUsages::STORAGE_BINDING
+ | wgpu::TextureUsages::TEXTURE_BINDING
+ | wgpu::TextureUsages::RENDER_ATTACHMENT,
+ );
+ let scratch_view = scratch.create_view(&wgpu::TextureViewDescriptor::default());
+
+ let mut over = vello::Scene::new();
+ // Disco vello grande y semitransparente que se monta SOBRE la grilla
+ // GPU (su centro cae sobre rects y discos a la vez).
+ over.fill(
+ Fill::NonZero,
+ Affine::IDENTITY,
+ Color::from_rgba8(255, 60, 120, 200),
+ None,
+ &Circle::new((300.0, 230.0), 70.0),
+ );
+ draw_label(
+ &mut over,
+ &mut ts,
+ 232.0,
+ 222.0,
+ "OVER",
+ 30.0,
+ Color::from_rgba8(255, 255, 255, 255),
+ );
+ renderer
+ .render_to_view(&hal, &over, &scratch_view, W, H, Color::TRANSPARENT)
+ .expect("render over");
+
+ let mut enc2 = hal
+ .device
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor {
+ label: Some("over-composite"),
+ });
+ overlay.composite(&hal.device, &mut enc2, &inter_view, &scratch_view);
+ hal.queue.submit(std::iter::once(enc2.finish()));
+ let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
+
+ // ── (4) readback → PNG ──────────────────────────────────────────────
+ write_png(&hal, &inter, &out);
+ eprintln!("gpu_primitivos_demo: escrito {out} ({W}x{H})");
+}
+
+fn make_tex(device: &wgpu::Device, usage: wgpu::TextureUsages) -> wgpu::Texture {
+ device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("gpu-prim-tex"),
+ 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,
+ view_formats: &[],
+ })
+}
+
+fn draw_label(
+ scene: &mut vello::Scene,
+ ts: &mut Typesetter,
+ x: f32,
+ y: f32,
+ text: &str,
+ size: f32,
+ color: Color,
+) {
+ // Reusa el typesetter: layout de una línea y blit de glyphs a la escena.
+ let block = TextBlock::simple(text, size, color, (x as f64, y as f64));
+ draw_block(scene, ts, &block);
+}
+
+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();
+}
diff --git a/llimphi-compositor/examples/image_fit_demo.rs b/llimphi-compositor/examples/image_fit_demo.rs
new file mode 100644
index 0000000..660928d
--- /dev/null
+++ b/llimphi-compositor/examples/image_fit_demo.rs
@@ -0,0 +1,288 @@
+//! Filmstrip headless de **`ImageFit`** (Bloque 12 de PARIDAD-FLUTTER):
+//! una misma imagen 4:3 sintética se compone en cinco rects 1:1 con
+//! `ImageFit::{Contain, Cover, Fill, None}` y, al final, un círculo
+//! redondeado al máximo con `ImageFit::Cover` para verificar que el
+//! clip respeta `radius` / `corner_radii` (caso avatar).
+//!
+//! Prueba el camino `View::image` + `View::image_fit` → `paint` (pasada
+//! de `node.image_fit` y `node_rrect` para el clip). Render headless:
+//! vello pinta a una textura y volcamos a PNG.
+//!
+//! `cargo run -p llimphi-compositor --example image_fit_demo -- [out.png]`
+
+use std::fs::File;
+use std::io::BufWriter;
+use std::sync::Arc;
+
+use llimphi_compositor::{measure_text_node, mount, paint, ImageFit, 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::{
+ Blob, Color, ImageAlphaType, ImageBrush as Image, ImageData, ImageFormat,
+};
+use llimphi_raster::{vello, Renderer};
+use llimphi_text::{Alignment, Typesetter};
+
+const W: u32 = 1500;
+const H: u32 = 380;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+/// Imagen sintética 4:3 (`240×180`): cuadrícula de 4×3 con colores
+/// distintos por celda + cruz central blanca. Permite "ver" la
+/// diferencia entre los fits sin embeber un archivo (`Contain` deja
+/// banda, `Cover` recorta, `Fill` deforma la cruz, `None` clippea las
+/// celdas externas).
+fn make_image() -> Image {
+ const IW: u32 = 240;
+ const IH: u32 = 180;
+ const COLS: u32 = 4;
+ const ROWS: u32 = 3;
+ let palette: [[u8; 3]; 12] = [
+ [231, 76, 60], [241, 196, 15], [46, 204, 113], [52, 152, 219],
+ [155, 89, 182], [26, 188, 156], [230, 126, 34], [149, 165, 166],
+ [192, 57, 43], [243, 156, 18], [22, 160, 133], [41, 128, 185],
+ ];
+ let mut px: Vec = Vec::with_capacity((IW * IH * 4) as usize);
+ let cw = IW / COLS;
+ let ch = IH / ROWS;
+ for y in 0..IH {
+ for x in 0..IW {
+ let col = (x / cw).min(COLS - 1);
+ let row = (y / ch).min(ROWS - 1);
+ let idx = (row * COLS + col) as usize;
+ // Cruz central blanca, ~8 px de grosor — la deformación de
+ // `Fill` se hace evidente cuando los brazos cambian de razón.
+ let mid_x = (x as i32 - IW as i32 / 2).abs() <= 4;
+ let mid_y = (y as i32 - IH as i32 / 2).abs() <= 4;
+ let [r, g, b] = if mid_x || mid_y {
+ [255, 255, 255]
+ } else {
+ palette[idx]
+ };
+ px.extend_from_slice(&[r, g, b, 255]);
+ }
+ }
+ Image::new(ImageData {
+ data: Blob::new(Arc::new(px)),
+ format: ImageFormat::Rgba8,
+ alpha_type: ImageAlphaType::Alpha,
+ width: IW,
+ height: IH,
+ })
+}
+
+/// Una "ficha" con la imagen arriba (cuadrada de 200×200) + un rótulo
+/// abajo con el nombre del fit. Cuerpo blanco con borde sutil.
+fn ficha(img: &Image, fit: ImageFit, label: &str, panel: Color, fg: Color) -> View<()> {
+ let visor = View::<()>::new(Style {
+ size: Size { width: length(200.0_f32), height: length(200.0_f32) },
+ ..Default::default()
+ })
+ .fill(Color::from_rgba8(30, 34, 44, 255)) // fondo gris para que `Contain` deje banda visible
+ .radius(8.0)
+ .border(1.0, Color::from_rgba8(60, 66, 80, 255))
+ .image(img.clone())
+ .image_fit(fit);
+
+ View::<()>::new(Style {
+ size: Size { width: length(220.0_f32), height: length(260.0_f32) },
+ flex_direction: FlexDirection::Column,
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::FlexStart),
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ padding: Rect {
+ left: length(10.0_f32),
+ right: length(10.0_f32),
+ top: length(10.0_f32),
+ bottom: length(10.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(panel)
+ .radius(14.0)
+ .border(1.0, Color::from_rgba8(220, 224, 232, 255))
+ .children(vec![
+ visor,
+ View::<()>::new(Style {
+ size: Size { width: percent(0.95_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(label.to_string(), 14.0, fg, Alignment::Center),
+ ])
+}
+
+/// Ficha avatar: imagen rectangular 4:3 metida en un cuadrado con
+/// radio máximo (= círculo) y `Cover`. Verifica que el clip respeta el
+/// `node_rrect` (corona el caso que rompía antes del Bloque 12).
+fn avatar(img: &Image, panel: Color, fg: Color) -> View<()> {
+ let crc = View::<()>::new(Style {
+ size: Size { width: length(200.0_f32), height: length(200.0_f32) },
+ ..Default::default()
+ })
+ .fill(Color::from_rgba8(30, 34, 44, 255))
+ .radius(100.0) // círculo completo
+ .border(2.0, Color::from_rgba8(60, 66, 80, 255))
+ .image(img.clone())
+ .image_fit(ImageFit::Cover);
+
+ View::<()>::new(Style {
+ size: Size { width: length(220.0_f32), height: length(260.0_f32) },
+ flex_direction: FlexDirection::Column,
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::FlexStart),
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ padding: Rect {
+ left: length(10.0_f32),
+ right: length(10.0_f32),
+ top: length(10.0_f32),
+ bottom: length(10.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(panel)
+ .radius(14.0)
+ .border(1.0, Color::from_rgba8(220, 224, 232, 255))
+ .children(vec![
+ crc,
+ View::<()>::new(Style {
+ size: Size { width: percent(0.95_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned("Cover + radio".to_string(), 14.0, fg, Alignment::Center),
+ ])
+}
+
+fn main() {
+ let out = std::env::args().nth(1).unwrap_or_else(|| "image_fit.png".to_string());
+ let theme = llimphi_theme::Theme::light();
+ let panel = theme.bg_panel;
+ let fg = Color::from_rgba8(30, 34, 44, 255);
+
+ let img = make_image();
+
+ let root = View::<()>::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ gap: Size { width: length(20.0_f32), height: length(0.0_f32) },
+ 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(theme.bg_app)
+ .children(vec![
+ ficha(&img, ImageFit::Contain, "Contain", panel, fg),
+ ficha(&img, ImageFit::Cover, "Cover", panel, fg),
+ ficha(&img, ImageFit::Fill, "Fill", panel, fg),
+ ficha(&img, ImageFit::None, "None", panel, fg),
+ avatar(&img, panel, fg),
+ ]);
+
+ 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("dump-image-fit"),
+ 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());
+ let [r, g, b, _] = theme.bg_app.components;
+ let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
+ renderer
+ .render_to_view(&hal, &scene, &view, W, H, bg)
+ .expect("render_to_view");
+
+ write_png(&hal, &target, &out);
+ eprintln!(
+ "image_fit_demo: escrito {out} ({W}x{H}) — 5 fichas: Contain (deja \
+ banda en el eje extra) · Cover (recorta el sobrante) · Fill (deforma \
+ la cruz) · None (1:1 centrada, recorta lo que no entra) · Cover sobre \
+ un cuadrado con radius=100 (avatar circular)."
+ );
+}
+
+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);
+ });
+ 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();
+}
diff --git a/llimphi-compositor/examples/layout_builder_demo.rs b/llimphi-compositor/examples/layout_builder_demo.rs
new file mode 100644
index 0000000..79ce69e
--- /dev/null
+++ b/llimphi-compositor/examples/layout_builder_demo.rs
@@ -0,0 +1,243 @@
+//! Demo headless del **LayoutBuilder** (Bloque 9 de PARIDAD-FLUTTER): el MISMO
+//! árbol declarativo, renderizado a dos anchos de viewport. Un panel central
+//! usa `View::layout_builder`: si su slot es **angosto** apila las tarjetas en
+//! **1 columna**; si es **ancho**, en **2 columnas**. La decisión depende del
+//! tamaño del slot (no de la ventana), resuelto en dos pasadas — exactamente lo
+//! que el runtime hace por frame.
+//!
+//! Emula el camino del runtime (`resolve_layout_builders`) con las funciones
+//! puras del compositor: `has_layout_builder` → mount pasada 1 → compute →
+//! `collect_builder_constraints` → `expand_layout_builders` → mount/paint.
+//!
+//! Vuelca dos PNGs (`-angosto.png` y `-ancho.png`).
+//! `cargo run -p llimphi-compositor --example layout_builder_demo -- [base]`
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use llimphi_compositor::{
+ collect_builder_constraints, expand_layout_builders, has_layout_builder, mount, paint,
+ Constraints, View,
+};
+use llimphi_hal::{wgpu, Hal};
+use llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
+use llimphi_layout::taffy::{AlignItems, JustifyContent, LengthPercentage, Rect};
+use llimphi_layout::LayoutTree;
+use llimphi_raster::peniko::Color;
+use llimphi_raster::{vello, Renderer};
+use llimphi_text::{Alignment, Typesetter};
+
+const H: u32 = 360;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+/// Bajo este ancho de slot, el panel apila en 1 columna; por encima, 2.
+const BREAKPOINT: f32 = 360.0;
+
+fn rgb(r: u8, g: u8, b: u8) -> Color {
+ Color::from_rgba8(r, g, b, 255)
+}
+
+/// Una tarjeta de muestra.
+fn card(label: &str) -> View<()> {
+ View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(64.0) },
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ ..Default::default()
+ })
+ .fill(rgb(60, 72, 100))
+ .radius(10.0)
+ .children(vec![View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(20.0) },
+ ..Default::default()
+ })
+ .text_aligned(label.to_string(), 14.0, rgb(235, 238, 245), Alignment::Center)])
+}
+
+/// El subárbol que el builder produce según sus constraints: 1 columna si
+/// angosto, 2 si ancho. Cada columna es un flex column con tarjetas.
+fn responsive_panel(c: Constraints) -> View<()> {
+ let dos_columnas = c.max_width >= BREAKPOINT;
+ let etiqueta = if dos_columnas {
+ format!("slot {:.0}px = 2 columnas", c.max_width)
+ } else {
+ format!("slot {:.0}px = 1 columna", c.max_width)
+ };
+ let header = View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(28.0) },
+ ..Default::default()
+ })
+ .text_aligned(etiqueta, 13.0, rgb(150, 200, 160), Alignment::Center);
+
+ let col = |labels: &[&str]| {
+ View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: percent(1.0) },
+ flex_direction: FlexDirection::Column,
+ gap: Size { width: length(0.0), height: length(10.0) },
+ ..Default::default()
+ })
+ .children(labels.iter().map(|l| card(l)).collect())
+ };
+
+ let cuerpo = if dos_columnas {
+ View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: percent(1.0) },
+ flex_direction: FlexDirection::Row,
+ gap: Size { width: length(12.0), height: length(0.0) },
+ ..Default::default()
+ })
+ .children(vec![col(&["Uno", "Tres"]), col(&["Dos", "Cuatro"])])
+ } else {
+ col(&["Uno", "Dos", "Tres", "Cuatro"])
+ };
+
+ View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: percent(1.0) },
+ flex_direction: FlexDirection::Column,
+ gap: Size { width: length(0.0), height: length(8.0) },
+ ..Default::default()
+ })
+ .children(vec![header, cuerpo])
+}
+
+/// Árbol raíz: una sidebar fija + un panel central que es el `layout_builder`.
+/// El ancho del slot del panel = viewport − sidebar − paddings, así cambia con
+/// el viewport sin que el árbol "sepa" el tamaño al construirse.
+fn root() -> View<()> {
+ let sidebar = View::<()>::new(Style {
+ size: Size { width: length(160.0), height: percent(1.0) },
+ ..Default::default()
+ })
+ .fill(rgb(34, 40, 54))
+ .radius(12.0)
+ .children(vec![View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(20.0) },
+ ..Default::default()
+ })
+ .text_aligned("sidebar", 13.0, rgb(140, 150, 170), Alignment::Center)]);
+
+ let panel = View::<()>::new(Style {
+ flex_grow: 1.0,
+ size: Size { width: percent(0.0), height: percent(1.0) },
+ ..Default::default()
+ })
+ .layout_builder(responsive_panel);
+
+ View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: percent(1.0) },
+ flex_direction: FlexDirection::Row,
+ gap: Size { width: length(16.0), height: length(0.0) },
+ padding: Rect {
+ left: LengthPercentage::length(16.0),
+ right: LengthPercentage::length(16.0),
+ top: LengthPercentage::length(16.0),
+ bottom: LengthPercentage::length(16.0),
+ },
+ ..Default::default()
+ })
+ .fill(rgb(24, 28, 38))
+ .children(vec![sidebar, panel])
+}
+
+/// Resuelve los builders (dos pasadas) y vuelca el árbol a un PNG a ese ancho.
+fn render_a(ancho: u32, ts: &mut Typesetter, hal: &Hal, renderer: &mut Renderer, path: &str) {
+ let viewport = (ancho as f32, H as f32);
+ // Pasada 1: montar (builders como hojas) + computar.
+ let v1 = root();
+ assert!(has_layout_builder(&v1), "el demo debe tener un layout_builder");
+ let mut l1 = LayoutTree::new();
+ let m1 = mount(&mut l1, v1);
+ let c1 = l1.compute(m1.root, viewport).expect("layout p1");
+ let cons = collect_builder_constraints(&m1, &c1);
+ // Pasada 2: árbol fresco + expand con las constraints reales.
+ let resolved = expand_layout_builders(root(), &cons);
+ let mut l2 = LayoutTree::new();
+ let m2 = mount(&mut l2, resolved);
+ let c2 = l2.compute(m2.root, viewport).expect("layout p2");
+
+ let mut scene = vello::Scene::new();
+ paint(&mut scene, &m2, &c2, ts, None, None);
+
+ let target = hal.device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("dump-lb"),
+ size: wgpu::Extent3d { width: ancho, 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, ancho, H, rgb(244, 245, 248))
+ .expect("render_to_view");
+ write_png(hal, &target, ancho, path);
+ eprintln!("layout_builder_demo: escrito {path} ({ancho}x{H}) — slot panel {:.0}px", cons[0].max_width);
+}
+
+fn main() {
+ let base = std::env::args().nth(1).unwrap_or_else(|| "lb".to_string());
+ let hal = pollster::block_on(Hal::new(None)).expect("hal");
+ let mut renderer = Renderer::new(&hal).expect("renderer");
+ let mut ts = Typesetter::new();
+ // Angosto: viewport 460 → slot ~268px (<360) → 1 columna.
+ render_a(460, &mut ts, &hal, &mut renderer, &format!("{base}-angosto.png"));
+ // Ancho: viewport 760 → slot ~568px (≥360) → 2 columnas.
+ render_a(760, &mut ts, &hal, &mut renderer, &format!("{base}-ancho.png"));
+}
+
+fn write_png(hal: &Hal, target: &wgpu::Texture, w: u32, 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);
+ });
+ 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 wr = enc.write_header().unwrap();
+ wr.write_image_data(&pixels).unwrap();
+}
diff --git a/llimphi-compositor/examples/pantallazo_motor.rs b/llimphi-compositor/examples/pantallazo_motor.rs
new file mode 100644
index 0000000..f4adb0d
--- /dev/null
+++ b/llimphi-compositor/examples/pantallazo_motor.rs
@@ -0,0 +1,697 @@
+//! Pantallazo headless del motor — **una UI real y densa** compuesta sólo con
+//! primitivas del compositor (View → layout → vello → wgpu → PNG), pensada
+//! para la tarjeta pública "Un motor gráfico soberano".
+//!
+//! Muestra en una sola pasada: tema oscuro de `llimphi-theme`, top bar con
+//! tabs, sidebar con filas seleccionadas, un editor de código con resaltado
+//! sintáctico vía `TextSpan`s sobre fuente mono, un párrafo de texto rico
+//! (pesos, cursiva, subrayado, mono inline), tarjetas de métricas con
+//! gradientes y sombras, un mini gráfico de barras hecho con puros rects,
+//! chips/botones, y un toast flotante con esquinas asimétricas.
+//!
+//! `cargo run -p llimphi-compositor --example pantallazo_motor --release -- [out.png]`
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use llimphi_compositor::{measure_text_node, mount, paint, Shadow, View};
+use llimphi_hal::{wgpu, Hal};
+use llimphi_layout::taffy;
+use llimphi_layout::taffy::prelude::{auto, length, percent, FlexDirection, Size, Style};
+use llimphi_layout::taffy::{AlignItems, JustifyContent, Position, Rect};
+use llimphi_layout::LayoutTree;
+use llimphi_raster::peniko::{Color, Gradient};
+use llimphi_raster::{vello, Renderer};
+use llimphi_text::{Alignment, TextSpan, TextSpanStyle, Typesetter};
+use vello::kurbo::Point;
+
+const W: u32 = 1280;
+const H: u32 = 800;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+fn rgb(r: u8, g: u8, b: u8) -> Color {
+ Color::from_rgba8(r, g, b, 255)
+}
+
+fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color {
+ Color::from_rgba8(r, g, b, a)
+}
+
+/// Caja vacía con tamaño fijo — separadores, swatches, barras.
+fn rect(w: f32, h: f32) -> View<()> {
+ View::<()>::new(Style { size: Size { width: length(w), height: length(h) }, ..Default::default() })
+}
+
+/// Nodo de texto de una línea con alto fijo (mismo patrón que los demos vecinos).
+fn txt(w: taffy::Dimension, h: f32, s: &str, size: f32, c: Color) -> View<()> {
+ View::<()>::new(Style { size: Size { width: w, height: length(h) }, ..Default::default() })
+ .text_aligned(s.to_string(), size, c, Alignment::Start)
+}
+
+/// Spans sintácticos: pinta TODAS las ocurrencias de cada `needle` con su estilo.
+fn spans_all(text: &str, reglas: &[(&str, TextSpanStyle)]) -> Vec {
+ let mut out = Vec::new();
+ for (needle, style) in reglas {
+ for (i, _) in text.match_indices(needle) {
+ out.push(TextSpan::new(i, i + needle.len(), style.clone()));
+ }
+ }
+ out
+}
+
+fn color_span(c: Color) -> TextSpanStyle {
+ TextSpanStyle { color: Some(c), ..Default::default() }
+}
+
+fn main() {
+ let out = std::env::args().nth(1).unwrap_or_else(|| "pantallazo_motor.png".to_string());
+ // Default dark (el look histórico de esta tarjeta); `LLIMPHI_THEME=`
+ // fuerza otro preset por nombre canónico (p. ej. `Tawa`) para evidenciar la
+ // paleta firma sin alterar el default del pipeline público.
+ let theme = std::env::var("LLIMPHI_THEME")
+ .ok()
+ .and_then(|n| llimphi_theme::Theme::by_name(&n))
+ .unwrap_or_else(llimphi_theme::Theme::dark);
+
+ // Paleta de sintaxis (sobre el panel oscuro del theme).
+ let kw = rgb(198, 120, 221); // keywords — violeta
+ let ty = rgb(229, 192, 123); // tipos — ámbar
+ let fnc = rgb(97, 175, 239); // funciones — azul
+ let strv = rgb(152, 195, 121); // strings — verde
+ let cmt = rgb(92, 104, 124); // comentarios — gris azulado
+ let lit = rgb(209, 154, 102); // literales numéricos — naranja
+ let code_fg = rgb(171, 178, 191);
+
+ // ───────────────────────────── top bar ─────────────────────────────
+ let tab = |name: &str, activo: bool| {
+ let base = View::<()>::new(Style {
+ size: Size { width: auto(), height: length(30.0) },
+ align_items: Some(AlignItems::Center),
+ padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
+ ..Default::default()
+ })
+ .radius(8.0);
+ let fg = if activo { theme.fg_text } else { theme.fg_muted };
+ let v = if activo { base.fill(theme.bg_selected) } else { base };
+ v.children(vec![txt(auto(), 18.0, name, 13.0, fg)])
+ };
+ let brand_dot = rect(10.0, 10.0)
+ .radius(5.0)
+ .fill_gradient(
+ Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0))
+ .with_stops([theme.accent, rgb(80, 200, 200)].as_slice()),
+ );
+ let buscador = View::<()>::new(Style {
+ size: Size { width: length(230.0), height: length(28.0) },
+ align_items: Some(AlignItems::Center),
+ padding: Rect { left: length(12.0), right: length(12.0), top: length(0.0), bottom: length(0.0) },
+ ..Default::default()
+ })
+ .fill(theme.bg_input)
+ .radius(14.0)
+ .border(1.0, theme.border)
+ .children(vec![txt(auto(), 16.0, "buscar en el haz… ⌘K", 12.0, theme.fg_placeholder)]);
+ let topbar = View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(46.0) },
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ gap: Size { width: length(10.0), height: length(0.0) },
+ padding: Rect { left: length(16.0), right: length(16.0), top: length(0.0), bottom: length(0.0) },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel_alt)
+ .border(1.0, theme.border)
+ .children(vec![
+ brand_dot,
+ txt(auto(), 18.0, "llimphi", 15.0, theme.fg_text).bold(),
+ rect(14.0, 1.0),
+ tab("pluma", true),
+ tab("khipu", false),
+ tab("cosmos", false),
+ tab("shuma", false),
+ // empuja el buscador a la derecha
+ View::<()>::new(Style { flex_grow: 1.0, ..Default::default() }),
+ buscador,
+ ]);
+
+ // ───────────────────────────── sidebar ─────────────────────────────
+ let fila = |nombre: &str, badge: Option<&str>, sel: bool, dot: Color| {
+ let base = View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(30.0) },
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::SpaceBetween),
+ padding: Rect { left: length(10.0), right: length(10.0), top: length(0.0), bottom: length(0.0) },
+ ..Default::default()
+ })
+ .radius(7.0);
+ let v = if sel { base.fill(theme.bg_selected) } else { base };
+ let izq = View::<()>::new(Style {
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ gap: Size { width: length(8.0), height: length(0.0) },
+ ..Default::default()
+ })
+ .children(vec![
+ rect(8.0, 8.0).radius(2.5).fill(dot),
+ txt(length(135.0), 17.0, nombre, 13.0, if sel { theme.fg_text } else { theme.fg_muted }),
+ ]);
+ let mut hijos = vec![izq];
+ if let Some(b) = badge {
+ hijos.push(
+ View::<()>::new(Style {
+ size: Size { width: auto(), height: length(17.0) },
+ align_items: Some(AlignItems::Center),
+ padding: Rect { left: length(7.0), right: length(7.0), top: length(0.0), bottom: length(0.0) },
+ ..Default::default()
+ })
+ .fill(theme.bg_button)
+ .radius(8.5)
+ .children(vec![txt(auto(), 13.0, b, 10.5, theme.fg_muted)]),
+ );
+ }
+ v.children(hijos)
+ };
+ let seccion = |t: &str| txt(percent(1.0), 16.0, t, 11.0, theme.fg_placeholder).bold();
+ let sidebar = View::<()>::new(Style {
+ size: Size { width: length(236.0), height: percent(1.0) },
+ flex_shrink: 0.0,
+ flex_direction: FlexDirection::Column,
+ gap: Size { width: length(0.0), height: length(4.0) },
+ padding: Rect { left: length(12.0), right: length(12.0), top: length(14.0), bottom: length(14.0) },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel)
+ .border(1.0, theme.border)
+ .children(vec![
+ seccion("HAZ DE CUERPOS"),
+ fila("ensayo · español", None, true, theme.accent),
+ fila("ensayo · english", Some("stale"), false, rgb(209, 154, 102)),
+ fila("ensayo · runasimi", None, false, rgb(80, 200, 200)),
+ fila("resumen ejecutivo", None, false, rgb(152, 195, 121)),
+ rect(1.0, 10.0),
+ seccion("MÓDULOS"),
+ fila("nodegraph.rs", Some("12"), false, fnc),
+ fila("text_editor.rs", Some("3"), false, fnc),
+ fila("typesetter.rs", None, false, fnc),
+ fila("raster/scene.rs", None, false, fnc),
+ rect(1.0, 10.0),
+ seccion("DAEMONS"),
+ fila("verbo · e5-small", Some("384d"), false, rgb(152, 195, 121)),
+ fila("chasqui · DHT", Some("9"), false, rgb(152, 195, 121)),
+ ]);
+
+ // ─────────────────────── editor de código (centro) ───────────────────────
+ // (líneas unidas a mano — la continuación `\` de Rust se comería la indentación)
+ let codigo = [
+ "// bucle Elm del motor: input → update → view → layout → raster",
+ "pub fn frame(&mut self, msg: Msg) -> Scene {",
+ " self.app.update(msg);",
+ " let view = self.app.view();",
+ " let tree = mount(&mut self.layout, view);",
+ " let computed = self.layout.compute(tree.root, self.size);",
+ " let mut scene = Scene::new();",
+ " paint(&mut scene, &tree, &computed, &mut self.ts);",
+ " scene // vello la rasteriza en GPU vía wgpu",
+ "}",
+ "",
+ "let sombra = Shadow::soft(90, 24.0).offset(0.0, 12.0);",
+ "let card = View::new(estilo)",
+ " .fill_gradient(grad) // gradiente en [0,1]²",
+ " .radius_corners(18.0, 18.0, 4.0, 4.0)",
+ " .shadow(sombra);",
+ ]
+ .join("\n");
+ let codigo = codigo.as_str();
+ let reglas = [
+ ("// bucle Elm del motor: input → update → view → layout → raster", TextSpanStyle { color: Some(cmt), italic: Some(true), ..Default::default() }),
+ ("// vello la rasteriza en GPU vía wgpu", TextSpanStyle { color: Some(cmt), italic: Some(true), ..Default::default() }),
+ ("// gradiente en [0,1]²", TextSpanStyle { color: Some(cmt), italic: Some(true), ..Default::default() }),
+ ("pub fn ", color_span(kw)),
+ ("let ", color_span(kw)),
+ ("&mut ", color_span(kw)),
+ ("self", color_span(rgb(224, 108, 117))),
+ ("Msg", color_span(ty)),
+ ("Scene", color_span(ty)),
+ ("Shadow", color_span(ty)),
+ ("View", color_span(ty)),
+ ("frame", TextSpanStyle { color: Some(fnc), weight: Some(700.0), ..Default::default() }),
+ ("update", color_span(fnc)),
+ ("view()", color_span(fnc)),
+ ("mount", color_span(fnc)),
+ ("compute", color_span(fnc)),
+ ("new", color_span(fnc)),
+ ("paint", color_span(fnc)),
+ ("soft", color_span(fnc)),
+ ("offset", color_span(fnc)),
+ ("fill_gradient", color_span(fnc)),
+ ("radius_corners", color_span(fnc)),
+ ("shadow(", color_span(fnc)),
+ ("90, 24.0", color_span(lit)),
+ ("0.0, 12.0", color_span(lit)),
+ ("18.0, 18.0, 4.0, 4.0", color_span(lit)),
+ ];
+ let code_spans = spans_all(codigo, ®las);
+ let code_text = View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(360.0) },
+ ..Default::default()
+ })
+ .text_spans(codigo, 13.5, code_fg, code_spans, Alignment::Start)
+ .mono()
+ .line_height(1.55);
+ // header del editor: tres puntos + nombre de archivo + chip de lenguaje
+ let punto = |c: Color| rect(11.0, 11.0).radius(5.5).fill(c);
+ let editor_header = View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(36.0) },
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ gap: Size { width: length(8.0), height: length(0.0) },
+ padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel_alt)
+ .radius_corners(12.0, 12.0, 0.0, 0.0)
+ .children(vec![
+ punto(rgb(224, 108, 117)),
+ punto(rgb(229, 192, 123)),
+ punto(rgb(152, 195, 121)),
+ rect(6.0, 1.0),
+ txt(auto(), 17.0, "eventloop.rs", 13.0, theme.fg_text).mono(),
+ txt(length(96.0), 16.0, "— llimphi-ui", 12.0, theme.fg_placeholder),
+ View::<()>::new(Style { flex_grow: 1.0, ..Default::default() }),
+ txt(auto(), 16.0, "rust", 11.0, theme.accent).mono(),
+ ]);
+ let editor = View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: auto() },
+ flex_grow: 1.0,
+ flex_direction: FlexDirection::Column,
+ ..Default::default()
+ })
+ .fill(theme.bg_panel)
+ .radius(12.0)
+ .border(1.0, theme.border)
+ .shadow(Shadow::soft(110, 26.0).offset(0.0, 12.0))
+ .children(vec![
+ editor_header,
+ View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: auto() },
+ flex_grow: 1.0,
+ padding: Rect { left: length(16.0), right: length(16.0), top: length(12.0), bottom: length(8.0) },
+ ..Default::default()
+ })
+ .children(vec![code_text]),
+ ]);
+
+ // ─────────────────── párrafo rico (debajo del editor) ───────────────────
+ let parrafo = "Un solo nodo de texto, varios lentes por rango de bytes: \
+NEGRITA para el énfasis, cursiva para la voz, un enlace.qu subrayado, \
+texto tachado para lo descartado, y Typesetter en mono inline — todo \
+medido y pintado por el mismo layout_spans, sin HTML ni DOM.";
+ let find = |n: &str| {
+ let i = parrafo.find(n).expect("needle");
+ (i, i + n.len())
+ };
+ let (b0, b1) = find("NEGRITA");
+ let (i0, i1) = find("cursiva");
+ let (l0, l1) = find("enlace.qu");
+ let (t0, t1) = find("tachado");
+ let (m0, m1) = find("Typesetter");
+ let rich_spans = vec![
+ TextSpan::new(b0, b1, TextSpanStyle { weight: Some(700.0), color: Some(theme.fg_text), ..Default::default() }),
+ TextSpan::new(i0, i1, TextSpanStyle { italic: Some(true), color: Some(theme.fg_text), ..Default::default() }),
+ TextSpan::new(l0, l1, TextSpanStyle { color: Some(theme.accent), underline: Some(true), ..Default::default() }),
+ TextSpan::new(t0, t1, TextSpanStyle { color: Some(theme.fg_destructive), strikethrough: Some(true), ..Default::default() }),
+ TextSpan::new(m0, m1, TextSpanStyle { font_family: Some(llimphi_text::MONOSPACE.to_string()), color: Some(rgb(80, 200, 200)), ..Default::default() }),
+ ];
+ let rich_card = View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(150.0) },
+ flex_direction: FlexDirection::Column,
+ gap: Size { width: length(0.0), height: length(8.0) },
+ padding: Rect { left: length(18.0), right: length(18.0), top: length(14.0), bottom: length(14.0) },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel)
+ .radius(12.0)
+ .border(1.0, theme.border)
+ .children(vec![
+ txt(percent(1.0), 20.0, "Texto rico — spans nativos", 14.5, theme.fg_text).bold(),
+ View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(78.0) },
+ ..Default::default()
+ })
+ .text_spans(parrafo, 13.5, theme.fg_muted, rich_spans, Alignment::Start)
+ .line_height(1.45),
+ ]);
+
+ let centro = View::<()>::new(Style {
+ size: Size { width: auto(), height: percent(1.0) },
+ flex_grow: 1.0,
+ flex_direction: FlexDirection::Column,
+ gap: Size { width: length(0.0), height: length(14.0) },
+ padding: Rect { left: length(14.0), right: length(14.0), top: length(14.0), bottom: length(14.0) },
+ ..Default::default()
+ })
+ .children(vec![editor, rich_card]);
+
+ // ───────────────────────── columna derecha ─────────────────────────
+ // 1) Tarjeta de métricas con gradiente + sombra (el look "hero card").
+ let grad_hero = Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0))
+ .with_stops([rgb(64, 92, 180), rgb(34, 46, 96)].as_slice());
+ let metrica = |valor: &str, label: &str| {
+ View::<()>::new(Style {
+ size: Size { width: length(80.0), height: auto() },
+ flex_shrink: 0.0,
+ flex_direction: FlexDirection::Column,
+ gap: Size { width: length(0.0), height: length(2.0) },
+ ..Default::default()
+ })
+ .children(vec![
+ txt(length(80.0), 26.0, valor, 21.0, rgb(240, 244, 252)).bold(),
+ txt(length(80.0), 15.0, label, 11.0, rgba(214, 222, 240, 190)),
+ ])
+ };
+ let hero = View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(120.0) },
+ flex_direction: FlexDirection::Column,
+ justify_content: Some(JustifyContent::Center),
+ gap: Size { width: length(0.0), height: length(10.0) },
+ padding: Rect { left: length(18.0), right: length(18.0), top: length(12.0), bottom: length(12.0) },
+ ..Default::default()
+ })
+ .fill_gradient(grad_hero)
+ .radius(14.0)
+ .border(1.0, rgba(150, 175, 240, 120))
+ .shadow(Shadow::soft(120, 28.0).offset(0.0, 14.0))
+ .children(vec![
+ txt(percent(1.0), 16.0, "RENDER · ÚLTIMO FRAME", 11.0, rgba(214, 222, 240, 200)).bold(),
+ View::<()>::new(Style {
+ flex_direction: FlexDirection::Row,
+ gap: Size { width: length(16.0), height: length(0.0) },
+ ..Default::default()
+ })
+ .children(vec![metrica("1.8 ms", "scene → GPU"), metrica("2 411", "nodos"), metrica("60 fps", "vsync")]),
+ ]);
+
+ // 2) Mini gráfico de barras: puros rects con gradiente, alineados al piso.
+ let alturas = [34.0_f32, 52.0, 41.0, 66.0, 58.0, 78.0, 49.0, 88.0, 71.0, 60.0, 94.0, 80.0];
+ let barras: Vec> = alturas
+ .iter()
+ .enumerate()
+ .map(|(i, &h)| {
+ let g = if i == 10 {
+ Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
+ .with_stops([rgb(120, 220, 200), rgb(60, 150, 140)].as_slice())
+ } else {
+ Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
+ .with_stops([rgb(110, 140, 220), rgb(58, 78, 128)].as_slice())
+ };
+ rect(15.0, h).radius_corners(4.0, 4.0, 0.0, 0.0).fill_gradient(g)
+ })
+ .collect();
+ let chart = View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(168.0) },
+ flex_direction: FlexDirection::Column,
+ gap: Size { width: length(0.0), height: length(10.0) },
+ padding: Rect { left: length(18.0), right: length(18.0), top: length(14.0), bottom: length(14.0) },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel)
+ .radius(12.0)
+ .border(1.0, theme.border)
+ .children(vec![
+ View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(18.0) },
+ flex_direction: FlexDirection::Row,
+ justify_content: Some(JustifyContent::SpaceBetween),
+ ..Default::default()
+ })
+ .children(vec![
+ txt(length(200.0), 18.0, "Throughput del raster", 13.0, theme.fg_text).bold(),
+ View::<()>::new(Style {
+ size: Size { width: length(70.0), height: length(16.0) },
+ ..Default::default()
+ })
+ .text_aligned("12 frames".to_string(), 11.0, theme.fg_placeholder, Alignment::End),
+ ]),
+ View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(94.0) },
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::FlexEnd),
+ justify_content: Some(JustifyContent::SpaceBetween),
+ ..Default::default()
+ })
+ .children(barras),
+ ]);
+
+ // 3) Botones / chips — el acento del theme en acción.
+ let boton = |label: &str, primario: bool| {
+ let base = View::<()>::new(Style {
+ size: Size { width: auto(), height: length(34.0) },
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ padding: Rect { left: length(18.0), right: length(18.0), top: length(0.0), bottom: length(0.0) },
+ ..Default::default()
+ })
+ .radius(17.0);
+ if primario {
+ base.fill_gradient(
+ Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
+ .with_stops([rgb(124, 154, 232), rgb(92, 120, 198)].as_slice()),
+ )
+ .shadow(Shadow::soft(90, 16.0).offset(0.0, 6.0))
+ .children(vec![txt(auto(), 18.0, label, 13.0, rgb(244, 247, 255)).bold()])
+ } else {
+ base.fill(theme.bg_button)
+ .border(1.0, theme.border)
+ .children(vec![txt(auto(), 18.0, label, 13.0, theme.fg_text)])
+ }
+ };
+ let acciones = View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(34.0) },
+ flex_direction: FlexDirection::Row,
+ gap: Size { width: length(10.0), height: length(0.0) },
+ ..Default::default()
+ })
+ .children(vec![boton("Regenerar", true), boton("Difundir", false), boton("Alinear", false)]);
+
+ // 4) Tarjeta de hebras (estado del haz multilienzo) — filas con dot de estado.
+ let hebra = |de: &str, a: &str, estado: &str, c: Color| {
+ View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(26.0) },
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::SpaceBetween),
+ ..Default::default()
+ })
+ .children(vec![
+ View::<()>::new(Style {
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ gap: Size { width: length(8.0), height: length(0.0) },
+ ..Default::default()
+ })
+ .children(vec![
+ rect(7.0, 7.0).radius(3.5).fill(c),
+ txt(length(170.0), 16.0, &format!("{de} → {a}"), 12.0, theme.fg_text),
+ ]),
+ View::<()>::new(Style {
+ size: Size { width: length(80.0), height: length(15.0) },
+ ..Default::default()
+ })
+ .text_aligned(estado.to_string(), 11.0, c, Alignment::End),
+ ])
+ };
+ let hebras = View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(148.0) },
+ flex_direction: FlexDirection::Column,
+ gap: Size { width: length(0.0), height: length(6.0) },
+ padding: Rect { left: length(18.0), right: length(18.0), top: length(14.0), bottom: length(14.0) },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel)
+ .radius(12.0)
+ .border(1.0, theme.border)
+ .children(vec![
+ txt(percent(1.0), 18.0, "Hebras del haz", 13.0, theme.fg_text).bold(),
+ hebra("español", "english", "stale", rgb(229, 192, 123)),
+ hebra("español", "runasimi", "al día", rgb(152, 195, 121)),
+ hebra("español", "resumen", "al día", rgb(152, 195, 121)),
+ hebra("english", "tono formal", "derivando…", theme.accent),
+ ]);
+
+ let derecha = View::<()>::new(Style {
+ size: Size { width: length(330.0), height: percent(1.0) },
+ flex_shrink: 0.0,
+ flex_direction: FlexDirection::Column,
+ gap: Size { width: length(0.0), height: length(14.0) },
+ padding: Rect { left: length(0.0), right: length(14.0), top: length(14.0), bottom: length(14.0) },
+ ..Default::default()
+ })
+ .children(vec![hero, chart, acciones, hebras]);
+
+ // ───────────────────────── status bar ─────────────────────────
+ let status_item = |w: f32, s: &str, c: Color| txt(length(w), 16.0, s, 11.5, c);
+ let statusbar = View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: length(30.0) },
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ gap: Size { width: length(18.0), height: length(0.0) },
+ padding: Rect { left: length(16.0), right: length(16.0), top: length(0.0), bottom: length(0.0) },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel_alt)
+ .border(1.0, theme.border)
+ .children(vec![
+ status_item(70.0, "git · main", theme.accent),
+ status_item(250.0, "wgpu 27 · vello 0.7 · taffy · parley", theme.fg_muted),
+ status_item(80.0, "BLAKE3 ok", rgb(152, 195, 121)),
+ View::<()>::new(Style { flex_grow: 1.0, ..Default::default() }),
+ status_item(45.0, "UTF-8", theme.fg_placeholder),
+ status_item(85.0, "Ln 7, Col 23", theme.fg_placeholder),
+ status_item(50.0, "100 Hz", theme.fg_placeholder),
+ ]);
+
+ // ─────────────── toast flotante (absoluto, esquinas asimétricas) ───────────────
+ let toast = View::<()>::new(Style {
+ position: Position::Absolute,
+ inset: Rect { left: auto(), top: auto(), right: length(360.0), bottom: length(48.0) },
+ size: Size { width: length(290.0), height: length(64.0) },
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ gap: Size { width: length(12.0), height: length(0.0) },
+ padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
+ ..Default::default()
+ })
+ .fill(rgb(28, 34, 48))
+ .radius_corners(18.0, 18.0, 18.0, 4.0)
+ .border(1.0, rgba(110, 140, 220, 160))
+ .shadow(Shadow::soft(150, 30.0).offset(0.0, 14.0))
+ .children(vec![
+ rect(34.0, 34.0)
+ .radius(10.0)
+ .fill_gradient(
+ Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0))
+ .with_stops([rgb(120, 220, 200), rgb(60, 140, 170)].as_slice()),
+ ),
+ View::<()>::new(Style {
+ flex_direction: FlexDirection::Column,
+ gap: Size { width: length(0.0), height: length(2.0) },
+ ..Default::default()
+ })
+ .children(vec![
+ txt(auto(), 17.0, "Cuerpo regenerado", 13.0, theme.fg_text).bold(),
+ txt(auto(), 15.0, "english · 42 átomos realineados", 11.5, theme.fg_muted),
+ ]),
+ ]);
+
+ // ───────────────────────── árbol raíz ─────────────────────────
+ let fila_central = View::<()>::new(Style {
+ size: Size { width: percent(1.0), height: auto() },
+ flex_grow: 1.0,
+ flex_direction: FlexDirection::Row,
+ ..Default::default()
+ })
+ .children(vec![sidebar, centro, derecha]);
+
+ let root = View::<()>::new(Style {
+ size: Size { width: length(W as f32), height: length(H as f32) },
+ flex_direction: FlexDirection::Column,
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![topbar, fila_central, statusbar, toast]);
+
+ // view → layout → scene → render headless → PNG (misma secuencia que el eventloop).
+ 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-motor"),
+ 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());
+ let [r, g, b, _] = theme.bg_app.components;
+ let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
+ renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render_to_view");
+
+ write_png(&hal, &target, &out);
+ eprintln!("pantallazo_motor: 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();
+}
diff --git a/llimphi-compositor/examples/primitivas_demo.rs b/llimphi-compositor/examples/primitivas_demo.rs
new file mode 100644
index 0000000..38770b5
--- /dev/null
+++ b/llimphi-compositor/examples/primitivas_demo.rs
@@ -0,0 +1,273 @@
+//! Volcado headless de las primitivas nuevas del compositor (Tier 1+2 del
+//! roadmap PARIDAD-FLUTTER): **sombra · gradiente · borde · peso de fuente ·
+//! radio por esquina**. Monta un árbol `View` con tarjetas que ejercitan cada
+//! una (y su combinación), lo pinta a una `vello::Scene` y lee la textura a
+//! PNG. Sirve para VERLAS sin ventana.
+//!
+//! `cargo run -p llimphi-compositor --example primitivas_demo -- [out.png]`
+
+use std::fs::File;
+use std::io::BufWriter;
+
+use llimphi_compositor::{measure_text_node, mount, paint, Shadow, 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, Gradient};
+use llimphi_raster::{vello, Renderer};
+use llimphi_text::{Alignment, Typesetter};
+use vello::kurbo::Point;
+
+const W: u32 = 1476;
+const H: u32 = 340;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+/// Una tarjeta con título + descripción, dimensionada igual para todas.
+fn card(build: impl FnOnce(View<()>) -> View<()>, title: &str, fg: Color) -> View<()> {
+ let base = View::<()>::new(Style {
+ size: Size { width: length(180.0_f32), height: length(150.0_f32) },
+ flex_direction: FlexDirection::Column,
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ gap: Size { width: length(0.0_f32), height: length(8.0_f32) },
+ ..Default::default()
+ })
+ .radius(16.0);
+ build(base).children(vec![View::<()>::new(Style {
+ size: Size { width: percent(0.9_f32), height: length(22.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(title.to_string(), 16.0, fg, Alignment::Center)])
+}
+
+fn main() {
+ let out = std::env::args().nth(1).unwrap_or_else(|| "primitivas.png".to_string());
+ let theme = llimphi_theme::Theme::light();
+
+ let panel = theme.bg_panel;
+ let dark = Color::from_rgba8(30, 34, 44, 255);
+ let white = Color::from_rgba8(248, 248, 250, 255);
+
+ // 1) Sombra: fill plano + elevación suave.
+ let sombra = card(
+ |v| v.fill(panel).shadow(Shadow::soft(70, 22.0).offset(0.0, 10.0)),
+ "Sombra",
+ dark,
+ );
+
+ // 2) Gradiente: relleno vertical claro→oscuro (espacio unidad [0,1]²).
+ let grad = Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0)).with_stops(
+ [Color::from_rgba8(96, 130, 220, 255), Color::from_rgba8(40, 60, 140, 255)].as_slice(),
+ );
+ let gradiente = card(|v| v.fill_gradient(grad.clone()), "Gradiente", white);
+
+ // 3) Borde: hairline sobre fill plano (reemplaza el truco del rect-padre).
+ let borde = card(
+ |v| v.fill(panel).border(1.5, theme.accent),
+ "Borde",
+ dark,
+ );
+
+ // 4) Combo: gradiente + borde + sombra — el look de un botón/card moderno.
+ let combo_grad = Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0)).with_stops(
+ [Color::from_rgba8(80, 200, 140, 255), Color::from_rgba8(30, 140, 110, 255)].as_slice(),
+ );
+ let combo = card(
+ |v| {
+ v.fill_gradient(combo_grad)
+ .border(1.5, Color::from_rgba8(180, 240, 210, 255))
+ .shadow(Shadow::soft(90, 24.0).offset(0.0, 12.0))
+ },
+ "Combo",
+ white,
+ );
+
+ // 5) Peso de fuente: la misma palabra en 400 (normal) y 700 (bold), para
+ // contrastar el grosor del trazo en una sola tarjeta.
+ let line = |txt: &str, bold: bool| {
+ let v = View::<()>::new(Style {
+ size: Size { width: percent(0.9_f32), height: length(30.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(txt.to_string(), 24.0, dark, Alignment::Center);
+ if bold { v.bold() } else { v }
+ };
+ let peso = View::<()>::new(Style {
+ size: Size { width: length(180.0_f32), height: length(150.0_f32) },
+ flex_direction: FlexDirection::Column,
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ gap: Size { width: length(0.0_f32), height: length(4.0_f32) },
+ ..Default::default()
+ })
+ .radius(16.0)
+ .fill(panel)
+ .border(1.0, theme.accent)
+ .children(vec![
+ line("Regular 400", false),
+ line("Bold 700", true),
+ View::<()>::new(Style {
+ size: Size { width: percent(0.9_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned("Peso", 14.0, dark, Alignment::Center),
+ ]);
+
+ // 6) Radio por esquina: esquinas asimétricas (arriba muy redondeadas,
+ // abajo casi rectas) — el look de una pestaña / bocadillo de chat. El
+ // borde sigue las cuatro esquinas.
+ let esquinas = card(
+ |v| {
+ v.fill_gradient(grad.clone())
+ .border(1.5, white)
+ .radius_corners(34.0, 34.0, 4.0, 4.0)
+ },
+ "Esquinas",
+ white,
+ );
+
+ // 7) Overflow / ellipsis: texto largo clampado a 1 y a 2 líneas, terminando
+ // en `…`. El ancho de la caja fuerza la envoltura; el clamp recorta.
+ let long = "Texto largo que no entra en el ancho de esta tarjeta angosta y debe recortarse";
+ let clamp = |txt: &str, n: usize, h: f32| {
+ View::<()>::new(Style {
+ size: Size { width: percent(0.86_f32), height: length(h) },
+ ..Default::default()
+ })
+ .text_aligned(txt.to_string(), 13.0, dark, Alignment::Start)
+ .ellipsis(n)
+ };
+ let elipsis = View::<()>::new(Style {
+ size: Size { width: length(180.0_f32), height: length(150.0_f32) },
+ flex_direction: FlexDirection::Column,
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
+ ..Default::default()
+ })
+ .radius(16.0)
+ .fill(panel)
+ .border(1.0, theme.accent)
+ .children(vec![
+ clamp(long, 1, 18.0),
+ clamp(long, 2, 36.0),
+ View::<()>::new(Style {
+ size: Size { width: percent(0.9_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned("Ellipsis 1·2", 14.0, dark, Alignment::Center),
+ ]);
+
+ let root = View::<()>::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ gap: Size { width: length(28.0_f32), height: length(0.0_f32) },
+ 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(theme.bg_app)
+ .children(vec![sombra, gradiente, borde, combo, peso, esquinas, elipsis]);
+
+ // view → layout → scene (misma secuencia que el eventloop).
+ 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("dump-primitivas"),
+ 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());
+ let [r, g, b, _] = theme.bg_app.components;
+ let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
+ renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render_to_view");
+
+ write_png(&hal, &target, &out);
+ eprintln!("primitivas_demo: 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);
+ });
+ 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();
+}
diff --git a/llimphi-compositor/examples/rich_text_demo.rs b/llimphi-compositor/examples/rich_text_demo.rs
new file mode 100644
index 0000000..a9b0251
--- /dev/null
+++ b/llimphi-compositor/examples/rich_text_demo.rs
@@ -0,0 +1,262 @@
+//! Volcado headless de **RichText spans** (Bloque 13 de PARIDAD-FLUTTER:
+//! cierra Tier 2 final): un mismo nodo de texto con defaults a nivel
+//! bloque (tamaño 16 px, color gris oscuro, weight 400, sin italic) más
+//! un arreglo de `TextSpan` que sobreescriben por rango de bytes
+//! `weight=700` (bold), `italic=true`, `color`, `underline=true`,
+//! `size_px=22` (heading inline), `font_family=mono` (``-like) y
+//! `strikethrough=true`. Verifica que la **medida** y el **pintado**
+//! consumen el mismo `layout_spans` (taffy reserva el alto del span más
+//! alto en su línea).
+//!
+//! `cargo run -p llimphi-compositor --example rich_text_demo -- [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, TextSpan, TextSpanStyle, Typesetter, MONOSPACE};
+
+const W: u32 = 980;
+const H: u32 = 380;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+fn rgb(r: u8, g: u8, b: u8) -> Color {
+ Color::from_rgba8(r, g, b, 255)
+}
+
+/// Helper para localizar un substring exacto en `text` y devolver el
+/// `[start, end)` en bytes — así los spans del demo son legibles ("apply
+/// bold to 'NEGRITA'") sin offsets a mano.
+fn range_of(text: &str, needle: &str) -> (usize, usize) {
+ let start = text.find(needle).unwrap_or_else(|| panic!("'{needle}' not found"));
+ (start, start + needle.len())
+}
+
+fn main() {
+ let out = std::env::args().nth(1).unwrap_or_else(|| "rich_text.png".to_string());
+ let theme = llimphi_theme::Theme::light();
+
+ let panel = theme.bg_panel;
+ let dark = rgb(30, 34, 44);
+ let accent = rgb(52, 152, 219);
+ let danger = rgb(231, 76, 60);
+ let muted = rgb(128, 132, 144);
+
+ // Párrafo con seis tipos de override + texto plano alrededor para que
+ // se note el contraste de cada lente.
+ let parrafo = "Esto es un párrafo que mezcla NEGRITA, cursiva, un \
+ link.com, un cambio de TAMAÑO inline, una palabra \
+ tachada y código de muestra inline.";
+ let (b0, b1) = range_of(parrafo, "NEGRITA");
+ let (i0, i1) = range_of(parrafo, "cursiva");
+ let (l0, l1) = range_of(parrafo, "link.com");
+ let (sz0, sz1) = range_of(parrafo, "TAMAÑO");
+ let (st0, st1) = range_of(parrafo, "tachada");
+ let (m0, m1) = range_of(parrafo, "código de muestra");
+ let spans = vec![
+ TextSpan::new(b0, b1, TextSpanStyle { weight: Some(700.0), ..Default::default() }),
+ TextSpan::new(i0, i1, TextSpanStyle { italic: Some(true), ..Default::default() }),
+ TextSpan::new(
+ l0,
+ l1,
+ TextSpanStyle {
+ color: Some(accent),
+ underline: Some(true),
+ ..Default::default()
+ },
+ ),
+ TextSpan::new(
+ sz0,
+ sz1,
+ TextSpanStyle {
+ size_px: Some(24.0),
+ weight: Some(700.0),
+ color: Some(rgb(46, 204, 113)),
+ ..Default::default()
+ },
+ ),
+ TextSpan::new(
+ st0,
+ st1,
+ TextSpanStyle {
+ color: Some(danger),
+ strikethrough: Some(true),
+ ..Default::default()
+ },
+ ),
+ TextSpan::new(
+ m0,
+ m1,
+ TextSpanStyle {
+ font_family: Some(MONOSPACE.to_string()),
+ color: Some(rgb(155, 89, 182)),
+ ..Default::default()
+ },
+ ),
+ ];
+
+ let texto_rico = View::<()>::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(160.0_f32) },
+ ..Default::default()
+ })
+ .text_spans(parrafo, 16.0, dark, spans, Alignment::Start);
+
+ // Subtítulo + descripción + el párrafo rico, todo dentro de una card
+ // (apilada con flex_direction Column).
+ let titulo = View::<()>::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "RichText spans (Bloque 13 — cierra Tier 2)",
+ 18.0,
+ dark,
+ Alignment::Start,
+ )
+ .bold();
+ let descripcion = View::<()>::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
+ ..Default::default()
+ })
+ .text_aligned(
+ "Un solo nodo, seis tipos de override aplicados por rango de bytes:",
+ 13.0,
+ muted,
+ Alignment::Start,
+ );
+
+ let card = View::<()>::new(Style {
+ size: Size { width: length(W as f32 - 80.0), height: length(H as f32 - 60.0) },
+ flex_direction: FlexDirection::Column,
+ align_items: Some(AlignItems::FlexStart),
+ justify_content: Some(JustifyContent::FlexStart),
+ gap: Size { width: length(0.0_f32), height: length(12.0_f32) },
+ padding: Rect {
+ left: length(24.0_f32),
+ right: length(24.0_f32),
+ top: length(20.0_f32),
+ bottom: length(20.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(panel)
+ .radius(16.0)
+ .border(1.0, rgb(220, 224, 232))
+ .children(vec![titulo, descripcion, texto_rico]);
+
+ let root = View::<()>::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .children(vec![card]);
+
+ 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("dump-rich-text"),
+ 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());
+ let [r, g, b, _] = theme.bg_app.components;
+ let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
+ renderer
+ .render_to_view(&hal, &scene, &view, W, H, bg)
+ .expect("render_to_view");
+
+ write_png(&hal, &target, &out);
+ eprintln!(
+ "rich_text_demo: escrito {out} ({W}x{H}) — un nodo de texto con \
+ seis spans aplicados por rango de bytes: bold, italic, link \
+ (color + underline), heading inline (size 24 + bold + verde), \
+ strikethrough rojo, y un fragmento en mono morado."
+ );
+}
+
+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);
+ });
+ 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();
+}
diff --git a/llimphi-compositor/examples/ripple_demo.rs b/llimphi-compositor/examples/ripple_demo.rs
new file mode 100644
index 0000000..9c7cc52
--- /dev/null
+++ b/llimphi-compositor/examples/ripple_demo.rs
@@ -0,0 +1,182 @@
+//! Filmstrip headless del **ripple/InkWell** (Bloque 8 de PARIDAD-FLUTTER):
+//! una fila de botones, cada uno con una salpicadura Material disparada en el
+//! mismo punto (arriba-izquierda) pero **observada a un progreso creciente** —
+//! de la onda recién nacida (izquierda) a casi extinta (derecha). Muestra el
+//! círculo expandiéndose desde el tap, recortado al contorno redondeado del
+//! botón, y atenuándose con el fade.
+//!
+//! 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`.
+//!
+//! `cargo run -p llimphi-compositor --example ripple_demo -- [out.png]`
+
+use std::fs::File;
+use std::io::BufWriter;
+use std::time::{Duration, Instant};
+
+use llimphi_compositor::{mount, paint, RippleRegistry, View};
+use llimphi_hal::{wgpu, Hal};
+use llimphi_layout::taffy::prelude::{length, FlexDirection, Size, Style};
+use llimphi_layout::taffy::{AlignItems, JustifyContent, LengthPercentage, Rect};
+use llimphi_layout::LayoutTree;
+use llimphi_raster::peniko::Color;
+use llimphi_raster::{vello, Renderer};
+use llimphi_text::{Alignment, Typesetter};
+
+const W: u32 = 1180;
+const H: u32 = 240;
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+const FRAMES: usize = 6;
+const DUR: Duration = Duration::from_millis(500);
+/// Punto del tap relativo al rect de cada botón (arriba-izquierda) — la onda
+/// crece desde ahí hacia el rincón opuesto, bien visible en el filmstrip.
+const TAP: (f32, f32) = (38.0, 36.0);
+
+fn rgb(r: u8, g: u8, b: u8) -> Color {
+ Color::from_rgba8(r, g, b, 255)
+}
+
+fn main() {
+ let out = std::env::args().nth(1).unwrap_or_else(|| "ripple.png".to_string());
+ let fg = rgb(235, 238, 245);
+ let surface = rgb(44, 52, 70);
+ let ink = Color::from_rgba8(255, 255, 255, 90); // onda blanca semitransparente
+
+ // Una fila de FRAMES botones con ripple (key = columna). Layout real con
+ // gap/padding → cada botón tiene su propio rect computado (sin transform,
+ // que el paint del ripple no contempla en v1).
+ let botones: Vec> = (0..FRAMES)
+ .map(|i| {
+ let pct = (i as f32 / (FRAMES as f32 - 1.0) * 100.0).round() as i32;
+ View::<()>::new(Style {
+ size: Size { width: length(150.0), height: length(140.0) },
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ ..Default::default()
+ })
+ .fill(surface)
+ .radius(20.0)
+ .ripple(i as u64, ink)
+ .children(vec![View::<()>::new(Style {
+ size: Size { width: length(130.0), height: length(20.0) },
+ ..Default::default()
+ })
+ .text_aligned(format!("{pct}%"), 14.0, fg, Alignment::Center)])
+ })
+ .collect();
+ let root = View::<()>::new(Style {
+ size: Size { width: length(W as f32), height: length(H as f32) },
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ gap: Size { width: length(20.0), height: length(0.0) },
+ padding: Rect {
+ left: LengthPercentage::length(20.0),
+ right: LengthPercentage::length(20.0),
+ top: LengthPercentage::length(0.0),
+ bottom: LengthPercentage::length(0.0),
+ },
+ ..Default::default()
+ })
+ .children(botones);
+
+ let mut layout = LayoutTree::new();
+ 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
+ // 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();
+ let mut scene = vello::Scene::new();
+ paint(&mut scene, &mounted, &computed, &mut ts, None, None);
+
+ let t0 = Instant::now();
+ let step = DUR / (FRAMES as u32 - 1);
+ for i in 0..FRAMES {
+ let mut reg = RippleRegistry::new();
+ reg.trigger(i as u64, TAP.0, TAP.1, ink, DUR, t0);
+ let now = t0 + step * i as u32;
+ reg.paint(&mut scene, &mounted, &computed, now);
+ }
+
+ // Volcado a PNG.
+ 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("dump-ripple"),
+ 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());
+ let bg = rgb(244, 245, 248);
+ renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render_to_view");
+ write_png(&hal, &target, &out);
+ eprintln!(
+ "ripple_demo: escrito {out} ({W}x{H}) — {FRAMES} botones, la misma onda \
+ de {}ms observada a 0→100% (crece desde el tap y se desvanece)",
+ DUR.as_millis()
+ );
+}
+
+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);
+ });
+ 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();
+}
diff --git a/llimphi-compositor/examples/showreel.rs b/llimphi-compositor/examples/showreel.rs
new file mode 100644
index 0000000..ebfed89
--- /dev/null
+++ b/llimphi-compositor/examples/showreel.rs
@@ -0,0 +1,741 @@
+//! **Showreel** del motor Llimphi — para r/rust. NO es eye-candy abstracto:
+//! es una vitrina de **widgets reales** del toolkit, *en acción*. Cada frame
+//! reconstruye un árbol `View` con widgets de verdad (`llimphi-widget-switch`,
+//! `-slider`, `-progress`, `-button`, `-segmented`) cuyo **estado** se deriva
+//! del tiempo normalizado `t∈[0,1]` — el toggle se enciende, el slider sube,
+//! la barra avanza, el segmented cambia de pestaña. Se montan con el `mount` /
+//! `paint` / `compute_with_measure` reales (taffy + parley + vello), idéntico
+//! al eventloop. No se dibujan a mano: si existe el widget, se usa el widget.
+//!
+//! El render es **headless y determinista** (sin reloj, sin runtime, sin
+//! winit): frame `i` de `N` → `t = i/(N-1)` → View → layout → vello::Scene →
+//! wgpu → PNG. El cold-open (trazo bezier draw-on) y el wordmark de cierre
+//! son `paint_with` sobre un nodo full-screen, superpuestos sobre los widgets.
+//!
+//! ```text
+//! cargo run -p llimphi-compositor --example showreel --release -- \
+//! [out_dir] [n_frames] [W] [H]
+//! ```
+//! Defaults: `out_dir=showreel_frames`, `n_frames=360`, `W=1600`, `H=900`.
+
+use std::fs::{create_dir_all, File};
+use std::io::BufWriter;
+
+use llimphi_compositor::{
+ measure_text_node, mount, paint, DragPhase, PaintRect, Shadow, View,
+};
+use llimphi_hal::{wgpu, Hal};
+use llimphi_layout::taffy;
+use llimphi_layout::taffy::prelude::{
+ auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style,
+};
+use llimphi_layout::taffy::Rect;
+use llimphi_layout::LayoutTree;
+use llimphi_raster::peniko::{self, Color, Gradient};
+use llimphi_raster::{vello, Renderer};
+use llimphi_text::{draw_layout_brush_xf, measurement, Alignment, Typesetter};
+use llimphi_theme::motion;
+use vello::kurbo::{Affine, BezPath, Circle, Point, Stroke};
+
+use llimphi_widget_button::{button_view, ButtonPalette};
+use llimphi_widget_progress::{linear_progress_view, radial_progress_view};
+use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
+use llimphi_widget_slider::{slider_view, SliderPalette};
+use llimphi_widget_switch::{switch_view, SwitchPalette};
+
+const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
+
+// ───────────────────────── utilidades ─────────────────────────
+
+/// Color con alpha escalado a `a∈[0,1]` (para fade del overlay vector).
+fn with_alpha(c: Color, a: f32) -> Color {
+ let [r, g, b, _] = c.components;
+ Color::new([r, g, b, a.clamp(0.0, 1.0)])
+}
+
+fn lerp(a: f64, b: f64, t: f64) -> f64 {
+ a + (b - a) * t
+}
+
+/// Reescala `t` desde el subintervalo `[lo,hi]` de la timeline a `[0,1]`,
+/// clampado. Fuera del intervalo devuelve 0 (antes) o 1 (después).
+fn seg(t: f32, lo: f32, hi: f32) -> f32 {
+ ((t - lo) / (hi - lo)).clamp(0.0, 1.0)
+}
+
+// ───────────────────────── tema / paleta ─────────────────────────
+
+#[derive(Clone)]
+struct Skin {
+ theme: llimphi_theme::Theme,
+ accent: Color,
+ panel: Color,
+ panel_hi: Color,
+ border: Color,
+ border_accent: Color,
+ fg: Color,
+ fg_muted: Color,
+ bg: Color,
+}
+
+// ───────────────────────── geometría de las tarjetas ─────────────────────────
+
+#[derive(Clone, Copy)]
+struct CardRect {
+ x: f64,
+ y: f64,
+ w: f64,
+ h: f64,
+}
+
+impl CardRect {
+ fn lerp(self, b: CardRect, t: f64) -> CardRect {
+ CardRect {
+ x: lerp(self.x, b.x, t),
+ y: lerp(self.y, b.y, t),
+ w: lerp(self.w, b.w, t),
+ h: lerp(self.h, b.h, t),
+ }
+ }
+}
+
+const N_CARDS: usize = 6;
+
+/// Disposición A — grilla 3×2 centrada (beat de ensamblado).
+fn layout_grid(cw: f64, ch: f64) -> [CardRect; N_CARDS] {
+ let card_w = 360.0;
+ let card_h = 196.0;
+ let gap = 40.0;
+ let cols = 3.0;
+ let rows = 2.0;
+ let total_w = cols * card_w + (cols - 1.0) * gap;
+ let total_h = rows * card_h + (rows - 1.0) * gap;
+ let x0 = (cw - total_w) / 2.0;
+ let y0 = (ch - total_h) / 2.0;
+ let mut out = [CardRect { x: 0.0, y: 0.0, w: card_w, h: card_h }; N_CARDS];
+ for (i, c) in out.iter_mut().enumerate() {
+ let col = (i % 3) as f64;
+ let row = (i / 3) as f64;
+ c.x = x0 + col * (card_w + gap);
+ c.y = y0 + row * (card_h + gap);
+ }
+ out
+}
+
+/// Disposición B — fila única ancha, alturas escalonadas (beat de morph).
+/// Los MISMOS widgets adentro, otra geometría: "cualquier layout con taffy".
+fn layout_row(cw: f64, ch: f64) -> [CardRect; N_CARDS] {
+ let gap = 22.0;
+ let n = N_CARDS as f64;
+ let card_w = (cw - 2.0 * 90.0 - (n - 1.0) * gap) / n;
+ let x0 = 90.0;
+ let cy = ch / 2.0;
+ // alturas tipo "ecualizador" — silueta dinámica al reacomodar.
+ let hs = [240.0, 300.0, 210.0, 320.0, 260.0, 230.0];
+ let mut out = [CardRect { x: 0.0, y: 0.0, w: card_w, h: 220.0 }; N_CARDS];
+ for (i, c) in out.iter_mut().enumerate() {
+ c.x = x0 + i as f64 * (card_w + gap);
+ c.h = hs[i];
+ c.y = cy - c.h / 2.0;
+ c.w = card_w;
+ }
+ out
+}
+
+// ───────────────────────── contenido de cada card ─────────────────────────
+
+/// Header de card: chip de acento + título.
+fn card_header(title: &str, s: &Skin, accented: bool) -> View<()> {
+ let chip = View::new(Style {
+ size: Size { width: length(28.0), height: length(8.0) },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .radius(4.0)
+ .fill(if accented { s.accent } else { s.fg_muted });
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(20.0) },
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ gap: Size { width: length(10.0), height: length(0.0) },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .children(vec![
+ chip,
+ View::new(Style { flex_grow: 1.0, ..Default::default() })
+ .text_aligned(title.to_string(), 12.5, s.fg_muted, Alignment::Start)
+ .bold(),
+ ])
+}
+
+/// Línea de "valor" grande (estado legible) bajo el control.
+fn value_line(text: &str, color: Color, size: f32) -> View<()> {
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(size + 6.0) },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .text_aligned(text.to_string(), size, color, Alignment::Start)
+ .bold()
+}
+
+/// Cuerpo de una card según índice — cada una hospeda widgets REALES cuyo
+/// estado deriva de `p∈[0,1]` (progreso del beat de widgets).
+fn card_body(i: usize, p: f32, s: &Skin) -> Vec> {
+ match i {
+ // ── 0: Switch (off → on) ──────────────────────────────────────
+ 0 => {
+ // El thumb se desliza en una rampa centrada del beat.
+ let prog = motion::ease_in_out_cubic(seg(p, 0.15, 0.6));
+ let on = prog > 0.5;
+ let pal = SwitchPalette::from_theme(&s.theme);
+ let sw_row = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(26.0) },
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ gap: Size { width: length(14.0), height: length(0.0) },
+ ..Default::default()
+ })
+ .children(vec![
+ switch_view(prog, (), &pal),
+ View::new(Style { flex_grow: 1.0, ..Default::default() })
+ .text_aligned(
+ "Sincronizar".to_string(),
+ 13.0,
+ s.fg,
+ Alignment::Start,
+ ),
+ ]);
+ vec![
+ card_header("switch", s, true),
+ spacer(8.0),
+ sw_row,
+ spacer(10.0),
+ value_line(if on { "ENCENDIDO" } else { "apagado" }, if on { s.accent } else { s.fg_muted }, 22.0),
+ ]
+ }
+ // ── 1: Slider (20% → 75%) ─────────────────────────────────────
+ 1 => {
+ let v = lerp(0.2, 0.75, motion::ease_in_out_cubic(seg(p, 0.1, 0.7)) as f64) as f32;
+ let mut pal = SliderPalette::from_theme(&s.theme);
+ pal.track_width = 168.0;
+ pal.label_width = 0.0;
+ pal.value_width = 50.0;
+ pal.track_thickness = 8.0;
+ pal.row_height = 26.0;
+ let sld = slider_view::<(), _>(
+ "",
+ v,
+ 0.0,
+ 1.0,
+ &pal,
+ |_phase: DragPhase, _dv: f32| None,
+ );
+ vec![
+ card_header("slider", s, false),
+ spacer(10.0),
+ sld,
+ spacer(12.0),
+ value_line(&format!("{:>3.0}%", v * 100.0), s.fg, 26.0),
+ ]
+ }
+ // ── 2: Linear progress (avanza) ───────────────────────────────
+ 2 => {
+ let v = motion::ease_out_cubic(seg(p, 0.05, 0.85));
+ let bar = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(12.0) },
+ position: Position::Relative,
+ ..Default::default()
+ })
+ .fill(s.theme.bg_button)
+ .radius(6.0)
+ .children(vec![linear_progress_view(
+ v,
+ s.theme.bg_button,
+ s.accent,
+ 12.0,
+ )]);
+ vec![
+ card_header("progress", s, true),
+ spacer(14.0),
+ bar,
+ spacer(14.0),
+ value_line(&format!("{:>3.0}% · compilando", v * 100.0), s.fg_muted, 13.0),
+ ]
+ }
+ // ── 3: Segmented control (cambia de pestaña activa) ───────────
+ 3 => {
+ // 3 segmentos; el activo recorre 0 → 1 → 2 a lo largo del beat.
+ let phase = seg(p, 0.1, 0.95);
+ let active = ((phase * 3.0).floor() as usize).min(2);
+ let labels = ["Día", "Semana", "Mes"];
+ let pal = SegmentedPalette::from_theme(&s.theme);
+ let seg_ctrl = segmented_view::<(), _>(&labels, active, |_| (), &pal);
+ vec![
+ card_header("segmented", s, false),
+ spacer(14.0),
+ seg_ctrl,
+ spacer(14.0),
+ value_line(labels[active], s.accent, 22.0),
+ ]
+ }
+ // ── 4: Botones (primario teal + ghost) ────────────────────────
+ 4 => {
+ // Paleta primaria: fondo teal, texto sobre fondo.
+ let mut prim = ButtonPalette::from_theme(&s.theme);
+ prim.bg = s.accent;
+ prim.bg_hover = s.accent;
+ prim.fg = s.bg; // texto oscuro sobre teal
+ prim.radius = 8.0;
+ let mut ghost = ButtonPalette::from_theme(&s.theme);
+ ghost.bg = s.theme.bg_button;
+ ghost.fg = s.fg;
+ ghost.radius = 8.0;
+ let row = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(38.0) },
+ flex_direction: FlexDirection::Row,
+ gap: Size { width: length(12.0), height: length(0.0) },
+ ..Default::default()
+ })
+ .children(vec![
+ View::new(Style {
+ size: Size { width: length(132.0), height: length(38.0) },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .children(vec![button_view("Regenerar", &prim, ())]),
+ View::new(Style {
+ size: Size { width: length(110.0), height: length(38.0) },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .children(vec![button_view("Difundir", &ghost, ())]),
+ ]);
+ vec![
+ card_header("button", s, true),
+ spacer(14.0),
+ row,
+ spacer(14.0),
+ value_line("primario · ghost", s.fg_muted, 13.0),
+ ]
+ }
+ // ── 5: Radial progress (anillo que se llena) ──────────────────
+ _ => {
+ let v = motion::ease_out_cubic(seg(p, 0.1, 0.9));
+ let ring = View::new(Style {
+ size: Size { width: length(96.0), height: length(96.0) },
+ position: Position::Relative,
+ ..Default::default()
+ })
+ .children(vec![radial_progress_view(
+ v,
+ s.theme.bg_button,
+ s.accent,
+ 0.14,
+ )]);
+ let ring_row = View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(96.0) },
+ flex_direction: FlexDirection::Row,
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ ..Default::default()
+ })
+ .children(vec![ring]);
+ vec![
+ card_header("radial", s, false),
+ spacer(6.0),
+ ring_row,
+ ]
+ }
+ }
+}
+
+/// Espaciador vertical de alto fijo.
+fn spacer(h: f32) -> View<()> {
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: length(h) },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+}
+
+/// Una card como contenedor absoluto, hospedando widgets reales.
+fn card_view(i: usize, rect: CardRect, alpha: f32, scale: f64, p: f32, s: &Skin) -> View<()> {
+ let accented = i == 0 || i == 2 || i == 4;
+ let border_col = if accented { s.border_accent } else { s.border };
+
+ // Pop de entrada: escala desde el centro de la card.
+ let cx = rect.x + rect.w / 2.0;
+ let cy = rect.y + rect.h / 2.0;
+ let xf = Affine::translate((cx, cy)) * Affine::scale(scale) * Affine::translate((-cx, -cy));
+
+ View::new(Style {
+ position: Position::Absolute,
+ inset: Rect {
+ left: length(rect.x as f32),
+ top: length(rect.y as f32),
+ right: auto(),
+ bottom: auto(),
+ },
+ size: Size { width: length(rect.w as f32), height: length(rect.h as f32) },
+ flex_direction: FlexDirection::Column,
+ gap: Size { width: length(0.0), height: length(0.0) },
+ padding: Rect {
+ left: length(22.0),
+ right: length(22.0),
+ top: length(20.0),
+ bottom: length(18.0),
+ },
+ ..Default::default()
+ })
+ .fill_gradient(
+ Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
+ .with_stops([s.panel_hi, s.panel].as_slice()),
+ )
+ .radius(18.0)
+ .border(if accented { 1.4 } else { 1.0 }, border_col)
+ .shadow(Shadow::soft(120, 26.0).offset(0.0, 12.0))
+ .transform(xf)
+ .alpha(alpha)
+ .children(card_body(i, p, s))
+}
+
+// ───────────────────────── overlays vector (cold-open + wordmark) ─────────────────────────
+
+/// Curva bezier "firma" del cold-open.
+fn signature_path(cw: f64, ch: f64) -> BezPath {
+ let cx = cw / 2.0;
+ let cy = ch / 2.0;
+ let mut p = BezPath::new();
+ p.move_to((cx - 360.0, cy + 40.0));
+ p.curve_to(
+ (cx - 150.0, cy - 220.0),
+ (cx + 150.0, cy + 220.0),
+ (cx + 360.0, cy - 40.0),
+ );
+ p
+}
+
+/// Recorta un `BezPath` cúbico a su fracción inicial `prog`. Devuelve la
+/// cabeza del trazo para anclar el punto teal.
+fn trim_path(full: &BezPath, prog: f64) -> (BezPath, Point) {
+ use vello::kurbo::ParamCurve;
+ let prog = prog.clamp(0.0, 1.0);
+ let mut cubic = None;
+ let mut start = Point::ZERO;
+ for el in full.elements() {
+ match el {
+ vello::kurbo::PathEl::MoveTo(p) => start = *p,
+ vello::kurbo::PathEl::CurveTo(c1, c2, p) => {
+ cubic = Some(vello::kurbo::CubicBez::new(start, *c1, *c2, *p));
+ }
+ _ => {}
+ }
+ }
+ let mut out = BezPath::new();
+ let mut head = start;
+ if let Some(cb) = cubic {
+ out.move_to(cb.p0);
+ let steps = 96;
+ for i in 1..=steps {
+ let u = (i as f64 / steps as f64) * prog;
+ let pt = cb.eval(u);
+ out.line_to(pt);
+ head = pt;
+ }
+ }
+ (out, head)
+}
+
+/// Dibuja los overlays vector (cold-open + wordmark + punto firma) sobre un
+/// nodo full-screen, en función de `t`. Los widgets ya están pintados debajo.
+fn draw_overlays(scene: &mut vello::Scene, ts: &mut Typesetter, t: f32, cw: f64, ch: f64, s: &Skin) {
+ // ── COLD OPEN (0–12%) ──────────────────────────────────────────
+ let b1 = seg(t, 0.0, 0.12);
+ let line_vis = 1.0 - seg(t, 0.12, 0.20);
+ if line_vis > 0.001 {
+ let path = signature_path(cw, ch);
+ let draw_on = motion::ease_out_cubic(seg(t, 0.02, 0.13)) as f64;
+ let (trimmed, head) = trim_path(&path, draw_on);
+ let line_col = with_alpha(s.accent, 0.9 * line_vis);
+ scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, line_col, None, &trimmed);
+ let pop = motion::ease_out_back(b1);
+ let r = (4.0 + 7.0 * pop as f64).max(0.0);
+ let dot_a = (b1 * line_vis).clamp(0.0, 1.0);
+ scene.fill(
+ peniko::Fill::NonZero,
+ Affine::IDENTITY,
+ with_alpha(s.accent, 0.18 * dot_a),
+ None,
+ &Circle::new(head, r * 3.2),
+ );
+ scene.fill(
+ peniko::Fill::NonZero,
+ Affine::IDENTITY,
+ with_alpha(s.accent, dot_a),
+ None,
+ &Circle::new(head, r),
+ );
+ }
+
+ // ── WORDMARK (82–100%) ─────────────────────────────────────────
+ let word_in = seg(t, 0.84, 0.95);
+ let word_a = motion::ease_out_cubic(word_in);
+ if word_a > 0.001 {
+ let size = 132.0_f32;
+ let layout = ts.layout(
+ "Llimphi", size, None, Alignment::Start, 1.0, false, None, 800.0, false, false, 0.0, 0.0,
+ );
+ let m = measurement(&layout);
+ let rise = lerp(24.0, 0.0, word_a as f64);
+ let ox = (cw - m.width as f64) / 2.0;
+ let oy = (ch - m.height as f64) / 2.0 - 18.0 + rise;
+ let brush = peniko::Brush::Solid(with_alpha(s.fg, word_a));
+ draw_layout_brush_xf(scene, &layout, &brush, Affine::translate((ox, oy)));
+
+ let sub_a = motion::ease_out_cubic(seg(t, 0.88, 0.99));
+ if sub_a > 0.001 {
+ let ssz = 26.0_f32;
+ let sub = ts.layout(
+ "a Rust GUI framework", ssz, None, Alignment::Start, 1.0, false, None, 400.0,
+ false, false, 0.0, 0.0,
+ );
+ let sm = measurement(&sub);
+ let dot_r = 6.0;
+ let block_w = sm.width as f64 + dot_r * 2.0 + 14.0;
+ let sx = (cw - block_w) / 2.0;
+ let sy = oy + m.height as f64 + 18.0;
+ scene.fill(
+ peniko::Fill::NonZero,
+ Affine::IDENTITY,
+ with_alpha(s.accent, sub_a),
+ None,
+ &Circle::new(Point::new(sx + dot_r, sy + ssz as f64 * 0.42), dot_r as f64),
+ );
+ let sbrush = peniko::Brush::Solid(with_alpha(s.fg_muted, sub_a));
+ draw_layout_brush_xf(
+ scene,
+ &sub,
+ &sbrush,
+ Affine::translate((sx + dot_r * 2.0 + 14.0, sy)),
+ );
+ }
+ }
+
+ // ── punto teal de firma (esquina inf-der), ancla de marca ───────
+ let corner_a = seg(t, 0.04, 0.12) * (1.0 - seg(t, 0.80, 0.86));
+ if corner_a > 0.001 {
+ let cx = cw - 54.0;
+ let cy = ch - 54.0;
+ scene.fill(
+ peniko::Fill::NonZero,
+ Affine::IDENTITY,
+ with_alpha(s.accent, 0.16 * corner_a),
+ None,
+ &Circle::new(Point::new(cx, cy), 18.0),
+ );
+ scene.fill(
+ peniko::Fill::NonZero,
+ Affine::IDENTITY,
+ with_alpha(s.accent, 0.9 * corner_a),
+ None,
+ &Circle::new(Point::new(cx, cy), 6.0),
+ );
+ }
+}
+
+// ───────────────────────── la escena por frame ─────────────────────────
+
+/// Construye el árbol `View` completo del frame `t`: las cards con widgets
+/// reales (con su estado derivado de t) + un nodo overlay full-screen que
+/// pinta cold-open / wordmark encima.
+fn build_view(t: f32, cw: f64, ch: f64, s: &Skin) -> View<()> {
+ let grid = layout_grid(cw, ch);
+ let row = layout_row(cw, ch);
+
+ // Progreso del "estado" de los widgets (toggle/slider/progress/…).
+ let widget_p = seg(t, 0.16, 0.58);
+ // Morph grid → fila (58–80%).
+ let morph = motion::ease_in_out_cubic(seg(t, 0.60, 0.80)) as f64;
+ // Fade-out de las cards antes del wordmark.
+ let cards_fade = 1.0 - seg(t, 0.80, 0.86);
+
+ let mut children: Vec> = Vec::new();
+
+ if cards_fade > 0.001 {
+ for i in 0..N_CARDS {
+ // Stagger de entrada: cada card arranca con retraso incremental.
+ let delay = i as f32 * 0.035;
+ let enter = motion::ease_out_back(seg(t, 0.12 + delay, 0.12 + delay + 0.16));
+ if enter <= 0.001 {
+ continue;
+ }
+ let rect = grid[i].lerp(row[i], morph);
+ let scale = lerp(0.88, 1.0, enter.min(1.0) as f64);
+ let alpha = (enter.min(1.0) * cards_fade).clamp(0.0, 1.0);
+ children.push(card_view(i, rect, alpha, scale, widget_p, s));
+ }
+ }
+
+ // Nodo overlay full-screen para el vector (cold-open + wordmark).
+ let overlay = View::new(Style {
+ position: Position::Absolute,
+ inset: Rect {
+ left: length(0.0),
+ top: length(0.0),
+ right: length(0.0),
+ bottom: length(0.0),
+ },
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .paint_with({
+ let s = s.clone();
+ move |scene, ts, _rect: PaintRect| {
+ draw_overlays(scene, ts, t, cw, ch, &s);
+ }
+ });
+ children.push(overlay);
+
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ position: Position::Relative,
+ ..Default::default()
+ })
+ .fill(s.bg)
+ .children(children)
+}
+
+fn main() {
+ let mut args = std::env::args().skip(1);
+ let out_dir = args.next().unwrap_or_else(|| "showreel_frames".to_string());
+ let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(360);
+ let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600);
+ let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900);
+ create_dir_all(&out_dir).expect("mkdir out_dir");
+
+ let theme = llimphi_theme::Theme::by_name("Tawa").expect("tema Tawa");
+ let accent = Color::from_rgba8(0x2B, 0xD9, 0xA6, 0xFF); // teal #2BD9A6 (acento firma)
+ let skin = Skin {
+ accent,
+ panel: theme.bg_panel,
+ panel_hi: theme.bg_button,
+ border: theme.border,
+ border_accent: with_alpha(accent, 0.55),
+ fg: theme.fg_text,
+ fg_muted: theme.fg_muted,
+ bg: theme.bg_app,
+ theme,
+ };
+ let [br, bg, bb, _] = skin.bg.components;
+ let base = Color::from_rgba8((br * 255.0) as u8, (bg * 255.0) as u8, (bb * 255.0) as u8, 255);
+
+ // GPU una sola vez; reusar device/renderer/target/buffer para los N frames.
+ 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("showreel"),
+ 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());
+
+ let mut ts = Typesetter::new();
+ let cw = w as f64;
+ let ch = h as f64;
+
+ for i in 0..n {
+ let t = if n <= 1 { 0.0 } else { i as f32 / (n as f32 - 1.0) };
+
+ let root = build_view(t, cw, ch, &skin);
+
+ // view → layout (con medición de texto real) → scene — idéntico al eventloop.
+ let mut layout = LayoutTree::new();
+ let mounted = mount(&mut layout, root);
+ 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);
+
+ renderer
+ .render_to_view(&hal, &scene, &view, w, h, base)
+ .expect("render_to_view");
+ let path = format!("{out_dir}/frame_{i:04}.png");
+ write_png(&hal, &target, &path, w, h);
+ if i % 30 == 0 || i == n - 1 {
+ eprintln!("showreel: frame {}/{} (t={:.3})", i + 1, n, t);
+ }
+ }
+ eprintln!("showreel: {n} frames en {out_dir}/ ({w}x{h})");
+}
+
+fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32) {
+ 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 r in 0..h as usize {
+ let sidx = r * padded;
+ pixels.extend_from_slice(&data[sidx..sidx + 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 wr = enc.write_header().unwrap();
+ wr.write_image_data(&pixels).unwrap();
+}
diff --git a/llimphi-compositor/src/anim.rs b/llimphi-compositor/src/anim.rs
new file mode 100644
index 0000000..474224b
--- /dev/null
+++ b/llimphi-compositor/src/anim.rs
@@ -0,0 +1,1096 @@
+//! Animaciones **implícitas** (estilo Flutter `AnimatedContainer`): un nodo
+//! del `View` declara una `key` estable y, cuando sus props visuales de paint
+//! cambian entre frames, el runtime **interpola** en vez de saltar — sin que
+//! la app cablee un `Tween` en su `Model` ni un loop de ticks.
+//!
+//! El modelo de Llimphi reconstruye el árbol `View` cada frame desde el
+//! `Model`, así que no hay estado retenido por nodo. Este registro lo aporta:
+//! mapea `key → AnimEntry` (valor actual + objetivo + reloj) y vive en el
+//! runtime entre frames. En cada redraw, DESPUÉS de `compute` y ANTES de
+//! `paint`, el runtime llama [`AnimRegistry::reconcile`], que:
+//!
+//! 1. Para cada nodo con [`Anim`], toma su valor objetivo (lo que la `view`
+//! pintó este frame).
+//! 2. Si el objetivo cambió respecto del guardado, arranca un tween desde el
+//! valor interpolado actual hacia el nuevo.
+//! 3. Escribe el valor interpolado de vuelta en el nodo (fill/radius) para
+//! que `paint` lo use.
+//! 4. Devuelve `true` si alguna animación sigue viva → el runtime pide otro
+//! frame (`request_redraw`). Cuando todas se asientan, deja de pedir frames
+//! (el ticker se autodetiene; no hay render loop ocioso).
+//!
+//! La **primera** aparición de una key no anima (igual que Flutter): sólo los
+//! **cambios** posteriores se interpolan. Props soportadas hoy: `fill` (color),
+//! `radius`, `alpha` (opacidad) y `transform` (afín 2D — scale/rotate/translate
+//! alrededor del centro del rect). Es ampliable agregando campos a
+//! [`AnimSnapshot`].
+//!
+//! **Animación de contenido (entrada y salida).** Aparte de los cambios de
+//! props, una key puede animar su **entrada** ([`crate::View::animated_enter`]:
+//! la primera aparición sube la opacidad de 0 a su valor) y su **salida**
+//! ([`crate::View::animated_exit`]: al desaparecer del árbol). El exit no se
+//! puede hacer sólo modificando nodos vivos — el nodo ya no está. La solución:
+//! el runtime captura la **subescena vello** que el nodo `exit` pinta cada
+//! frame mientras vive (vía [`AnimRegistry::live_exit_nodes`] +
+//! [`AnimRegistry::store_live_exit`]); cuando la key desaparece, esa subescena
+//! retenida se promueve a **fantasma** y [`AnimRegistry::replay_ghosts`] la
+//! reproduce con opacidad decreciente hasta que el reloj se agota.
+//!
+//! **Cross-fade real (`AnimatedSwitcher`).** Un nodo puede declarar
+//! ([`crate::View::animated_switch`]) una `key` estable + una **variante** de
+//! contenido. Cuando la variante cambia entre frames, el runtime promueve el
+//! contenido anterior (retenido en `live` el frame previo) a fantasma
+//! (fade-out) y arranca el subárbol nuevo desde alpha 0 (fade-in), en el mismo
+//! rect — una transición entre dos identidades distintas reusando la misma
+//! infra de ghosts del `exit`, sin tener que combinar enter+exit de dos keys.
+
+use std::collections::HashMap;
+use std::time::{Duration, Instant};
+
+use vello::kurbo::{Affine, Rect};
+use vello::peniko::{Color, Fill, Mix};
+use vello::Scene;
+
+use crate::Mounted;
+
+/// Declara que las props visuales de paint de este nodo se animan de forma
+/// implícita. `key` debe ser estable entre rebuilds del `View` (índice de
+/// item, hash de id, etc.) — es lo que enlaza "el mismo nodo" entre frames.
+#[derive(Clone, Copy, Debug)]
+pub struct Anim {
+ pub key: u64,
+ pub duration: Duration,
+ /// Easing aplicado a `t ∈ [0,1]`. Las canónicas viven en
+ /// `llimphi_theme::motion`; por defecto el builder usa un ease-out cúbico.
+ 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
+ /// el arranque del primer frame. Sin él, la primera aparición se asienta
+ /// instantánea (default histórico de `View::animated`).
+ pub enter: bool,
+ /// `true` si la **salida** de la key debe animar (fade-out): cuando el nodo
+ /// desaparece del árbol, el runtime retiene la última subescena que pintó y
+ /// la reproduce con opacidad decreciente durante `duration`, en vez de que
+ /// el nodo se esfume de golpe. Tiene coste por frame (captura el subárbol
+ /// mientras vive) — usar en pocos nodos (toasts, modales, paneles), no en
+ /// cada fila de una lista grande.
+ pub exit: bool,
+ /// Transformación afín desde la que arrancar la **entrada** (`enter`). Por
+ /// ej. `Some(Affine::scale(0.6))` da el "pop" del FAB; `Some(Affine::
+ /// translate((0.0, 60.0)))` da slide-in vertical. Llega al target del nodo
+ /// (`node.transform` o identidad) en `duration`. Sin efecto si `enter` es
+ /// `false`. Combinable con el fade de entrada por defecto.
+ pub enter_from_xf: Option,
+ /// Discriminador de **variante de contenido** para cross-fade real
+ /// (Flutter `AnimatedSwitcher`). Cuando es `Some(v)` y `v` **cambia**
+ /// entre frames bajo la misma `key`, el runtime promueve la subescena del
+ /// contenido anterior a fantasma (fade-out) y hace fade-in del nuevo, en
+ /// el mismo rect — una transición real entre dos identidades distintas, no
+ /// 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,
+}
+
+/// Ease-out cúbico, el default razonable para transiciones implícitas
+/// (arranca rápido, frena suave). Copia local para no acoplar el compositor a
+/// `llimphi-theme`; el caller puede pasar cualquier `fn(f32)->f32`.
+pub fn ease_out_cubic(t: f32) -> f32 {
+ let u = 1.0 - t.clamp(0.0, 1.0);
+ 1.0 - u * u * u
+}
+
+/// Declara que el **tamaño** de este nodo (CSS `width`/`height` /
+/// Flutter `AnimatedSize`/Compose `animateContentSize()`) se anima de
+/// forma implícita cuando cambia entre frames. Bloque 15 de
+/// PARIDAD-FLUTTER (extensión faltante del Bloque 4).
+///
+/// A diferencia de [`Anim`] (que interpola props de **paint** después
+/// del layout: fill/radius/alpha/transform), el tamaño tiene que estar
+/// fijo **antes** del layout — siblings y hijos dependen del rect del
+/// nodo. Por eso este registro vive aparte y el reconciler camina el
+/// `View` tree **antes** de `mount`, parchando `style.size` con el
+/// valor interpolado.
+///
+/// **Límite v1**: sólo anima cuando `style.size.width` y
+/// `style.size.height` son ambas `Dimension::Length(_)`. Si una es
+/// `Percent`/`Auto`, el nodo se monta tal cual sin animación (no hay
+/// "tamaño en píxeles" estable para interpolar). El caller que quiera
+/// animar un nodo flex debe declarar `length(...)` explícito.
+#[derive(Clone, Copy, Debug)]
+pub struct SizeAnim {
+ pub key: u64,
+ pub duration: Duration,
+ pub easing: fn(f32) -> f32,
+}
+
+#[derive(Clone, Copy)]
+struct SizeAnimEntry {
+ from: (f32, f32),
+ to: (f32, f32),
+ start: Instant,
+ duration: Duration,
+ easing: fn(f32) -> f32,
+}
+
+impl SizeAnimEntry {
+ /// Entrada "asentada" (from == to): no anima. Igual que
+ /// `AnimEntry::settled`, usamos `duration: ZERO` para que `done(now)`
+ /// devuelva `true` desde el frame 0 — así la primera aparición no
+ /// pide más frames. Cuando llegue un target nuevo el reconciler
+ /// sobreescribe `duration` con el de `SizeAnim`.
+ fn settled(target: (f32, f32), now: Instant, _dur: Duration, easing: fn(f32) -> f32) -> Self {
+ Self {
+ from: target,
+ to: target,
+ start: now,
+ duration: Duration::ZERO,
+ easing,
+ }
+ }
+
+ fn t(&self, now: Instant) -> f32 {
+ if self.duration.is_zero() {
+ 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);
+ (self.easing)(raw)
+ }
+
+ fn value(&self, now: Instant) -> (f32, f32) {
+ let t = self.t(now);
+ let (fw, fh) = self.from;
+ let (tw, th) = self.to;
+ (fw + (tw - fw) * t, fh + (th - fh) * t)
+ }
+
+ fn done(&self, now: Instant) -> bool {
+ now.saturating_duration_since(self.start) >= self.duration
+ }
+}
+
+/// Registro de animaciones implícitas de **tamaño**, vivo entre
+/// frames. El runtime mantiene una instancia y llama
+/// [`reconcile_size_anim`] en cada redraw **antes** del mount/layout.
+#[derive(Default)]
+pub struct SizeAnimRegistry {
+ entries: HashMap,
+}
+
+impl SizeAnimRegistry {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn clear(&mut self) {
+ self.entries.clear();
+ }
+
+ /// Para tests: hay animación viva para esa key.
+ pub fn is_animating(&self, key: u64, now: Instant) -> bool {
+ self.entries.get(&key).map(|e| !e.done(now)).unwrap_or(false)
+ }
+}
+
+/// Lee `(width, height)` en píxeles si **ambos** son
+/// `Dimension::Length(_)`. Devuelve `None` si alguno es `Auto`,
+/// `Percent`, etc. — esos nodos no se animan en v1. (taffy 0.9 esconde
+/// las variantes detrás de un `CompactLength`; chequeamos por tag.)
+fn try_extract_length_size(
+ style: &llimphi_layout::Style,
+) -> Option<(f32, f32)> {
+ use llimphi_layout::taffy::CompactLength;
+ let w = style.size.width;
+ let h = style.size.height;
+ if w.tag() == CompactLength::LENGTH_TAG && h.tag() == CompactLength::LENGTH_TAG {
+ Some((w.value(), h.value()))
+ } else {
+ None
+ }
+}
+
+fn patch_length_size(style: &mut llimphi_layout::Style, size: (f32, f32)) {
+ use llimphi_layout::taffy::Dimension;
+ style.size.width = Dimension::length(size.0);
+ style.size.height = Dimension::length(size.1);
+}
+
+/// Recorre el `View` tree y, para cada nodo con [`SizeAnim`], reconcila
+/// su `style.size` con el registry: si cambió el objetivo, arranca un
+/// tween; si está animando, parcha `style.size` con el valor
+/// interpolado. Devuelve `true` si alguna animación de tamaño sigue
+/// viva → el runtime debe pedir otro redraw.
+///
+/// **Cuándo llamarlo**: el runtime lo invoca tras `A::view(model)` y
+/// **antes** de `mount`/`compute`, así el layout cascade ve el tamaño
+/// interpolado en vez del objetivo crudo (siblings y hijos reflowean
+/// suave).
+///
+/// Las keys no vistas este frame se descartan al final — un nodo que se
+/// va deja de animar (mismo comportamiento que [`AnimRegistry::reconcile`]).
+pub fn reconcile_size_anim(
+ view: &mut crate::View,
+ reg: &mut SizeAnimRegistry,
+ now: Instant,
+) -> bool {
+ let mut seen: Vec = Vec::new();
+ let animating = reconcile_size_anim_inner(view, reg, now, &mut seen);
+ if reg.entries.len() != seen.len() {
+ reg.entries.retain(|k, _| seen.contains(k));
+ }
+ animating
+}
+
+fn reconcile_size_anim_inner(
+ view: &mut crate::View,
+ reg: &mut SizeAnimRegistry,
+ now: Instant,
+ seen: &mut Vec,
+) -> bool {
+ let mut animating = false;
+ if let Some(sa) = view.animated_size {
+ if let Some(target) = try_extract_length_size(&view.style) {
+ seen.push(sa.key);
+ let entry = reg
+ .entries
+ .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
+ // `AnimRegistry` de props.
+ entry.from = entry.value(now);
+ entry.to = target;
+ entry.start = now;
+ entry.duration = sa.duration;
+ entry.easing = sa.easing;
+ }
+ let interp = if entry.done(now) { entry.to } else { entry.value(now) };
+ patch_length_size(&mut view.style, interp);
+ if !entry.done(now) {
+ animating = true;
+ }
+ }
+ }
+ for child in view.children.iter_mut() {
+ if reconcile_size_anim_inner(child, reg, now, seen) {
+ animating = true;
+ }
+ }
+ animating
+}
+
+/// Foto de las props animables de un nodo en un frame. `alpha == None` ≡ nodo
+/// opaco (1.0): es la convención de `View::alpha` y la usa el lerp para mezclar
+/// hacia/desde "sin alpha explícito" sin tratarlo como un salto. Lo mismo para
+/// `transform == None` ≡ identidad, así "sin transform" → "con transform" anima
+/// desde la identidad (estilo CSS `transform: none` → `transform: scale(1.5)`).
+#[derive(Clone, Copy, PartialEq)]
+struct AnimSnapshot {
+ fill: Option,
+ radius: f64,
+ alpha: Option,
+ transform: Option,
+}
+
+#[inline]
+fn lerp_f64(a: f64, b: f64, t: f32) -> f64 {
+ a + (b - a) * t as f64
+}
+
+#[inline]
+fn lerp_color(a: Color, b: Color, t: f32) -> Color {
+ let p = a.components;
+ let q = b.components;
+ Color {
+ components: [
+ p[0] + (q[0] - p[0]) * t,
+ p[1] + (q[1] - p[1]) * t,
+ p[2] + (q[2] - p[2]) * t,
+ p[3] + (q[3] - p[3]) * t,
+ ],
+ ..a
+ }
+}
+
+/// Lerp componente-a-componente de las 6 coefs del afín (m00, m10, m01, m11,
+/// m02, m12). Es lo mismo que Flutter `MatrixTween`: no preserva una rotación
+/// pura entre matrices muy distintas, pero alcanza para las animaciones UI
+/// típicas (scale/translate/rotaciones chicas, slide-in, pop, hero).
+#[inline]
+fn lerp_affine(a: Affine, b: Affine, t: f32) -> Affine {
+ let p = a.as_coeffs();
+ let q = b.as_coeffs();
+ let ft = t as f64;
+ Affine::new([
+ p[0] + (q[0] - p[0]) * ft,
+ p[1] + (q[1] - p[1]) * ft,
+ p[2] + (q[2] - p[2]) * ft,
+ p[3] + (q[3] - p[3]) * ft,
+ p[4] + (q[4] - p[4]) * ft,
+ p[5] + (q[5] - p[5]) * ft,
+ ])
+}
+
+impl AnimSnapshot {
+ /// Interpola entre `self` (origen) y `to` (objetivo). El color sólo se
+ /// mezcla si ambos lados tienen fill sólido; si uno es `None` (gradiente o
+ /// sin fill) se salta al objetivo sin crossfade.
+ fn lerp(self, to: AnimSnapshot, t: f32) -> AnimSnapshot {
+ let fill = match (self.fill, to.fill) {
+ (Some(a), Some(b)) => Some(lerp_color(a, b, t)),
+ _ => to.fill,
+ };
+ // `None` ≡ opaco (1.0): un lado sin alpha se mezcla contra 1.0 en vez
+ // de saltar, así fade-in (0→opaco) y fade de un alpha explícito a/desde
+ // "sin alpha" interpolan suave. None↔None se mantiene None (sin capa).
+ let alpha = match (self.alpha, to.alpha) {
+ (None, None) => None,
+ (a, b) => {
+ let from = a.unwrap_or(1.0);
+ let dst = b.unwrap_or(1.0);
+ Some(from + (dst - from) * t)
+ }
+ };
+ // `None` ≡ identidad: idem. Un lado sin transform se mezcla contra
+ // `Affine::IDENTITY` en vez de saltar, así "sin xf" → `scale(1.5)`
+ // arranca desde scale(1) (Flutter/CSS hacen lo mismo). None↔None se
+ // mantiene None (sin push_layer afín en paint).
+ let transform = match (self.transform, to.transform) {
+ (None, None) => None,
+ (a, b) => {
+ let from = a.unwrap_or(Affine::IDENTITY);
+ let dst = b.unwrap_or(Affine::IDENTITY);
+ Some(lerp_affine(from, dst, t))
+ }
+ };
+ AnimSnapshot {
+ fill,
+ radius: lerp_f64(self.radius, to.radius, t),
+ alpha,
+ transform,
+ }
+ }
+}
+
+/// Estado retenido de una animación: tween entre `from` y `to`.
+struct AnimEntry {
+ from: AnimSnapshot,
+ to: AnimSnapshot,
+ start: Instant,
+ duration: Duration,
+ easing: fn(f32) -> f32,
+}
+
+impl AnimEntry {
+ /// Entrada ya asentada en `snap` (from == to): no anima.
+ fn settled(snap: AnimSnapshot, now: Instant) -> Self {
+ Self {
+ from: snap,
+ to: snap,
+ start: now,
+ duration: Duration::ZERO,
+ easing: |t| t,
+ }
+ }
+
+ /// Progreso `[0,1]` con easing aplicado.
+ fn t(&self, now: Instant) -> f32 {
+ if self.duration.is_zero() {
+ 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);
+ (self.easing)(raw)
+ }
+
+ fn value(&self, now: Instant) -> AnimSnapshot {
+ self.from.lerp(self.to, self.t(now))
+ }
+
+ fn done(&self, now: Instant) -> bool {
+ now.saturating_duration_since(self.start) >= self.duration
+ }
+}
+
+/// Subescena retenida de un nodo marcado para animar su salida, capturada por
+/// el runtime el último frame que el nodo vivió. Mientras la key sigue presente
+/// se refresca cada frame; cuando desaparece, se promueve a [`Ghost`].
+struct LiveExit {
+ scene: Scene,
+ duration: Duration,
+ easing: fn(f32) -> f32,
+}
+
+/// Un nodo que ya salió del árbol y se está desvaneciendo: su subescena retenida
+/// + el reloj de fade-out.
+struct Ghost {
+ scene: Scene,
+ start: Instant,
+ duration: Duration,
+ easing: fn(f32) -> f32,
+}
+
+impl Ghost {
+ /// Opacidad actual del fantasma: `1 → 0` con easing aplicado.
+ fn alpha(&self, now: Instant) -> f32 {
+ if self.duration.is_zero() {
+ return 0.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)
+ }
+
+ fn done(&self, now: Instant) -> bool {
+ now.saturating_duration_since(self.start) >= self.duration
+ }
+}
+
+/// Registro de animaciones implícitas, vivo entre frames. El runtime mantiene
+/// una instancia y llama [`Self::reconcile`] en cada redraw.
+#[derive(Default)]
+pub struct AnimRegistry {
+ entries: HashMap,
+ /// Snapshots de los nodos `exit`/`switch` presentes (refrescados por el
+ /// runtime tras el paint de cada frame). Membresía = "presente el frame
+ /// anterior".
+ live: HashMap,
+ /// Nodos `exit` que ya desaparecieron (o contenido viejo de un `switch`)
+ /// que se están desvaneciendo.
+ ghosts: HashMap,
+ /// Última variante vista por cada key con `switch` — para detectar el
+ /// cambio de contenido que dispara el cross-fade.
+ variants: HashMap,
+}
+
+impl AnimRegistry {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Reconcilia el árbol montado con el estado retenido. Para cada nodo con
+ /// [`Anim`]: detecta si el objetivo cambió (arranca tween), interpola y
+ /// **escribe** el valor del frame de vuelta en el nodo (fill/radius). Las
+ /// keys que no aparecieron este frame se descartan (un nodo que se va deja
+ /// de animar). Devuelve `true` si alguna animación sigue en curso.
+ ///
+ /// Llamar DESPUÉS de `compute` y ANTES de `paint`. `now` es el instante del
+ /// frame (el runtime pasa `Instant::now()`; los tests pasan instantes
+ /// controlados).
+ pub fn reconcile(&mut self, mounted: &mut Mounted, now: Instant) -> bool {
+ let mut animating = false;
+ let mut seen: Vec = Vec::new();
+ // Keys presentes que requieren captura `live` y tracking de vanish
+ // (exit O switch). Membresía = "vive este frame".
+ let mut present_live: Vec = Vec::new();
+ // Sólo keys `exit` puras: su reaparición CANCELA el fade-out. Las de
+ // `switch` están presentes todos los frames y su ghost (contenido
+ // viejo) NO debe cancelarse por presencia.
+ let mut present_exit_only: Vec = Vec::new();
+ for node in &mut mounted.nodes {
+ let Some(anim) = node.anim else { continue };
+ seen.push(anim.key);
+ let target = AnimSnapshot {
+ fill: node.fill,
+ radius: node.radius,
+ alpha: node.alpha,
+ transform: node.transform,
+ };
+ // Detección de cross-fade (switch) ANTES de tomar prestado
+ // `entries`: si la variante cambió, el contenido viejo retenido en
+ // `live` (del frame anterior) se promueve a fantasma (fade-out) y
+ // el nodo nuevo arranca su fade-in desde alpha 0.
+ let mut switched = false;
+ if anim.exit {
+ present_live.push(anim.key);
+ present_exit_only.push(anim.key);
+ } else if let Some(variant) = anim.switch {
+ present_live.push(anim.key);
+ if let Some(prev) = self.variants.insert(anim.key, variant) {
+ if prev != variant {
+ switched = true;
+ if let Some(le) = self.live.remove(&anim.key) {
+ self.ghosts.insert(
+ anim.key,
+ Ghost {
+ scene: le.scene,
+ start: now,
+ duration: le.duration,
+ easing: le.easing,
+ },
+ );
+ }
+ }
+ }
+ }
+ let entry = self.entries.entry(anim.key).or_insert_with(|| {
+ // Primera aparición. Con `enter`, arranca un tween de opacidad
+ // 0 → objetivo (fade-in); si además hay `enter_from_xf`, también
+ // arranca de esa transform → target.transform (scale-in/slide-in).
+ if anim.enter {
+ let from = AnimSnapshot {
+ alpha: Some(0.0),
+ transform: anim.enter_from_xf.or(target.transform),
+ ..target
+ };
+ AnimEntry {
+ from,
+ to: target,
+ start: now,
+ duration: anim.duration,
+ easing: anim.easing,
+ }
+ } else {
+ AnimEntry::settled(target, now)
+ }
+ });
+ if switched {
+ // Cross-fade: el contenido nuevo entra desde transparente
+ // (el viejo ya quedó como fantasma desvaneciéndose encima).
+ entry.from = AnimSnapshot {
+ alpha: Some(0.0),
+ ..target
+ };
+ entry.to = target;
+ entry.start = now;
+ 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.
+ entry.from = entry.value(now);
+ entry.to = target;
+ entry.start = now;
+ entry.duration = anim.duration;
+ entry.easing = anim.easing;
+ }
+ // Al terminar aterriza EXACTO en el objetivo (incluido `alpha:
+ // None` / `transform: None`, que evita capa de opacidad residual o
+ // un push_layer afín espurio frame a frame).
+ let v = if entry.done(now) { entry.to } else { entry.value(now) };
+ node.fill = v.fill;
+ node.radius = v.radius;
+ node.alpha = v.alpha;
+ node.transform = v.transform;
+ if !entry.done(now) {
+ animating = true;
+ }
+ }
+ if self.entries.len() != seen.len() {
+ self.entries.retain(|k, _| seen.contains(k));
+ }
+ // Las variantes de keys que ya no aparecen se descartan (si la key
+ // vuelve, su primera aparición re-asienta sin cross-fade).
+ if self.variants.len() != seen.len() {
+ self.variants.retain(|k, _| seen.contains(k));
+ }
+
+ // Salidas (fade-out). Una key `exit`/`switch` presente el frame anterior
+ // (vive en `live`) que ya no aparece → se promueve a fantasma con su
+ // última subescena retenida. Si una key `exit` con fantasma reaparece,
+ // se cancela el fade (no las de `switch`: su fantasma es contenido viejo
+ // que debe seguir desvaneciéndose aunque la key siga presente). Por
+ // último, descartamos los fantasmas cuyo reloj se agotó.
+ let vanished: Vec = self
+ .live
+ .keys()
+ .filter(|k| !present_live.contains(k))
+ .copied()
+ .collect();
+ for key in vanished {
+ if let Some(le) = self.live.remove(&key) {
+ self.ghosts.insert(
+ key,
+ Ghost {
+ scene: le.scene,
+ start: now,
+ duration: le.duration,
+ easing: le.easing,
+ },
+ );
+ }
+ }
+ for key in &present_exit_only {
+ self.ghosts.remove(key);
+ }
+ self.ghosts.retain(|_, g| !g.done(now));
+ animating || !self.ghosts.is_empty()
+ }
+
+ /// Nodos `exit` presentes este frame que el runtime debe **capturar**: por
+ /// cada uno devuelve `(idx, subtree_end, key)` para pintar su subárbol en
+ /// una subescena con [`crate::paint_range`] y entregarla a
+ /// [`Self::store_live_exit`]. Llamar DESPUÉS de `paint` (cuando el árbol y
+ /// la geometría ya están firmes).
+ pub fn live_exit_nodes(&self, mounted: &Mounted) -> Vec<(usize, usize, u64)> {
+ mounted
+ .nodes
+ .iter()
+ .enumerate()
+ .filter_map(|(idx, n)| {
+ n.anim
+ .filter(|a| a.exit || a.switch.is_some())
+ .map(|a| (idx, n.subtree_end, a.key))
+ })
+ .collect()
+ }
+
+ /// 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 });
+ }
+
+ /// Reproduce los fantasmas activos sobre `scene`, cada uno con su opacidad
+ /// decreciente, clipeados al viewport `(w, h)`. Llamar DESPUÉS del paint
+ /// principal (van por encima). Devuelve `true` si queda algún fantasma vivo
+ /// (el runtime ya lo sabe por [`Self::reconcile`], pero es cómodo).
+ pub fn replay_ghosts(&mut self, scene: &mut Scene, now: Instant, w: f32, h: f32) -> bool {
+ if self.ghosts.is_empty() {
+ return false;
+ }
+ let clip = Rect::new(0.0, 0.0, w as f64, h as f64);
+ for g in self.ghosts.values() {
+ 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.pop_layer();
+ }
+ true
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{mount, View};
+ use llimphi_layout::{LayoutTree, Style};
+
+ fn rgba(r: u8, g: u8, b: u8) -> Color {
+ Color::from_rgba8(r, g, b, 255)
+ }
+
+ /// Monta un único nodo con fill + anim(key=1) y devuelve su `Mounted`.
+ fn one(fill: Color) -> Mounted<()> {
+ let v = View::<()>::new(Style::default())
+ .fill(fill)
+ .animated(1, Duration::from_millis(200));
+ let mut layout = LayoutTree::new();
+ mount(&mut layout, v)
+ }
+
+ #[test]
+ fn primera_aparicion_no_anima() {
+ let mut reg = AnimRegistry::new();
+ let mut m = one(rgba(255, 0, 0));
+ let now = Instant::now();
+ let animating = reg.reconcile(&mut m, now);
+ assert!(!animating, "la primera vez no debe animar");
+ assert_eq!(m.nodes[0].fill, Some(rgba(255, 0, 0)));
+ }
+
+ #[test]
+ fn cambio_de_color_interpola_y_pide_frames() {
+ let mut reg = AnimRegistry::new();
+ let t0 = Instant::now();
+ // Frame 1: rojo, se asienta.
+ let mut m = one(rgba(255, 0, 0));
+ reg.reconcile(&mut m, t0);
+ // Frame 2: la view ahora pinta azul (target nuevo). En el frame en que
+ // se DETECTA el cambio arranca el reloj: aún muestra el origen (rojo)
+ // pero ya pide frames.
+ let mut m = one(rgba(0, 0, 255));
+ let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(100));
+ assert!(animating, "al detectar el cambio debe pedir frames");
+ // Frame 3: 100ms dentro del tween de 200ms. El fill ya está mezclado:
+ // ni rojo puro ni azul puro.
+ let mut m = one(rgba(0, 0, 255));
+ let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(200));
+ assert!(animating, "a mitad del tween debe seguir animando");
+ let c = m.nodes[0].fill.expect("fill").components;
+ assert!(c[0] < 1.0 && c[0] > 0.0, "rojo intermedio: {}", c[0]);
+ assert!(c[2] > 0.0 && c[2] < 1.0, "azul intermedio: {}", c[2]);
+ }
+
+ #[test]
+ fn al_terminar_llega_al_objetivo_y_deja_de_pedir_frames() {
+ let mut reg = AnimRegistry::new();
+ let t0 = Instant::now();
+ let mut m = one(rgba(255, 0, 0));
+ reg.reconcile(&mut m, t0);
+ let mut m = one(rgba(0, 0, 255));
+ reg.reconcile(&mut m, t0 + Duration::from_millis(100)); // arranca
+ // Pasada la duración, llega exacto al objetivo y no pide más frames.
+ let mut m = one(rgba(0, 0, 255));
+ let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
+ assert!(!animating);
+ assert_eq!(m.nodes[0].fill, Some(rgba(0, 0, 255)));
+ }
+
+ /// Monta un nodo con alpha + anim(key=1) y devuelve su `Mounted`.
+ fn one_alpha(alpha: f32) -> Mounted<()> {
+ let v = View::<()>::new(Style::default())
+ .alpha(alpha)
+ .animated(1, Duration::from_millis(200));
+ let mut layout = LayoutTree::new();
+ mount(&mut layout, v)
+ }
+
+ /// Monta un nodo opaco (sin alpha) con animación de ENTRADA.
+ fn one_enter() -> Mounted<()> {
+ let v = View::<()>::new(Style::default())
+ .fill(rgba(10, 20, 30))
+ .animated_enter(1, Duration::from_millis(200));
+ let mut layout = LayoutTree::new();
+ mount(&mut layout, v)
+ }
+
+ #[test]
+ fn fade_in_de_entrada_arranca_transparente_y_llega_a_opaco() {
+ let mut reg = AnimRegistry::new();
+ let t0 = Instant::now();
+ // Primera aparición de un nodo `enter`: a diferencia de `animated`,
+ // SÍ anima — arranca casi transparente y pide frames.
+ let mut m = one_enter();
+ let animating = reg.reconcile(&mut m, t0);
+ assert!(animating, "la entrada debe animar desde el primer frame");
+ assert_eq!(m.nodes[0].alpha, Some(0.0), "arranca transparente");
+ // A mitad del tween, alpha intermedio.
+ let mut m = one_enter();
+ reg.reconcile(&mut m, t0 + Duration::from_millis(100));
+ let a = m.nodes[0].alpha.expect("alpha");
+ assert!(a > 0.0 && a < 1.0, "alpha intermedio: {a}");
+ // Pasada la duración: opaco exacto (None, sin capa residual) y quieto.
+ let mut m = one_enter();
+ let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
+ assert!(!animating);
+ assert_eq!(m.nodes[0].alpha, None, "aterriza en opaco sin capa");
+ }
+
+ /// Monta un nodo `exit` (key=7) y devuelve su `Mounted`.
+ fn one_exit() -> Mounted<()> {
+ let v = View::<()>::new(Style::default())
+ .fill(rgba(10, 20, 30))
+ .animated_exit(7, Duration::from_millis(200));
+ let mut layout = LayoutTree::new();
+ mount(&mut layout, v)
+ }
+
+ /// Árbol vacío de nodos animados (la key `exit` ya no aparece).
+ fn empty() -> Mounted<()> {
+ let v = View::<()>::new(Style::default()).fill(rgba(9, 9, 9));
+ let mut layout = LayoutTree::new();
+ mount(&mut layout, v)
+ }
+
+ #[test]
+ fn fade_out_de_salida_promueve_fantasma_y_lo_descarta_al_terminar() {
+ 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).
+ 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);
+ // 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));
+ assert!(animating, "un fantasma vivo mantiene el ticker");
+ assert!(reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0));
+ // Frame 3: pasada la duración el fantasma se descarta y el loop para.
+ let mut m = empty();
+ let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(300));
+ assert!(!animating, "fantasma agotado → sin más frames");
+ assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(300), 100.0, 100.0));
+ }
+
+ /// Monta un nodo `switch` (key=5) con la variante dada.
+ fn one_switch(variant: u64) -> Mounted<()> {
+ let v = View::<()>::new(Style::default())
+ .fill(rgba(10, 20, 30))
+ .animated_switch(5, variant, Duration::from_millis(200));
+ let mut layout = LayoutTree::new();
+ mount(&mut layout, v)
+ }
+
+ #[test]
+ fn switch_de_variante_cruza_contenido() {
+ let mut reg = AnimRegistry::new();
+ let t0 = Instant::now();
+ // Frame 1: variante 1, primera aparición → asienta, no cruza.
+ 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);
+ // 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);
+ let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10));
+ assert!(animating, "el cross-fade pide frames");
+ let a = m.nodes[0].alpha.expect("alpha de fade-in");
+ assert!(a < 0.3, "el contenido nuevo arranca casi transparente: {a}");
+ assert!(
+ reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0),
+ "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);
+ // 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));
+ assert!(!animating, "asentado tras la duración");
+ assert_eq!(m.nodes[0].alpha, None, "opaco exacto sin capa residual");
+ assert!(
+ !reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(400), 100.0, 100.0),
+ "fantasma agotado"
+ );
+ }
+
+ #[test]
+ fn switch_misma_variante_no_cruza() {
+ let mut reg = AnimRegistry::new();
+ 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);
+ // 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));
+ assert!(!animating, "sin cambio de variante no cruza");
+ assert_eq!(m.nodes[0].alpha, None, "el contenido sigue opaco");
+ assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(10), 100.0, 100.0));
+ }
+
+ #[test]
+ fn reaparecer_cancela_el_fantasma() {
+ let mut reg = AnimRegistry::new();
+ 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);
+ // Se va → fantasma.
+ let mut m = empty();
+ assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10)));
+ // Reaparece a mitad del fade → el fantasma se cancela (no hay doble).
+ let mut m = one_exit();
+ let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(100));
+ assert!(!animating, "al reaparecer no queda fantasma");
+ assert!(!reg.replay_ghosts(&mut Scene::new(), t0 + Duration::from_millis(100), 100.0, 100.0));
+ }
+
+ /// Monta un nodo con un transform afín explícito + anim(key=1).
+ fn one_xf(xf: Affine) -> Mounted<()> {
+ let v = View::<()>::new(Style::default())
+ .transform(xf)
+ .animated(1, Duration::from_millis(200));
+ let mut layout = LayoutTree::new();
+ mount(&mut layout, v)
+ }
+
+ /// Monta un nodo sin transform pero con anim_enter_from (scale 0.5 → 1.0).
+ fn one_pop_in() -> Mounted<()> {
+ let v = View::<()>::new(Style::default())
+ .fill(rgba(1, 2, 3))
+ .animated_enter_from(2, Duration::from_millis(200), Affine::scale(0.5));
+ let mut layout = LayoutTree::new();
+ mount(&mut layout, v)
+ }
+
+ #[test]
+ fn cambio_de_transform_interpola_y_pide_frames() {
+ let mut reg = AnimRegistry::new();
+ let t0 = Instant::now();
+ // Frame 1: identidad → se asienta sin animar.
+ let mut m = one_xf(Affine::IDENTITY);
+ assert!(!reg.reconcile(&mut m, t0), "primera aparición no anima");
+ // Frame 2: la view ahora pide scale(2.0) → arranca tween.
+ let mut m = one_xf(Affine::scale(2.0));
+ let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(50));
+ assert!(animating, "al cambiar la xf debe pedir frames");
+ // Frame 3: a mitad, el m00 está entre 1.0 y 2.0.
+ let mut m = one_xf(Affine::scale(2.0));
+ reg.reconcile(&mut m, t0 + Duration::from_millis(150));
+ let c = m.nodes[0].transform.expect("transform").as_coeffs();
+ assert!(c[0] > 1.0 && c[0] < 2.0, "m00 intermedio: {}", c[0]);
+ assert!(c[3] > 1.0 && c[3] < 2.0, "m11 intermedio: {}", c[3]);
+ }
+
+ #[test]
+ fn transform_al_terminar_llega_exacto() {
+ let mut reg = AnimRegistry::new();
+ let t0 = Instant::now();
+ let mut m = one_xf(Affine::IDENTITY);
+ reg.reconcile(&mut m, t0);
+ let mut m = one_xf(Affine::translate((10.0, 20.0)));
+ reg.reconcile(&mut m, t0 + Duration::from_millis(50));
+ // Pasada la duración: aterriza exacto en la xf objetivo.
+ let mut m = one_xf(Affine::translate((10.0, 20.0)));
+ let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
+ assert!(!animating);
+ let c = m.nodes[0].transform.expect("xf").as_coeffs();
+ assert!((c[4] - 10.0).abs() < 1e-9, "tx exacto: {}", c[4]);
+ assert!((c[5] - 20.0).abs() < 1e-9, "ty exacto: {}", c[5]);
+ }
+
+ #[test]
+ fn pop_in_arranca_desde_la_xf_inicial_y_aterriza_sin_xf() {
+ let mut reg = AnimRegistry::new();
+ let t0 = Instant::now();
+ // Frame 1: el nodo no declara `.transform` pero sí `enter_from`. La
+ // PRIMERA aparición arranca CON xf = scale(0.5) (lo que pide el caller)
+ // y debe pedir frames.
+ let mut m = one_pop_in();
+ let animating = reg.reconcile(&mut m, t0);
+ assert!(animating, "pop-in anima desde el primer frame");
+ let c = m.nodes[0].transform.expect("xf inicial").as_coeffs();
+ assert!((c[0] - 0.5).abs() < 1e-9, "arranca en scale 0.5: {}", c[0]);
+ // Frame intermedio: el m00 ya creció hacia 1.0.
+ let mut m = one_pop_in();
+ reg.reconcile(&mut m, t0 + Duration::from_millis(100));
+ let c = m.nodes[0].transform.expect("xf medio").as_coeffs();
+ assert!(c[0] > 0.5 && c[0] < 1.0, "scale intermedio: {}", c[0]);
+ // Frame final: aterriza en None (sin xf residual), igual que alpha.
+ let mut m = one_pop_in();
+ let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400));
+ assert!(!animating, "asentado");
+ assert_eq!(m.nodes[0].transform, None, "sin xf residual al asentarse");
+ }
+
+ #[test]
+ fn cambio_de_alpha_interpola() {
+ let mut reg = AnimRegistry::new();
+ let t0 = Instant::now();
+ // Frame 1: alpha 1.0, se asienta (no es `enter`).
+ let mut m = one_alpha(1.0);
+ let animating = reg.reconcile(&mut m, t0);
+ assert!(!animating, "primera aparición sin enter no anima");
+ // Frame 2: la view baja a 0.0 → arranca tween.
+ let mut m = one_alpha(0.0);
+ reg.reconcile(&mut m, t0 + Duration::from_millis(50));
+ // Frame 3: a mitad, alpha intermedio.
+ let mut m = one_alpha(0.0);
+ reg.reconcile(&mut m, t0 + Duration::from_millis(150));
+ let a = m.nodes[0].alpha.expect("alpha");
+ assert!(a > 0.0 && a < 1.0, "alpha intermedio: {a}");
+ }
+
+ #[test]
+ fn keys_que_se_van_se_descartan() {
+ let mut reg = AnimRegistry::new();
+ let now = Instant::now();
+ let mut m = one(rgba(1, 2, 3));
+ reg.reconcile(&mut m, now);
+ assert_eq!(reg.entries.len(), 1);
+ // Frame sin ningún nodo animado: la entrada se descarta.
+ let v = View::<()>::new(Style::default()).fill(rgba(9, 9, 9));
+ let mut layout = LayoutTree::new();
+ let mut m2 = mount(&mut layout, v);
+ reg.reconcile(&mut m2, now);
+ assert_eq!(reg.entries.len(), 0);
+ }
+
+ // ─── Bloque 15: tests de SizeAnim / animateContentSize ───
+
+ fn sized_view(key: u64, w: f32, h: f32, dur_ms: u64) -> View<()> {
+ use llimphi_layout::taffy::prelude::{length, Size};
+ let mut style = Style::default();
+ style.size = Size { width: length(w), height: length(h) };
+ View::<()>::new(style).animated_size(key, Duration::from_millis(dur_ms))
+ }
+
+ #[test]
+ fn size_anim_primera_aparicion_no_anima() {
+ let mut reg = SizeAnimRegistry::new();
+ let mut v = sized_view(1, 100.0, 80.0, 200);
+ let now = Instant::now();
+ let animating = reconcile_size_anim(&mut v, &mut reg, now);
+ assert!(!animating, "primera vez: sin animación");
+ // El style.size queda intacto (length(100, 80)).
+ let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
+ assert_eq!((w, h), (100.0, 80.0));
+ }
+
+ #[test]
+ fn size_anim_cambia_target_interpola() {
+ let mut reg = SizeAnimRegistry::new();
+ let t0 = Instant::now();
+ // Frame 1: target = 100×80, se asienta.
+ let mut v = sized_view(1, 100.0, 80.0, 200);
+ reconcile_size_anim(&mut v, &mut reg, t0);
+ // Frame 2: target nuevo = 200×160. En el frame que se detecta el
+ // cambio arranca el reloj — todavía pinta cerca del origen.
+ let mut v = sized_view(1, 200.0, 160.0, 200);
+ let animating = reconcile_size_anim(&mut v, &mut reg, t0);
+ assert!(animating, "cambio de target: pide frames");
+ let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
+ assert!(w < 200.0 && w >= 100.0, "ancho intermedio: {w}");
+ assert!(h < 160.0 && h >= 80.0, "alto intermedio: {h}");
+ // Frame 3: 100 ms (mitad del tween).
+ let mut v = sized_view(1, 200.0, 160.0, 200);
+ let animating = reconcile_size_anim(&mut v, &mut reg, t0 + Duration::from_millis(100));
+ assert!(animating, "a mitad del tween sigue animando");
+ let (w, h) = (v.style.size.width.value(), v.style.size.height.value());
+ assert!(w > 100.0 && w < 200.0, "ancho mitad-tween: {w}");
+ assert!(h > 80.0 && h < 160.0, "alto mitad-tween: {h}");
+ }
+
+ #[test]
+ fn size_anim_termina_y_se_detiene() {
+ let mut reg = SizeAnimRegistry::new();
+ let t0 = Instant::now();
+ let mut v = sized_view(1, 100.0, 80.0, 200);
+ reconcile_size_anim(&mut v, &mut reg, t0);
+ let mut v = sized_view(1, 200.0, 160.0, 200);
+ reconcile_size_anim(&mut v, &mut reg, t0); // arranca
+ // Pasada la duración: aterriza exacto en el objetivo y no pide más.
+ let mut v = sized_view(1, 200.0, 160.0, 200);
+ let animating = reconcile_size_anim(&mut v, &mut reg, t0 + Duration::from_millis(400));
+ assert!(!animating);
+ assert_eq!(
+ (v.style.size.width.value(), v.style.size.height.value()),
+ (200.0, 160.0),
+ );
+ }
+
+ #[test]
+ fn size_anim_no_animable_si_tamano_no_es_length() {
+ // Si el caller declara percent o auto, el reconciler lo deja pasar
+ // sin tracking — no hay valor en píxeles estable para interpolar.
+ use llimphi_layout::taffy::prelude::{percent, Dimension, Size};
+ let mut reg = SizeAnimRegistry::new();
+ let mut style = Style::default();
+ style.size = Size { width: percent(0.5), height: Dimension::auto() };
+ let mut v = View::<()>::new(style).animated_size(1, Duration::from_millis(200));
+ let animating = reconcile_size_anim(&mut v, &mut reg, Instant::now());
+ assert!(!animating);
+ // El size no se tocó: width sigue siendo percent (no LENGTH_TAG).
+ use llimphi_layout::taffy::CompactLength;
+ assert_ne!(v.style.size.width.tag(), CompactLength::LENGTH_TAG);
+ }
+
+ #[test]
+ fn size_anim_descarta_keys_no_vistas() {
+ let mut reg = SizeAnimRegistry::new();
+ let now = Instant::now();
+ let mut v = sized_view(42, 50.0, 50.0, 200);
+ reconcile_size_anim(&mut v, &mut reg, now);
+ assert_eq!(reg.entries.len(), 1);
+ // Frame sin animated_size: la entrada se descarta.
+ let mut v: View<()> = View::<()>::new(Style::default());
+ reconcile_size_anim(&mut v, &mut reg, now);
+ assert_eq!(reg.entries.len(), 0);
+ }
+}
diff --git a/llimphi-compositor/src/hero.rs b/llimphi-compositor/src/hero.rs
new file mode 100644
index 0000000..e37c29e
--- /dev/null
+++ b/llimphi-compositor/src/hero.rs
@@ -0,0 +1,273 @@
+//! **Hero / shared-element transitions** — un mismo nodo lógico (key estable)
+//! que aparece en posiciones distintas entre frames "vuela" del rect anterior
+//! al actual en vez de saltar. Es el Hero de Flutter auténtico.
+//!
+//! Modelo:
+//! - El caller marca un nodo con [`View::hero(key, duration)`](crate::View::hero).
+//! `key` enlaza "el mismo nodo lógico" entre dos `view()` distintos (entre
+//! rutas, paneles, layouts) — dos nodos con la misma `key` en frames distintos
+//! son la misma identidad para el runtime.
+//! - El runtime mantiene una instancia de [`HeroRegistry`] entre frames y llama
+//! [`HeroRegistry::reconcile`] DESPUÉS de `compute` y ANTES de `paint`. Por
+//! cada nodo hero:
+//! - Lee su rect absoluto del [`ComputedLayout`].
+//! - Si en el frame anterior la misma `key` vivió en un rect distinto,
+//! arranca un tween: durante `duration`, escribe en `node.transform` una
+//! afín que "lleva visualmente" el nodo del rect actual al rect anterior y
+//! converge a `IDENTITY`. El nodo se ve VOLAR del rect anterior al actual.
+//! - Mientras el tween esté vivo, devuelve `true` y el runtime pide otro
+//! frame (ticker autodetenido).
+//! - Al asentarse, deja `node.transform = None`: cero costo de transform
+//! residual en frames posteriores.
+//!
+//! No depende de [`crate::AnimRegistry`] — el wiring es independiente; sólo
+//! reusa el campo `transform` del [`MountedNode`](crate::MountedNode), que el
+//! `paint` ya respeta como cualquier otro afín.
+//!
+//! ## Reglas de uso
+//!
+//! - `key` debe ser estable y **única** entre los nodos hero presentes en un
+//! mismo frame. Dos hero con la misma key en el mismo árbol generan
+//! ambigüedad; el runtime se queda con la última que recorra.
+//! - El rect "anterior" es el del frame anterior — no funciona como
+//! shared-element entre dos *vistas montadas a la vez* (eso requeriría dos
+//! rect simultáneos por key). Funciona entre transiciones de rutas.
+
+use std::collections::HashMap;
+use std::time::{Duration, Instant};
+
+use llimphi_layout::{ComputedLayout, Rect};
+use vello::kurbo::Affine;
+
+/// Declara un nodo como **hero**: la `key` enlaza la identidad entre frames; si
+/// el rect cambia, el runtime anima la transición.
+#[derive(Clone, Copy, Debug)]
+pub struct Hero {
+ pub key: u64,
+ pub duration: Duration,
+ /// Easing aplicado a `t ∈ [0,1]`. Por defecto, los setters de [`View`]
+ /// usan un ease-out cúbico (igual que las animaciones implícitas).
+ pub easing: fn(f32) -> f32,
+}
+
+/// Registro de heroes, vivo entre frames. Guarda el último rect por `key` para
+/// detectar el delta y un tween activo si está animando.
+#[derive(Default)]
+pub struct HeroRegistry {
+ /// Último rect donde se pintó un nodo con esta `key`. Se actualiza en cada
+ /// `reconcile`. Es contra esto que detectamos el cambio que dispara el
+ /// tween.
+ last: HashMap,
+ /// Tweens en curso. Cada uno conoce su `from_rect`, el reloj y el easing.
+ /// Una key con tween activo NO arranca uno nuevo si vuelve a moverse —
+ /// reusamos el `from_rect` original para que la trayectoria sea continua
+ /// (si el target cambia a mitad, vuela hacia el nuevo destino, no cambia
+ /// el origen).
+ tweens: HashMap,
+}
+
+struct Tween {
+ from_rect: Rect,
+ start: Instant,
+ duration: Duration,
+ easing: fn(f32) -> f32,
+}
+
+impl HeroRegistry {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Reconcilia heroes con el árbol montado. Para cada nodo con [`Hero`]:
+ /// - Si el rect cambió respecto del frame anterior, arranca tween.
+ /// - Si hay tween activo y vivo, escribe `node.transform` con la afín
+ /// interpolada (cur → from).
+ /// - Cuando el tween termina, lo limpia y deja `node.transform = None`.
+ ///
+ /// Llamar DESPUÉS de `compute` y ANTES de `paint`. Devuelve `true` si
+ /// algún tween sigue en curso → el runtime pide otro frame.
+ pub fn reconcile(
+ &mut self,
+ mounted: &mut crate::Mounted,
+ computed: &ComputedLayout,
+ now: Instant,
+ ) -> bool {
+ let mut animating = false;
+ let mut seen: Vec = Vec::new();
+ for node in &mut mounted.nodes {
+ let Some(hero) = node.hero else { continue };
+ let Some(cur) = computed.get(node.id) else { continue };
+ seen.push(hero.key);
+
+ // ¿Cambió el rect respecto del último frame? Arrancar tween (si no
+ // hay uno activo; si lo hay, no re-resetamos el origen).
+ if let Some(last) = self.last.get(&hero.key).copied() {
+ if last != cur && !self.tweens.contains_key(&hero.key) {
+ self.tweens.insert(
+ hero.key,
+ Tween {
+ from_rect: last,
+ start: now,
+ duration: hero.duration,
+ easing: hero.easing,
+ },
+ );
+ }
+ }
+
+ // Aplicar tween si está vivo. Calcula la afín que mapea `cur` al
+ // `from_rect` y la interpola hacia identidad a medida que `t` crece.
+ if let Some(tw) = self.tweens.get(&hero.key) {
+ let elapsed = now.saturating_duration_since(tw.start).as_secs_f32();
+ let raw = (elapsed / tw.duration.as_secs_f32().max(1e-6)).clamp(0.0, 1.0);
+ if raw >= 1.0 {
+ // Aterrizó: dejamos el nodo sin transform y limpiamos.
+ node.transform = None;
+ self.tweens.remove(&hero.key);
+ } else {
+ let t = (tw.easing)(raw);
+ let back = back_transform(cur, tw.from_rect);
+ let xf = lerp_affine(back, Affine::IDENTITY, t);
+ node.transform = Some(xf);
+ animating = true;
+ }
+ }
+
+ self.last.insert(hero.key, cur);
+ }
+ // Las keys que no aparecieron este frame se descartan (un hero que se
+ // va deja de recordarse; si vuelve, su rect "anterior" será el nuevo
+ // primero — no anima desde el último que tuvo hace varios frames).
+ if self.last.len() != seen.len() {
+ self.last.retain(|k, _| seen.contains(k));
+ self.tweens.retain(|k, _| seen.contains(k));
+ }
+ animating
+ }
+}
+
+/// Afín local que, aplicada con [`View::transform`]'s convención (alrededor
+/// del centro del rect actual), mapea visualmente cada punto del `cur_rect`
+/// al punto correspondiente del `from_rect`. Es la base de un "fly":
+/// el nodo se pinta en `cur` pero con esta xf VOLVIÓ a `from` —
+/// interpolando hacia identidad, "vuela" de `from` a `cur`.
+fn back_transform(cur: Rect, from: Rect) -> Affine {
+ // El compositor aplica xf como `T(centro_cur) · xf_local · T(-centro_cur)`,
+ // así que xf_local debe ser `scale + translate` que mapea:
+ // esquina superior izquierda de cur → esquina superior izquierda de from.
+ //
+ // Si scale = (from.w/cur.w, from.h/cur.h) y t = (cx_from - cx_cur,
+ // cy_from - cy_cur), entonces `T(t) · S` cumple esa propiedad (despejo en
+ // los comentarios del módulo).
+ let sx = (from.w as f64) / (cur.w as f64).max(1e-6);
+ let sy = (from.h as f64) / (cur.h as f64).max(1e-6);
+ let cx_cur = (cur.x + cur.w * 0.5) as f64;
+ let cy_cur = (cur.y + cur.h * 0.5) as f64;
+ let cx_from = (from.x + from.w * 0.5) as f64;
+ let cy_from = (from.y + from.h * 0.5) as f64;
+ Affine::translate((cx_from - cx_cur, cy_from - cy_cur)) * Affine::scale_non_uniform(sx, sy)
+}
+
+/// Lerp componente-a-componente de las 6 coefs del afín. Idéntica al helper
+/// privado de [`crate::anim`] — vive separada para mantener el módulo `hero`
+/// auto-contenido (sin acoplar a Anim).
+fn lerp_affine(a: Affine, b: Affine, t: f32) -> Affine {
+ let p = a.as_coeffs();
+ let q = b.as_coeffs();
+ let ft = t as f64;
+ Affine::new([
+ p[0] + (q[0] - p[0]) * ft,
+ p[1] + (q[1] - p[1]) * ft,
+ p[2] + (q[2] - p[2]) * ft,
+ p[3] + (q[3] - p[3]) * ft,
+ p[4] + (q[4] - p[4]) * ft,
+ p[5] + (q[5] - p[5]) * ft,
+ ])
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{mount, View};
+ use llimphi_layout::{LayoutTree, Style};
+ use llimphi_layout::taffy::prelude::length;
+ use llimphi_layout::taffy::Size;
+
+ /// Monta un único nodo hero con su Style + key=1 + dur=200ms. Devuelve
+ /// `Mounted` y el `ComputedLayout` ya resuelto contra un viewport de
+ /// 1000×1000 — los rects salen del propio Style.
+ fn one(x: f32, y: f32, w: f32, h: f32) -> (crate::Mounted<()>, ComputedLayout) {
+ let v = View::<()>::new(Style {
+ size: Size { width: length(w), height: length(h) },
+ inset: llimphi_layout::taffy::Rect {
+ left: length(x),
+ top: length(y),
+ right: llimphi_layout::taffy::prelude::auto(),
+ bottom: llimphi_layout::taffy::prelude::auto(),
+ },
+ position: llimphi_layout::taffy::Position::Absolute,
+ ..Default::default()
+ })
+ .hero(1, Duration::from_millis(200));
+ let mut layout = LayoutTree::new();
+ let mounted = mount(&mut layout, v);
+ let computed = layout
+ .compute(mounted.root, (1000.0_f32, 1000.0_f32))
+ .expect("layout");
+ (mounted, computed)
+ }
+
+ #[test]
+ fn primera_aparicion_no_anima() {
+ let mut reg = HeroRegistry::new();
+ let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
+ let animating = reg.reconcile(&mut m, &c, Instant::now());
+ assert!(!animating, "primera aparición no debe animar");
+ assert!(m.nodes[0].transform.is_none(), "sin xf en primer frame");
+ }
+
+ #[test]
+ fn cambio_de_rect_arranca_tween_y_aplica_xf() {
+ let mut reg = HeroRegistry::new();
+ let t0 = Instant::now();
+ // Frame 1: rect (10, 10, 50, 50).
+ let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
+ reg.reconcile(&mut m, &c, t0);
+ // Frame 2: el nodo ahora vive en (200, 200, 100, 100) → arranca tween.
+ let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
+ let animating = reg.reconcile(&mut m, &c, t0 + Duration::from_millis(50));
+ assert!(animating, "cambio de rect → tween");
+ let xf = m.nodes[0].transform.expect("xf");
+ // A 50ms en una anim de 200ms, raw ≈ 0.25; con ease-out cúbico t > 0.25.
+ // La afín NO debe ser identidad (algún coef se ve).
+ let c = xf.as_coeffs();
+ assert!(c[0] != 1.0 || c[3] != 1.0 || c[4] != 0.0 || c[5] != 0.0,
+ "xf no debe ser identidad a mitad del tween: {:?}", c);
+ }
+
+ #[test]
+ fn al_terminar_limpia_la_xf() {
+ let mut reg = HeroRegistry::new();
+ let t0 = Instant::now();
+ let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
+ reg.reconcile(&mut m, &c, t0);
+ let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
+ reg.reconcile(&mut m, &c, t0 + Duration::from_millis(10));
+ // Pasada la duración: el tween se descarta y deja el nodo sin xf.
+ let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
+ let animating = reg.reconcile(&mut m, &c, t0 + Duration::from_millis(500));
+ assert!(!animating);
+ assert!(m.nodes[0].transform.is_none());
+ }
+
+ #[test]
+ fn back_transform_es_identidad_si_los_rects_coinciden() {
+ let r = Rect { x: 50.0, y: 50.0, w: 100.0, h: 100.0 };
+ let xf = back_transform(r, r);
+ let c = xf.as_coeffs();
+ assert!((c[0] - 1.0).abs() < 1e-9);
+ assert!((c[3] - 1.0).abs() < 1e-9);
+ assert!(c[4].abs() < 1e-9);
+ assert!(c[5].abs() < 1e-9);
+ }
+}
diff --git a/llimphi-compositor/src/layout_builder.rs b/llimphi-compositor/src/layout_builder.rs
new file mode 100644
index 0000000..09ce6dd
--- /dev/null
+++ b/llimphi-compositor/src/layout_builder.rs
@@ -0,0 +1,233 @@
+//! **LayoutBuilder** — el 4º seam de PARIDAD-FLUTTER: construir un subárbol
+//! sensible al **tamaño del slot** del nodo (no de la ventana — para eso
+//! alcanza `on_resize` + el Model). Flutter `LayoutBuilder`.
+//!
+//! El modelo de Llimphi corre `view → mount → compute → paint`: el `View` se
+//! arma ANTES de conocer el layout, así que "construir distinto según el espacio
+//! disponible" exige diferir. La solución, sin tocar `mount`/`paint`, es una
+//! **resolución en dos pasadas** orquestada por el runtime:
+//!
+//! 1. Montar el árbol tal cual ([`crate::View::layout_builder`] queda como
+//! **hoja** — no tiene `children` estáticos) y computar el layout. Ahora cada
+//! builder tiene su rect resuelto por su `Style`/contexto flex.
+//! 2. [`collect_builder_constraints`] lee esos rects (en pre-orden), se pide un
+//! `view()` fresco y [`expand_layout_builders`] invoca cada closure con sus
+//! [`crate::Constraints`] para producir el subárbol real. Ese árbol expandido
+//! se monta y pinta normalmente.
+//!
+//! [`has_layout_builder`] hace que todo esto sea **coste cero** cuando ningún
+//! nodo usa el builder (el caso de la abrumadora mayoría de frames): es un
+//! simple walk que corta el camino de dos pasadas.
+//!
+//! **Correspondencia de orden.** `collect_builder_constraints` recorre
+//! `Mounted::nodes` (pre-orden, padre antes que hijos — el orden en que `mount`
+//! los pushea) filtrando `is_layout_builder`; `expand_layout_builders` recorre
+//! el `View` fresco en el MISMO pre-orden asignando un índice por builder. Como
+//! ambos árboles salen del mismo `view(model)` determinista, el i-ésimo builder
+//! de uno corresponde al i-ésimo del otro — por eso alcanza con un `Vec`
+//! ordenado, sin keys.
+//!
+//! **Límite v1**: sin anidamiento. Un builder cuyo subárbol producido contiene
+//! otro `layout_builder` no resuelve el interno (no existía en la pasada 1):
+//! queda como hoja. El anidamiento requeriría iterar la resolución; se difiere.
+
+use crate::{Constraints, ComputedLayout, Mounted, View};
+
+/// `true` si `view` o algún descendiente declara un [`crate::View::layout_builder`].
+/// El runtime lo usa para decidir si vale la pena la resolución en dos pasadas;
+/// cuando es `false` (lo normal) el camino diferido se evita por completo.
+pub fn has_layout_builder(view: &View) -> bool {
+ view.layout_builder.is_some() || view.children.iter().any(has_layout_builder)
+}
+
+/// Lee las [`Constraints`] (tamaño del slot) de cada nodo `is_layout_builder`
+/// del árbol montado, en pre-orden. El runtime las pasa a
+/// [`expand_layout_builders`]. Un nodo sin rect computado (fuera del layout)
+/// cae a `0×0`.
+pub fn collect_builder_constraints(
+ mounted: &Mounted,
+ computed: &ComputedLayout,
+) -> Vec {
+ mounted
+ .nodes
+ .iter()
+ .filter(|n| n.is_layout_builder)
+ .map(|n| {
+ computed
+ .get(n.id)
+ .map(|r| Constraints { max_width: r.w, max_height: r.h })
+ .unwrap_or(Constraints { max_width: 0.0, max_height: 0.0 })
+ })
+ .collect()
+}
+
+/// Expande los `layout_builder` de `view` (pre-orden) usando `cons` — una
+/// [`Constraints`] por builder, en el orden que produjo
+/// [`collect_builder_constraints`]. Cada builder se reemplaza por un nodo
+/// contenedor (su mismo `Style`) cuyo único hijo es lo que devolvió la closure
+/// invocada con sus constraints. Builders sin constraint correspondiente (más
+/// builders que `cons`, p. ej. uno anidado recién producido) caen a `0×0` y se
+/// resuelven igual, pero su tamaño será nulo (límite v1: sin anidamiento).
+/// Consume `view`.
+pub fn expand_layout_builders(view: View