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.

Llimphi showreel — real widgets (switch, slider, progress, segmented control, buttons, radial) animating live, then reflowing across layouts @@ -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 (sand, cactus, carved rivers) orbiting — rendered by llimphi-3d +
+ 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, cons: &[Constraints]) -> View { + let mut idx = 0; + expand_rec(view, cons, &mut idx) +} + +fn expand_rec(mut view: View, cons: &[Constraints], idx: &mut usize) -> View { + if let Some(builder) = view.layout_builder.take() { + let c = cons + .get(*idx) + .copied() + .unwrap_or(Constraints { max_width: 0.0, max_height: 0.0 }); + *idx += 1; + // El builder posee los hijos: descartamos cualquier `children` estático + // y ponemos lo que produjo la closure. NO recursamos en el resultado + // (v1 sin anidamiento — un builder interno queda como hoja al montarse). + let child = builder(c); + view.children = vec![child]; + view + } else { + let children = std::mem::take(&mut view.children); + view.children = children + .into_iter() + .map(|c| expand_rec(c, cons, idx)) + .collect(); + view + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{mount, Constraints}; + use llimphi_layout::taffy::prelude::*; + use llimphi_layout::{LayoutTree, Style}; + + /// Árbol sin builders → `has_layout_builder` falso y expand es no-op. + #[test] + fn sin_builder_es_noop() { + let v = View::<()>::new(Style::default()) + .children(vec![View::<()>::new(Style::default())]); + assert!(!has_layout_builder(&v)); + let v = expand_layout_builders(v, &[]); + assert_eq!(v.children.len(), 1); + } + + #[test] + fn detecta_builder_anidado_en_hijos() { + let v = View::<()>::new(Style::default()).children(vec![ + View::<()>::new(Style::default()), + View::<()>::new(Style::default()).layout_builder(|_c| View::<()>::new(Style::default())), + ]); + assert!(has_layout_builder(&v)); + } + + /// El builder recibe las constraints y produce su subárbol; el nodo deja de + /// ser builder y queda como contenedor con el hijo producido. + #[test] + fn expand_invoca_closure_con_constraints() { + // Dos columnas a percent(0.5) del root 400px → cada slot = 200px. La de + // la izquierda es un builder que mete 1 hijo si es angosta (<300) o 2 si + // es ancha. A 200px mete 1. + let build_col = |c: Constraints| { + let n = if c.max_width < 300.0 { 1 } else { 2 }; + View::<()>::new(Style::default()) + .children((0..n).map(|_| View::<()>::new(Style::default())).collect()) + }; + let root = View::<()>::new(Style { + size: Size { width: length(400.0), height: length(100.0) }, + flex_direction: FlexDirection::Row, + ..Default::default() + }) + .children(vec![ + View::<()>::new(Style { + size: Size { width: percent(0.5), height: percent(1.0) }, + ..Default::default() + }) + .layout_builder(build_col), + View::<()>::new(Style { + size: Size { width: percent(0.5), height: percent(1.0) }, + ..Default::default() + }), + ]); + + // Pasada 1: montar (builder como hoja) y computar. + let mut l1 = LayoutTree::new(); + let m1 = mount(&mut l1, root); + let c1 = l1.compute(m1.root, (400.0, 100.0)).expect("layout"); + let cons = collect_builder_constraints(&m1, &c1); + assert_eq!(cons.len(), 1, "un solo builder"); + assert!((cons[0].max_width - 200.0).abs() < 1.0, "slot 200px: {:?}", cons[0]); + + // Pasada 2: árbol fresco (mismo Style) + expand. + let root2 = View::<()>::new(Style { + size: Size { width: length(400.0), height: length(100.0) }, + flex_direction: FlexDirection::Row, + ..Default::default() + }) + .children(vec![ + View::<()>::new(Style { + size: Size { width: percent(0.5), height: percent(1.0) }, + ..Default::default() + }) + .layout_builder(build_col), + View::<()>::new(Style { + size: Size { width: percent(0.5), height: percent(1.0) }, + ..Default::default() + }), + ]); + let expanded = expand_layout_builders(root2, &cons); + // El nodo builder (hijo 0 del root) ya no es builder y tiene 1 hijo + // producido (slot 200 < 300 → angosto → 1 columna). + let col_izq = &expanded.children[0]; + assert!(col_izq.layout_builder.is_none(), "ya expandido"); + assert_eq!(col_izq.children.len(), 1, "200px angosto → 1 hijo"); + } + + /// Con un slot ancho el mismo builder produce 2 hijos — verifica que la + /// rama de decisión depende de las constraints reales. + #[test] + fn slot_ancho_produce_mas_hijos() { + let build_col = |c: Constraints| { + let n = if c.max_width < 300.0 { 1 } else { 2 }; + View::<()>::new(Style::default()) + .children((0..n).map(|_| View::<()>::new(Style::default())).collect()) + }; + // Constraint inyectada directo: 500px → ancho. El builder devuelve UN + // contenedor (hijo único del nodo) con 2 columnas adentro. + let v = View::<()>::new(Style::default()).layout_builder(build_col); + let expanded = expand_layout_builders(v, &[Constraints { max_width: 500.0, max_height: 100.0 }]); + assert_eq!(expanded.children.len(), 1, "el builder produce 1 contenedor"); + assert_eq!(expanded.children[0].children.len(), 2, "ancho → 2 columnas"); + } + + /// Pre-orden: dos builders hermanos reciben sus constraints en orden. + #[test] + fn dos_builders_reciben_constraints_en_preorden() { + let mk = |w: f32| { + move |_c: Constraints| { + View::<()>::new(Style { + size: Size { width: length(w), height: length(10.0) }, + ..Default::default() + }) + } + }; + let root = View::<()>::new(Style::default()).children(vec![ + View::<()>::new(Style::default()).layout_builder(mk(1.0)), + View::<()>::new(Style::default()).layout_builder(mk(2.0)), + ]); + let cons = vec![ + Constraints { max_width: 111.0, max_height: 0.0 }, + Constraints { max_width: 222.0, max_height: 0.0 }, + ]; + let expanded = expand_layout_builders(root, &cons); + // Ambos expandidos, en orden (verificamos vía el ancho del hijo producido + // que NO depende de la constraint acá — sólo confirmamos que se invocaron + // los dos y que ninguno quedó como builder). + assert!(expanded.children[0].layout_builder.is_none()); + assert!(expanded.children[1].layout_builder.is_none()); + assert_eq!(expanded.children[0].children.len(), 1); + assert_eq!(expanded.children[1].children.len(), 1); + } +} diff --git a/llimphi-compositor/src/lib.rs b/llimphi-compositor/src/lib.rs index 7172d2c..10f350a 100644 --- a/llimphi-compositor/src/lib.rs +++ b/llimphi-compositor/src/lib.rs @@ -19,16 +19,31 @@ use std::sync::Arc; use llimphi_layout::taffy::NodeId; use llimphi_layout::{ComputedLayout, LayoutTree, Style}; -use vello::kurbo::{Affine, Point, Rect as KurboRect, RoundedRect}; -use vello::peniko::{Color, Fill, Image, Mix}; +use vello::kurbo::{ + Affine, Ellipse, Point, Rect as KurboRect, RoundedRect, RoundedRectRadii, Stroke, +}; +use vello::peniko::{BlendMode, Color, Fill, Gradient, ImageBrush as Image, Mix}; +mod anim; +mod hero; +mod layout_builder; mod render; +mod ripple; +mod semantics; mod view; +pub use anim::{ + ease_out_cubic, reconcile_size_anim, Anim, AnimRegistry, SizeAnim, SizeAnimRegistry, +}; +pub use hero::{Hero, HeroRegistry}; +pub use layout_builder::{collect_builder_constraints, expand_layout_builders, has_layout_builder}; pub use render::*; +pub use ripple::{Ripple, RippleRegistry}; +pub use semantics::{Role, SemanticsFlags, SemanticsSpec}; /// Texto a pintar dentro de un nodo. Alineación por defecto `Center` /// (horizontal y vertical), apta para labels de botón. Para layouts tipo /// editor o párrafo, usar `.text_aligned(...)` con `Alignment::Start`. +#[derive(Clone)] pub struct TextSpec { pub content: String, pub size_px: f32, @@ -36,6 +51,19 @@ pub struct TextSpec { pub alignment: llimphi_text::Alignment, /// `true` = forzar variante italic en la fuente activa. Default false. pub italic: bool, + /// Peso de fuente CSS: 400 = normal, 700 = bold. parley elige la + /// variante más cercana de la familia activa (o la sintetiza). Se usa + /// tanto al **medir** como al **pintar**, así medida y dibujo coinciden. + /// Default 400. + pub weight: f32, + /// Límite de líneas (CSS `-webkit-line-clamp` / Flutter `maxLines`). `None` + /// = sin límite (envuelve libre). Cuando el texto excede, se trunca: con + /// [`Self::ellipsis`] la última línea termina en `…`, sin él se corta seco. + /// Afecta medida (taffy reserva el alto de N líneas) y pintado. + pub max_lines: Option, + /// Si `true` y `max_lines` trunca, la última línea visible termina en `…`. + /// Sin efecto si `max_lines` es `None`. Default false. + pub ellipsis: bool, /// CSS-style font-family string (acepta lista con fallbacks). `None` /// = la fuente default de parley. pub font_family: Option, @@ -51,6 +79,46 @@ pub struct TextSpec { /// `Typesetter::layout_runs` + `draw_layout_runs`, y `color` actúa como /// color por defecto de lo no cubierto por ningún run. pub runs: Option>, + /// Subrayado activo. El runtime pinta la línea bajo la línea base usando + /// las métricas (`underline_offset`, `underline_size`) que parley deriva + /// de la fuente — así un texto a 12pt y otro a 24pt tienen un subrayado + /// proporcional sin que el caller calcule nada. + pub underline: bool, + /// Tachado activo. Mismo régimen que [`Self::underline`] pero sobre el + /// strikethrough metric — útil para listas to-do, items removidos en un + /// diff, precios viejos. + pub strikethrough: bool, + /// **Spans inline mixtos** (RichText): overrides de + /// tamaño/peso/italic/familia/color/underline/strikethrough por rango + /// de bytes (parley convention). `None` = texto uniforme (camino + /// `layout_clamped`); `Some([])` se trata como `None`. Cuando hay + /// spans, el runtime usa `Typesetter::layout_spans` (Layout + /// con `max_width`/wrap) + `draw_layout_runs_xf`; los campos del + /// `TextSpec` son **defaults a nivel bloque** que cada span puede + /// sobreescribir. Tier 2 final de PARIDAD-FLUTTER (Bloque 13). + pub spans: Option>, + /// `letter-spacing`: px **extra** entre letras (CSS). 0 = normal. Afecta + /// shaping y medida. Sólo el camino uniforme (`layout_clamped`); el camino + /// de spans (RichText) lo ignora en v1. + pub letter_spacing: f32, + /// `word-spacing`: px **extra** entre palabras (CSS). 0 = normal. Mismo + /// régimen que [`Self::letter_spacing`]. + pub word_spacing: f32, + /// `white-space: nowrap`/`pre`: si `true`, el texto **no envuelve** — + /// se shapea en una sola línea (`break_all_lines(None)`) sin importar el + /// ancho disponible, y desborda la caja (lo recorta `overflow: hidden` si + /// lo hay). Afecta medida (taffy reserva el ancho de la línea completa) y + /// pintado. Default false (wrap libre, comportamiento previo). Sólo el + /// camino uniforme (`layout_clamped`); el de spans (RichText) lo ignora en + /// v1, igual que el clamp. + pub no_wrap: bool, + /// `overflow-wrap: break-word`/`anywhere` (o `word-break: break-all`): si + /// `true`, una palabra más ancha que la caja se **parte** para que entre, + /// en vez de desbordar. Afecta medida (taffy puede reservar menos ancho) y + /// pintado. Default false (la palabra larga desborda — comportamiento + /// previo). Sólo el camino uniforme (`layout_clamped`); el de spans + /// (RichText) lo ignora en v1, igual que `no_wrap`/clamp. + pub overflow_wrap: bool, } /// Fase de un drag activo. `Move` se emite por cada `CursorMoved` con el @@ -100,6 +168,78 @@ pub type ScrollFn = Arc Option + Send + Sync>; /// Recibe `(phase, dx, dy, initial_lx, initial_ly)`. pub type DragAtFn = Arc Option + Send + Sync>; +/// Variante de [`DragFn`] que recibe la **velocidad del drag al soltarlo** +/// (`vx`, `vy` en px/s). El runtime mide el desplazamiento sobre los +/// últimos ~100 ms de movimiento (ventana móvil de hasta ocho samples) +/// y la pasa en `DragPhase::End`. Durante `DragPhase::Move` ambas son +/// `0.0` — la velocidad sólo es significativa al final. Permite +/// **fling-desde-drag**: el caller arranca un ticker con esa velocidad y +/// la decae con [`fling_step`](https://docs.rs/) hasta asentar. Reemplaza +/// la estimación manual que antes tenía que llevar el caller con +/// `Instant::now()` por su cuenta. +pub type DragVelocityFn = + Arc Option + Send + Sync>; + +/// Fase de un **gesto continuo** (pinch-to-zoom de momento; rotación a futuro). +/// El runtime emite `Begin` al iniciar el gesto, `Update` por cada cambio +/// incremental y `End` al terminar. El camino de Ctrl+rueda (universal, sin +/// trackpad) emite un único `Update` por click de rueda — no hay un "inicio" +/// ni "fin" naturales, así que el handler debe tolerar `Update`s sueltos sin +/// `Begin` previo (es lo común en desktop). El camino de trackpad +/// (`PinchGesture`, sólo macOS/iOS) sí entrega `Begin`/`Update*`/`End`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GesturePhase { + Begin, + Update, + End, +} + +/// Handler de gesto de **escala** (pinch-to-zoom). Recibe `(phase, factor, +/// focal_x, focal_y)`: +/// - `factor`: cambio de escala **incremental y multiplicativo** desde el +/// evento anterior — `1.0` = sin cambio, `>1.0` agranda (zoom in), `<1.0` +/// achica (zoom out). El caller acumula con `mi_zoom *= factor` y, si +/// quiere, lo clampa a su rango. En `Begin`/`End` el factor es `1.0`. +/// - `focal_x`/`focal_y`: punto focal del gesto **relativo a la esquina +/// superior-izquierda del rect del nodo** (mismo espacio que los handlers +/// `*_at`). Es el punto que debe quedar fijo bajo el cursor al hacer zoom — +/// el caller lo usa para zoomear "hacia el cursor" en vez de hacia el +/// centro. En Ctrl+rueda es la posición del cursor; en trackpad, idem. +/// +/// Devolver `Some(Msg)` dispara una transición; `None` ignora el evento. El +/// runtime lo resuelve con [`hit_test_scale`]: el nodo más al frente bajo el +/// cursor que declare un `on_scale` consume el gesto. Es la base del zoom de +/// los canvases (pineal/cosmos/nakui). +pub type ScaleFn = Arc Option + Send + Sync>; + +/// Handler de gesto de **rotación** (trackpad, sólo macOS — winit no emite +/// `RotationGesture` en Wayland/Windows). Análogo a [`ScaleFn`] pero el +/// segundo argumento es el **delta de ángulo incremental en radianes** +/// (positivo = horario) en lugar del factor de escala; `(focal_x, focal_y)` +/// es el punto bajo el cursor relativo al rect del nodo. El nodo más al +/// frente bajo el cursor que declare un `on_rotate` consume el gesto. Base +/// para rotar canvases/imágenes con dos dedos. Ver [`View::on_rotate`]. +pub type RotateFn = Arc Option + Send + Sync>; + +/// Restricciones de tamaño que un [`LayoutBuilderFn`] recibe: las dimensiones +/// del slot que el layout le asignó al nodo (en px físicos). Análogo a las +/// `BoxConstraints` de Flutter `LayoutBuilder` / al `MediaQuery` pero **local +/// al nodo** (no a la ventana). El builder construye su subárbol en función de +/// esto — p. ej. una columna si `max_width < 600`, dos si es ancho. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Constraints { + pub max_width: f32, + pub max_height: f32, +} + +/// Constructor **diferido** de subárbol sensible al tamaño (Flutter +/// `LayoutBuilder`). El runtime resuelve el tamaño del slot del nodo en una +/// primera pasada de layout y luego invoca esta closure con esas +/// [`Constraints`] para producir los hijos — así "construir distinto según el +/// espacio disponible" deja de exigir conocer el tamaño al armar el `View`. Ver +/// [`View::layout_builder`]. +pub type LayoutBuilderFn = Arc View + Send + Sync>; + /// Rect absoluto del nodo (en coordenadas físicas del frame). Lo /// recibe el callback de [`View::paint_with`] para que pueda /// posicionar sus primitivas custom dentro del nodo. @@ -163,6 +303,133 @@ pub type GpuPaintFn = Arc< + Sync, >; +/// Callback de pintura vello "over": idéntico en firma a [`PaintFn`] +/// `(&mut Scene, &mut Typesetter, PaintRect)`, pero el runtime lo invoca +/// en una pasada vello FINAL, **después** de todos los `gpu_painter` del +/// frame. Sus primitivas se rasterizan sobre fondo transparente y se +/// componen con alpha encima de la intermedia (que ya tiene +/// vello-base + GPU directo). Resuelve el z-order inverso al de +/// [`GpuPaintFn`]: permite pintar texto/sprites AA por vello **encima** +/// de celdas instanciadas por GPU (dominium grid, futuro motor voxel). +/// +/// Orden total del frame: `[vello base] → [gpu_painter] → [over_painter] +/// → [overlay/menús]`. Los menús (`view_overlay`) siguen quedando por +/// encima del over-layer. Ver [`View::paint_over`]. Es un alias de +/// [`PaintFn`]; existe sólo para documentar la semántica temporal. +pub type OverPaintFn = PaintFn; + +/// Sombra proyectada detrás del rect del nodo (drop shadow), rasterizada +/// con el `draw_blurred_rounded_rect` nativo de vello. Se pinta **antes** +/// del relleno, así el fill (si es opaco) tapa la parte solapada y la +/// sombra sólo asoma por el desenfoque + el offset. El radio sigue al del +/// nodo (más `spread`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Shadow { + pub color: Color, + /// Desviación estándar del gaussiano (qué tan difusa). En px. + pub blur: f64, + /// Desplazamiento de la sombra respecto del nodo. + pub dx: f64, + pub dy: f64, + /// Cuánto crece (px) el rect de la sombra respecto del nodo. + pub spread: f64, +} + +impl Shadow { + /// Sombra con color + blur explícitos, sin offset ni spread. + pub fn new(color: Color, blur: f64) -> Self { + Self { color, blur, dx: 0.0, dy: 0.0, spread: 0.0 } + } + + /// Elevación suave y tasteful: negro translúcido, leve caída hacia + /// abajo. El default razonable para cards/menús/modales. + pub fn soft(alpha: u8, blur: f64) -> Self { + Self { + color: Color::from_rgba8(0, 0, 0, alpha), + blur, + dx: 0.0, + dy: blur * 0.4, + spread: 0.0, + } + } + + pub fn offset(mut self, dx: f64, dy: f64) -> Self { + self.dx = dx; + self.dy = dy; + self + } + + pub fn spread(mut self, spread: f64) -> Self { + self.spread = spread; + self + } +} + +/// Borde (stroke) pintado sobre el contorno redondeado del nodo, **inset** +/// hacia adentro media línea para que el grosor quede dentro del rect +/// (convención CSS `box-sizing: border-box`). Se pinta después del relleno. +#[derive(Clone, Copy, Debug)] +pub struct Border { + pub width: f64, + pub color: Color, +} + +impl Border { + pub fn new(width: f64, color: Color) -> Self { + Self { width, color } + } +} + +/// Una operación de filtro CSS (`filter: blur()/brightness()/…`) aplicada al +/// **propio subárbol** del nodo. A diferencia de `backdrop_blur` (que afecta lo +/// pintado *debajo*), un `FilterOp` modifica el contenido del nodo. El runtime +/// los aplica como post-pasada GPU sobre la intermediate, restringidos al rect +/// del nodo, en el orden de la lista. La lista crece por fase (CSS Filter +/// Effects 1): `Blur` (7.1232) + `ColorMatrix` (7.1233). Fase 7.1232. +#[derive(Clone, Debug, PartialEq)] +pub enum FilterOp { + /// `filter: blur()`. `px` es la desviación estándar del Gauss (igual + /// convención que CSS). Se aplica con `BlurCompositor`, el mismo camino que + /// `backdrop_blur`. + Blur(f32), + /// Filtros de color (`brightness`/`contrast`/`grayscale`/`sepia`/`saturate`/ + /// `invert`/`hue-rotate`/`opacity`) colapsados a una **matriz de color 4×5** + /// row-major: por fila `[c0, c1, c2, c3, bias]`, salida R/G/B/A + /// (`out = M·rgba + bias`). Se aplica con `ColorFilterCompositor`. Fase + /// 7.1233. + ColorMatrix([f32; 20]), + /// `filter: drop-shadow( [blur] [color])`. Se pinta como una sombra + /// Gaussiana del **border-box** detrás del nodo (con `draw_blurred_rounded_rect`, + /// igual primitiva que `Shadow`/box-shadow). v1: sombra del rect, no de la + /// silueta alpha del subárbol. A diferencia de `Blur`/`ColorMatrix`, NO es + /// post-pasada GPU — se pinta en vello antes del relleno, por lo que + /// `collect_filters` la ignora. Fase 7.1234. + DropShadow(Shadow), +} + +/// Punto de pivote de `transform` (CSS `transform-origin`). Cada eje se resuelve +/// contra el rect del nodo como `px + frac · tamaño`: `px` (ya escalado por zoom +/// por el caller) cubre offsets absolutos y `frac` los porcentuales (`0.5` = 50% +/// del ancho/alto). El default CSS `50% 50%` (centro) es +/// `{ px: (0.0, 0.0), frac: (0.5, 0.5) }`; un nodo con `transform_origin: None` +/// usa ese centro. Modela `px + %` por eje igual que `transform_rel` modela el +/// `translate(<%>)` — necesario porque el % depende del layout, desconocido hasta +/// `paint`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TransformPivot { + /// Offset absoluto en px (ya × zoom) por eje `(x, y)`. + pub px: (f64, f64), + /// Fracción del tamaño del rect por eje `(x, y)` (`0.5` = 50%). + pub frac: (f64, f64), +} + +impl Default for TransformPivot { + fn default() -> Self { + // CSS `transform-origin: 50% 50%` — centro del rect. + Self { px: (0.0, 0.0), frac: (0.5, 0.5) } + } +} + /// Nodo de la vista declarativa. Estilo de layout (taffy) + relleno opcional /// (vello) + texto opcional (skrifa+vello) + Msg al click opcional + hijos. pub struct View { @@ -172,12 +439,53 @@ pub struct View { /// = no se reacciona al hover. pub hover_fill: Option, pub radius: f64, + /// Radio **por esquina** (top-left, top-right, bottom-right, bottom-left), + /// que sobreescribe a `radius` cuando está presente. Permite cards con + /// sólo las esquinas de arriba redondeadas, pestañas, bocadillos de chat, + /// etc. (CSS `border-radius` con 4 valores). `None` = usar el `radius` + /// uniforme. Ver [`View::radius_corners`]. La **sombra** sigue usando un + /// radio escalar (el blur nativo de vello no acepta radios por esquina); + /// el **borde** sí respeta las cuatro esquinas. + pub corner_radii: Option, + /// Sombra proyectada detrás del nodo (drop shadow). `None` = sin sombra + /// (la mayoría de nodos). Ver [`Shadow`]. + pub shadow: Option, + /// Relleno con **gradiente**, autoreado en el cuadrado unidad `[0,1]²` y + /// mapeado al rect del nodo. Gana sobre `fill` como base; `hover_fill` + /// (un color) lo sigue overrideando en hover. Ver [`View::fill_gradient`]. + pub fill_gradient: Option, + /// Borde (stroke) sobre el contorno redondeado. Ver [`Border`]. + pub border: Option, pub text: Option, /// Imagen a pintar dentro del rect del nodo. Se centra y escala - /// preservando aspect ratio (`min(rect.w/img.w, rect.h/img.h)`). - /// El alfa por píxel de la imagen y el `Image::alpha` global se - /// respetan; el `fill` (si lo hay) se pinta debajo como background. + /// según [`Self::image_fit`] (default `Contain` = preservar + /// aspect ratio cabiendo). El alfa por píxel de la imagen y el + /// `Image::alpha` global se respetan; el `fill` (si lo hay) se + /// pinta debajo como background. El clip al `node_rrect` respeta + /// `radius`/`corner_radii`, así avatares y cards con esquinas + /// redondeadas funcionan sin envolver en un padre `clip(true)`. pub image: Option, + /// Política de encaje de [`Self::image`] en el rect del nodo + /// (CSS `object-fit`). `None` = `Contain` (el default histórico). + /// Ver [`ImageFit`] y [`View::image_fit`]. + pub image_fit: Option, + /// **Máscara de luminancia** (CSS `mask-image`). Si está presente, el + /// runtime aísla el subárbol del nodo en una capa y luego lo enmascara con + /// la luminancia de esta imagen (`push_luminance_mask_layer` de vello): + /// blanco = visible, negro = oculto, gris = semitransparente. El encaje lo + /// fija [`Self::mask_placement`] (size/position/repeat); sin él la imagen se + /// estira al border-box. `None` = sin máscara. Ver [`View::mask_image`]. + pub mask_image: Option, + /// Encaje de [`Self::mask_image`] (CSS `mask-size`/`-position`/`-repeat`). + /// `None` = estirar al border-box (Fase 7.1226). Sólo se consulta si + /// `mask_image` está presente. Ver [`MaskPlacement`] y + /// [`View::mask_placement`]. Fase 7.1227. + pub mask_placement: Option, + /// Capas de máscara ADICIONALES (`mask-image: url(a), url(b), …`): cada una + /// es `(imagen, operador)`. Comparten [`Self::mask_placement`] con la capa 0 + /// ([`Self::mask_image`]); se combinan con ella según el operador. Vacío = + /// una sola capa. Ver [`View::mask_extra`]. Fase 7.1231. + pub mask_extra: Vec<(Image, MaskCompose)>, /// Callback de pintura custom. Si está presente, el runtime lo /// invoca durante el paint del nodo con el `Scene` vivo + el rect /// absoluto. Pensado para "canvas elements" (dominium, pluma, @@ -188,6 +496,13 @@ pub struct View { /// frame; comparte tree y orden DFS con los demás. Ver /// [`GpuPaintFn`]. pub gpu_painter: Option, + /// Pintor vello "over": closure que pinta DESPUÉS del pase GPU del + /// frame, sobre una escena vello que el runtime compone con alpha + /// encima de la intermedia. Sirve para sprites/texto AA encima de + /// celdas instanciadas por GPU. Ver [`View::paint_over`] y + /// [`OverPaintFn`]. Misma firma que [`PaintFn`] — sólo cambia + /// *cuándo* corre (post-GPU). `None` = sin over-layer (coste cero). + pub over_painter: Option, pub on_click: Option, /// Handler de click que recibe la posición **relativa al rect del /// nodo** (esquina superior-izquierda del nodo = `(0, 0)`). Útil @@ -215,6 +530,12 @@ pub struct View { /// Variante de drag que recibe la posición inicial del press relativa /// al rect del nodo. Gana sobre `drag` si ambos están presentes. pub drag_at: Option>, + /// Variante de drag que recibe la **velocidad** al soltar (`vx`, `vy` + /// en px/s) además del delta puntual. Gana sobre `drag`/`drag_at` + /// cuando está presente — un nodo elige un único sabor de drag. Habilita + /// fling-desde-drag (el caller arranca un ticker con esa velocidad y la + /// decae con [`fling_step`]). + pub drag_velocity: Option>, /// Payload `u64` que viaja con el drag iniciado sobre este nodo. Lo /// recibe el handler [`Self::on_drop`] del drop target. Sin payload, /// el drag funciona igual pero ningún drop target reacciona. @@ -228,23 +549,104 @@ pub struct View { /// `scene.push_layer` con `Mix::Clip`). El hit-test también respeta /// el recorte: clicks fuera del rect ignoran a los hijos. pub clip: bool, + /// Si `Some([top, right, bottom, left])`, recorta los descendientes a un + /// rect ENCOGIDO por esos insets (px) desde el rect del nodo — modela + /// `clip-path: inset(...)`. Implica clip aunque `clip == false`. + pub clip_inset: Option<[f32; 4]>, + /// Si `Some(spec)` (14 floats), recorta los descendientes a una ELIPSE — + /// modela `clip-path: circle()`/`ellipse()`. El centro (4) se resuelve + /// contra el rect: `cx = cx_px + cx_pct/100·w`, `cy = cy_px + + /// cy_pct/100·h`. Cada radio (5: `[px, pct_w, pct_h, pct_diag, side]`) con + /// `side == 0` suma `px + pct_w/100·w + pct_h/100·h + pct_diag/100·diag` + /// (`diag = √(w²+h²)/√2`); con `side != 0` se computa desde la distancia + /// del centro a los bordes (`1`/`2` = closest/farthest sobre los 4 lados; + /// `3`/`4` = ídem sobre el eje del radio). Layout: `[cx×2, cy×2, rx×5, + /// ry×5]`. Implica clip aunque `clip == false`. Si conviven `clip_inset` y + /// `clip_ellipse`, gana la elipse (una sola capa de recorte por nodo). + pub clip_ellipse: Option<[f32; 14]>, + /// Si `Some((evenodd, puntos))`, recorta los descendientes a un POLÍGONO — + /// modela `clip-path: polygon()`. Cada punto `[x_px, x_pct, y_px, y_pct]` + /// resuelve `(x_px + x_pct/100·w, y_px + y_pct/100·h)` contra el rect. + /// `evenodd` elige la regla de relleno. Implica clip aunque `clip == + /// false`. Prioridad de recorte por nodo: polygon > elipse > inset > rect. + pub clip_polygon: Option<(bool, Vec<[f32; 4]>)>, + /// Si `Some((evenodd, d))`, recorta los descendientes a un PATH SVG — + /// modela `clip-path: path()`. `d` es el string SVG crudo (user units px, + /// relativos al origen del rect); el pintado lo parsea con + /// `BezPath::from_svg` y lo traslada al origen del nodo. Si el parseo + /// falla, no recorta. Implica clip aunque `clip == false`. Prioridad: + /// path > polygon > elipse > inset > rect. + pub clip_path_svg: Option<(bool, String)>, + /// Si `Some([t,r,b,l])`, el clip-path se resuelve contra una caja de + /// referencia (``) que es el rect del nodo ENCOGIDO por esos + /// insets px (padding-box = border; content-box = border+padding). El + /// pintado lo aplica ANTES de resolver la forma; sin forma, recorta a ese + /// rect. `None` = referencia = border-box (rect completo). Fase 7.1225. + pub clip_ref_inset: Option<[f32; 4]>, /// Msg a emitir cuando el cursor entra al rect del nodo (transición /// no-hover → hover). Útil para previews tipo "URL del link al /// pasar el mouse". pub on_pointer_enter: Option, /// Msg a emitir cuando el cursor sale del rect del nodo. pub on_pointer_leave: Option, + /// Handler de **movimiento del cursor** sobre el nodo: recibe `(local_x, + /// local_y, rect_w, rect_h)` en CADA `CursorMoved` mientras el cursor está + /// encima (no sólo en la transición de entrada, a diferencia de + /// [`Self::on_pointer_enter`]). Análogo posicional de hover, base de cosas + /// como el thumbnail que sigue al cursor sobre un timeline o un drawer que + /// reacciona a la posición. `None` no dispara update. + pub on_pointer_move_at: Option>, /// Handler de rueda local. Si está presente y el cursor cae sobre este /// nodo, el runtime lo invoca antes del `App::on_wheel` global; un /// `Some(Msg)` consume el evento. Base de las áreas de scroll /// autocontenidas. Ver [`ScrollFn`]. pub on_scroll: Option>, + /// Handler de gesto de **escala** (pinch-to-zoom). Si está presente y el + /// gesto cae sobre este nodo (Ctrl+rueda en desktop, pinch de trackpad en + /// macOS), el runtime lo invoca con el factor incremental + el punto focal + /// local. Base del zoom de canvases. Ver [`ScaleFn`] y [`View::on_scale`]. + pub on_scale: Option>, + /// Handler de gesto de **rotación** (dos dedos en trackpad, macOS). Si + /// está presente y el gesto cae sobre este nodo, el runtime lo invoca con + /// el delta de ángulo incremental (radianes) + el punto focal local. Ver + /// [`RotateFn`] y [`View::on_rotate`]. + pub on_rotate: Option>, + /// Msg a emitir en **doble-tap** (dos presses izquierdos sobre este nodo + /// dentro de una ventana temporal corta y muy cerca). Es un evento + /// **aditivo**: si el nodo también tiene `on_click`, éste igual dispara en + /// cada press; el doble-tap llega además en el segundo. Para doble-tap + /// exclusivo, poné el handler en un nodo sin `on_click`. Ver + /// [`View::on_double_tap`]. + pub on_double_tap: Option, + /// Variante posicional de [`Self::on_double_tap`]: recibe la posición del + /// segundo tap relativa al rect del nodo (para zoom-to-point, etc.). Gana + /// sobre `on_double_tap` si ambos están. + pub on_double_tap_at: Option>, + /// Msg a emitir en **long-press** (mantener el botón izquierdo sobre este + /// nodo ~500 ms sin moverse ni soltar). El runtime lo arbitra por tiempo: + /// si el cursor se aleja (pasó a drag/scroll) o se suelta antes, se + /// cancela. Evento **aditivo** (ver [`Self::on_double_tap`]); el caso + /// limpio es un nodo con drag-to-pan + long-press y sin `on_click` (un + /// canvas). Útil para menús contextuales táctiles / selección. Ver + /// [`View::on_long_press`]. + pub on_long_press: Option, + /// Variante posicional de [`Self::on_long_press`]: recibe la posición del + /// press relativa al rect del nodo (para abrir el menú en el punto). Gana + /// sobre `on_long_press` si ambos están. + pub on_long_press_at: Option>, /// Marca este nodo como **enfocable** con el id opaco `u64`. El runtime /// mantiene el foco (uno por ventana) y lo mueve con Tab/Shift+Tab en /// orden de árbol (pre-orden) y al clickear un nodo enfocable; notifica /// a la app vía `App::on_focus` para que pinte el ring y rutee el /// teclado. El id lo elige el caller (índice de campo, hash, etc.). pub focusable: Option, + /// Marca este nodo de **texto** como seleccionable con el mouse fuera del + /// editor (arrastrar resalta, Ctrl/Cmd+C copia). El `u64` es una **key + /// estable** entre rebuilds del `View` (los `NodeId` de taffy cambian cada + /// frame, así que la selección retenida en el runtime se ancla a esta key, + /// igual que `animated`). Sólo tiene efecto en nodos con `text` uniforme + /// (no `runs`/`spans`). Ver [`View::selectable`]. + pub text_select_key: Option, /// Opacidad multiplicada sobre TODO el subtree (este nodo + hijos), /// en `[0.0, 1.0]`. Se realiza con `scene.push_layer(Mix::Normal, a, …)` /// alrededor del rect del nodo: el subárbol se rasteriza en una capa @@ -255,6 +657,35 @@ pub struct View { /// composición tiene costo (allocate + blit), por lo que sólo /// poblar este slot cuando hace falta — no es un atributo gratis. pub alpha: Option, + /// Animación **implícita** de las props de paint (fill/radius): cuando el + /// valor cambia entre frames, el runtime interpola en vez de saltar. `None` + /// = sin animación (la abrumadora mayoría). La `key` debe ser estable entre + /// rebuilds. Ver [`Anim`] y [`View::animated`]. Lo consume el runtime vía + /// [`AnimRegistry::reconcile`] (DESPUÉS de layout, ANTES de paint). + pub anim: Option, + /// **Animación implícita de tamaño** (Flutter `AnimatedSize` / + /// Compose `animateContentSize()`). `None` = sin animación. La key + /// debe ser estable entre rebuilds. A diferencia de [`Self::anim`] + /// (props de paint, reconcilia DESPUÉS de layout), el tamaño tiene + /// que estar firme **antes** del layout — siblings/hijos dependen + /// del rect del nodo. El runtime llama + /// [`reconcile_size_anim`] sobre el `View` tree **antes** de + /// `mount` y parcha `style.size` con el valor interpolado. Sólo se + /// activa si ambos `style.size.width` y `style.size.height` son + /// `Dimension::Length(_)`. Ver [`SizeAnim`] y [`View::animated_size`]. + pub animated_size: Option, + /// **Semántica accesible** del nodo (rol, label, value, flags ARIA). El + /// runtime la traduce a un árbol AccessKit por frame para alimentar + /// lectores de pantalla (NVDA/VoiceOver/Orca/TalkBack). `None` = no + /// declarada (el lector lee el texto plano si lo hay, sin rol específico). + /// Ver [`SemanticsSpec`]. + pub semantics: Option, + /// **Hero shared-element**: marca este nodo como una identidad estable + /// entre frames. Si la misma `key` aparece en otra posición en un frame + /// siguiente, el runtime interpola `transform` para "volar" del rect + /// anterior al actual durante la `duration` declarada. Ver + /// [`Hero`] y [`HeroRegistry`]. `None` = sin hero (la abrumadora mayoría). + pub hero: Option, /// Transformación afín 2D aplicada a este nodo y todo su subtree /// **alrededor del centro de su propio rect** (convención CSS /// `transform-origin: 50% 50%`). El runtime resuelve el centro en @@ -269,15 +700,264 @@ pub struct View { /// el afín, y la posición local que reciben los handlers `*_at` se /// reporta en espacio de pantalla, no en el espacio local del nodo. pub transform: Option, + /// Traslación RELATIVA al tamaño del propio nodo, en fracciones de su rect + /// computado: `(fx, fy)` ⇒ desplaza `(fx · w, fy · h)` px. Se resuelve en + /// `paint`/`hit_test` (única instancia donde se conoce el tamaño usado) y + /// se compone como el factor más externo del afín del nodo, ANTES del + /// centrado por `transform-origin`. Pensado para el `translate(<%>)` de CSS + /// (p. ej. el truco de centrado `translate(-50%, -50%)` ⇒ `(-0.5, -0.5)`), + /// que no es expresable como `Affine` fijo porque el % depende del layout. + /// `None` = sin traslación relativa (la abrumadora mayoría). Compone con + /// `transform` (afín fijo) si ambos están: `T_rel · transform`. + pub transform_rel: Option<(f64, f64)>, + /// Punto de pivote de `transform` (CSS `transform-origin`). `None` ⇒ el + /// default CSS `50% 50%` (centro del rect) — el caso mayoritario. Ver + /// [`TransformPivot`] y [`View::transform_origin`]. + pub transform_origin: Option, /// Texto de **tooltip**: si está, el runtime/cliente puede mostrar un /// rótulo flotante cuando el cursor se posa sobre este nodo. Llimphi sólo /// transporta el dato hasta el [`MountedNode`]; *quién* lo pinta (un overlay /// del runtime, una surface popup del cliente) lo decide el consumidor. El /// hit-test de hover ya localiza el nodo bajo el cursor. `None` = sin tip. pub tooltip: Option, + /// Forma del puntero del mouse mientras está sobre este nodo (o un + /// descendiente sin cursor propio — se hereda del ancestro más cercano que + /// lo declare). El runtime lo resuelve en el hit-test de hover y lo aplica a + /// la ventana. `None` = hereda (default flecha en la raíz). Ver [`Cursor`] y + /// [`View::cursor`]. Llimphi-native (sin winit); el runtime lo mapea. + pub cursor: Option, + /// Feedback de tap **ripple/InkWell**: al presionar este nodo, el runtime + /// emite una salpicadura Material (círculo que se expande desde el punto y + /// se desvanece, recortado al contorno del nodo). Es puro feedback visual, + /// aditivo al `on_click`; vive en el runtime ([`RippleRegistry`]), no en el + /// `Model`. `None` = sin ripple. Ver [`View::ripple`]. + pub ripple: Option, + /// Constructor **diferido** sensible al tamaño (`LayoutBuilder`). Si está + /// presente, este nodo NO usa sus `children` estáticos: el runtime resuelve + /// su slot en una primera pasada de layout y luego invoca esta closure con + /// las [`Constraints`] resueltas para producir el subárbol. `None` = nodo + /// normal (la abrumadora mayoría). Ver [`View::layout_builder`]. + pub layout_builder: Option>, + /// Backdrop blur sobre el contenido pintado **debajo** de este nodo. + /// Ver [`View::backdrop_blur`] / [`MountedNode::backdrop_blur`]. v1: + /// sólo se aplica a nodos top-level sin clip/alpha ancestral. + pub backdrop_blur: Option, + /// Filtros CSS (`filter: …`) sobre el propio subárbol del nodo. Vacío = sin + /// filtro. Ver [`View::filter`] / [`FilterOp`]. Fase 7.1232. + pub filter: Vec, + /// **Modo de mezcla** del nodo entero contra su backdrop (CSS + /// `mix-blend-mode`). `Some(bm)` ⇒ el subárbol del nodo se rasteriza en una + /// capa aislada (`scene.push_layer(bm, …)` alrededor del rect) y se mezcla + /// con el modo `bm` contra todo lo pintado antes en el stacking context. + /// `None` = source-over normal (la abrumadora mayoría). Ver [`View::blend`]. + /// Fase 7.1237. + pub blend: Option, pub children: Vec>, } +impl View { + /// Transforma el `Msg` de **todo el árbol** vía `f`, devolviendo + /// `View`. Es la pieza que permite **embeber el `view` de un sub-app** + /// en un host (junto con [`crate::Handle::lift`] para sus efectos): el host + /// pinta `sub_view.map(Msg::Sub)` y los eventos del sub-árbol vuelven como + /// su propio `Msg`. Patrón estándar de anidado Elm. `f` se comparte (`Arc`) + /// entre todos los callbacks e hijos, así que debe ser `Send + Sync`. + pub fn map(self, f: F) -> View + where + Msg2: 'static, + F: Fn(Msg) -> Msg2 + Send + Sync + 'static, + { + self.map_shared(Arc::new(f)) + } + + fn map_shared( + self, + f: Arc Msg2 + Send + Sync>, + ) -> View { + let View { + style, + fill, + hover_fill, + radius, + corner_radii, + shadow, + fill_gradient, + border, + text, + image, + image_fit, + mask_image, + mask_placement, + mask_extra, + painter, + gpu_painter, + over_painter, + on_click, + on_click_at, + on_right_click, + on_right_click_at, + on_middle_click, + drag, + drag_at, + drag_velocity, + drag_payload, + on_drop, + drop_hover_fill, + clip, + clip_inset, + clip_ellipse, + clip_polygon, + clip_path_svg, + clip_ref_inset, + on_pointer_enter, + on_pointer_leave, + on_pointer_move_at, + on_scroll, + on_scale, + on_rotate, + on_double_tap, + on_double_tap_at, + on_long_press, + on_long_press_at, + focusable, + text_select_key, + alpha, + anim, + animated_size, + semantics, + hero, + transform, + transform_rel, + transform_origin, + tooltip, + cursor, + ripple, + layout_builder, + backdrop_blur, + filter, + blend, + children, + } = self; + // Wrappers: cada callback que produce `Option` se reenvía y su + // resultado se eleva con `f`. `f` se clona por callback (todos comparten + // el mismo `Arc`). + View { + // — campos agnósticos al Msg: pasan tal cual — + style, + fill, + hover_fill, + radius, + corner_radii, + shadow, + fill_gradient, + border, + text, + image, + image_fit, + mask_image, + mask_placement, + mask_extra, + painter, + gpu_painter, + over_painter, + drag_payload, + drop_hover_fill, + clip, + clip_inset, + clip_ellipse, + clip_polygon, + clip_path_svg, + clip_ref_inset, + focusable, + text_select_key, + alpha, + anim, + animated_size, + semantics, + hero, + transform, + transform_rel, + transform_origin, + tooltip, + cursor, + ripple, + backdrop_blur, + filter, + blend, + // — Msg simples — + on_click: on_click.map(|m| f(m)), + on_right_click: on_right_click.map(|m| f(m)), + on_middle_click: on_middle_click.map(|m| f(m)), + on_pointer_enter: on_pointer_enter.map(|m| f(m)), + on_pointer_leave: on_pointer_leave.map(|m| f(m)), + on_double_tap: on_double_tap.map(|m| f(m)), + on_long_press: on_long_press.map(|m| f(m)), + // — ClickAtFn (lx, ly, w, h) — + on_click_at: on_click_at.map(|h| { + let f = f.clone(); + Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn + }), + on_right_click_at: on_right_click_at.map(|h| { + let f = f.clone(); + Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn + }), + on_pointer_move_at: on_pointer_move_at.map(|h| { + let f = f.clone(); + Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn + }), + on_double_tap_at: on_double_tap_at.map(|h| { + let f = f.clone(); + Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn + }), + on_long_press_at: on_long_press_at.map(|h| { + let f = f.clone(); + Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn + }), + // — drag / scroll / gestos — + drag: drag.map(|h| { + let f = f.clone(); + Arc::new(move |p, dx, dy| h(p, dx, dy).map(|m| f(m))) as DragFn + }), + drag_at: drag_at.map(|h| { + let f = f.clone(); + Arc::new(move |p, dx, dy, lx, ly| h(p, dx, dy, lx, ly).map(|m| f(m))) + as DragAtFn + }), + drag_velocity: drag_velocity.map(|h| { + let f = f.clone(); + Arc::new(move |p, dx, dy, vx, vy| h(p, dx, dy, vx, vy).map(|m| f(m))) + as DragVelocityFn + }), + on_drop: on_drop.map(|h| { + let f = f.clone(); + Arc::new(move |payload| h(payload).map(|m| f(m))) as DropFn + }), + on_scroll: on_scroll.map(|h| { + let f = f.clone(); + Arc::new(move |dx, dy| h(dx, dy).map(|m| f(m))) as ScrollFn + }), + on_scale: on_scale.map(|h| { + let f = f.clone(); + Arc::new(move |ph, s, cx, cy| h(ph, s, cx, cy).map(|m| f(m))) as ScaleFn + }), + on_rotate: on_rotate.map(|h| { + let f = f.clone(); + Arc::new(move |ph, r, cx, cy| h(ph, r, cx, cy).map(|m| f(m))) as RotateFn + }), + // — layout_builder produce un View: recursá el map — + layout_builder: layout_builder.map(|h| { + let f = f.clone(); + Arc::new(move |c| h(c).map_shared(f.clone())) as LayoutBuilderFn + }), + // — hijos: recursión — + children: children + .into_iter() + .map(|c| c.map_shared(f.clone())) + .collect(), + } + } +} + /// Versión "instalada" del árbol: cada nodo tiene su NodeId de taffy, color /// y handler. Se mantiene en orden de inserción (recorrido pre-orden), así /// el hit-test puede iterar al revés para honrar el orden de pintado. @@ -308,6 +988,204 @@ pub struct TextMeasure { pub italic: bool, pub font_family: Option, pub line_height: f32, + pub weight: f32, + pub max_lines: Option, + pub ellipsis: bool, + /// Idem [`TextSpec::underline`]. Se replica en la medida porque parley + /// no cambia de ancho con decoración (no toca el shaping); pero la clave + /// del caché de shaping sí cambia, y queremos que medida y pintado + /// peguen la misma entrada del caché. + pub underline: bool, + /// Idem [`TextSpec::strikethrough`]. Mismo razonamiento que `underline`. + pub strikethrough: bool, + /// Idem [`TextSpec::spans`]. La medida usa el mismo + /// `Typesetter::layout_spans` que el pintado, así taffy reserva el alto + /// real considerando overrides de `size_px` por span (un `

` inline + /// dentro de un párrafo agranda esa línea). `None`/`vacío` = medir con + /// `layout_clamped` (camino uniforme). + pub spans: Option>, + /// Idem [`TextSpec::letter_spacing`]. Entra en la medida porque cambia el + /// ancho del shaping (y la clave del caché). + pub letter_spacing: f32, + /// Idem [`TextSpec::word_spacing`]. Mismo razonamiento que `letter_spacing`. + pub word_spacing: f32, + /// Idem [`TextSpec::no_wrap`]. Entra en la medida porque cambia el ancho + /// reservado: con `no_wrap` el texto se mide en una sola línea (ancho + /// completo) en vez de envolver al `available`. + pub no_wrap: bool, + /// Idem [`TextSpec::overflow_wrap`]. Entra en la medida porque parte la + /// palabra larga: con el flag, el ancho mínimo del bloque deja de estar + /// fijado por el token más ancho. + pub overflow_wrap: bool, +} + +/// Cómo encajar una imagen en el rect del nodo (CSS `object-fit` / +/// Flutter `BoxFit`). El runtime calcula la escala y el origen +/// correspondientes a esta política y siempre recorta al +/// `node_rrect` del nodo, así el clip respeta `radius` / +/// `corner_radii`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageFit { + /// Preservar aspect ratio, **caber** dentro del rect (escala = + /// `min(sx, sy)`). Deja banda en el eje menos restrictivo. + /// CSS `object-fit: contain` / Flutter `BoxFit.contain`. **Default + /// histórico** — lo que hacía `View::image()` antes del Bloque 12. + Contain, + /// Preservar aspect ratio, **cubrir** todo el rect (escala = + /// `max(sx, sy)`). Recorta el sobrante en el eje menos + /// restrictivo (el clip al `node_rrect` lo absorbe). CSS + /// `object-fit: cover` / Flutter `BoxFit.cover` — ideal para + /// avatares y hero images. + Cover, + /// Estirar la imagen para ocupar el rect, **sin** preservar + /// aspect ratio (`sx`/`sy` independientes). CSS `object-fit: + /// fill` / Flutter `BoxFit.fill`. + Fill, + /// **No** escalar la imagen — pintarla a su tamaño original, + /// centrada en el rect. Si la imagen excede el rect, el clip al + /// `node_rrect` la recorta. CSS `object-fit: none` / Flutter + /// `BoxFit.none`. + None, +} + +/// Longitud de un eje de [`MaskSize`]/posición de máscara, **sin resolver** — +/// el paint la resuelve contra el rect del nodo. Neutral respecto de CSS: el +/// frontend (p. ej. puriy) traduce `mask-size`/`mask-position` a esto. Fase +/// 7.1227. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum MaskLen { + /// Tamaño intrínseco de la imagen (en size) / offset 0 (en position). + Auto, + /// Longitud absoluta en px. + Px(f32), + /// Porcentaje: en size, del lado correspondiente del rect; en position, + /// alineación CSS (el `p%` de la máscara cae sobre el `p%` del rect). + Pct(f32), +} + +/// `mask-size` neutral (espejo de `BackgroundSize`). Ver [`MaskPlacement`]. +/// Fase 7.1227. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum MaskSize { + /// Tamaño intrínseco de la imagen-máscara. + Auto, + /// Escalar preservando aspecto hasta **cubrir** el rect. + Cover, + /// Escalar preservando aspecto hasta **caber** en el rect. + Contain, + /// Tamaño explícito por eje (`Auto` en un eje = derivar por aspecto). + Explicit { x: MaskLen, y: MaskLen }, +} + +/// Modo de una máscara (CSS `mask-mode`). Decide qué canal del píxel-máscara +/// modula el alpha del contenido. Fase 7.1228. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MaskMode { + /// La **luminancia** del píxel multiplica el alpha (negro = oculto, blanco = + /// visible). Lo usa CSS para máscaras SVG ``. Es el default del + /// compositor cuando no hay `MaskPlacement` (camino estirado, Fase 7.1226). + #[default] + Luminance, + /// El **canal alpha** del píxel modula el alpha (transparente = oculto). Es + /// el default CSS para imágenes raster (`mask-mode: match-source`). Se pinta + /// con `Compose::DestIn` en vez de la capa de luminancia. + Alpha, +} + +/// Operador de combinación entre capas de máscara (CSS `mask-composite`). Mapea +/// a un `Compose` Porter-Duff de vello cuando una capa extra se compone sobre +/// las de abajo. Fase 7.1231. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MaskCompose { + /// La capa se **suma** sobre las de abajo (source-over). Default CSS. + #[default] + Add, + /// La capa **resta** (source-out: la fuente donde NO solapa el destino). + Subtract, + /// **Intersección** (source-in: la fuente donde solapa el destino). + Intersect, + /// **Exclusión** (xor: las regiones no solapadas de ambas). + Exclude, +} + +/// Encaje y modo de una **máscara** (CSS `mask-size` + `mask-position` + +/// `mask-repeat` + `mask-mode`), resuelto contra el rect del nodo en el paint, +/// con la misma aritmética que `background-image`. En el [`MountedNode`] viaja +/// como `Option`: `None` = estirar la máscara al border-box en modo luminancia +/// (comportamiento de la Fase 7.1226). Fase 7.1227 (encaje), 7.1228 (modo). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MaskPlacement { + /// Tamaño del tile. + pub size: MaskSize, + /// Offset/alineación horizontal del primer tile. + pub pos_x: MaskLen, + /// Offset/alineación vertical del primer tile. + pub pos_y: MaskLen, + /// Tilear en X (`mask-repeat` cubre el eje horizontal). + pub repeat_x: bool, + /// Tilear en Y. + pub repeat_y: bool, + /// Canal que modula el alpha (luminancia vs alpha). Fase 7.1228. + pub mode: MaskMode, + /// Insets `[top, right, bottom, left]` px del border-box a la caja de + /// `mask-clip`: el efecto de la máscara se **recorta** a esa caja. `None` = + /// border-box. Fase 7.1230. + pub clip_inset: Option<[f32; 4]>, + /// Insets `[top, right, bottom, left]` px del border-box a la caja de + /// `mask-origin`: size/position/tiling se resuelven contra esa caja. `None` + /// = border-box. Fase 7.1230. + pub origin_inset: Option<[f32; 4]>, +} + +impl Default for ImageFit { + fn default() -> Self { + ImageFit::Contain + } +} + +/// Forma del puntero del mouse. Subconjunto práctico, llimphi-native (el +/// compositor no depende de winit). El runtime (`llimphi-ui`) mapea 1:1 a +/// `winit::window::CursorIcon`. Nombres alineados con CSS/winit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Cursor { + /// Flecha por defecto. + Default, + /// Manito — sobre algo clickeable (links, botones). + Pointer, + /// I-beam — sobre texto editable/seleccionable. + Text, + /// Cruz — selección precisa (canvas, picker de color). + Crosshair, + /// Cuatro flechas — mover un objeto. + Move, + /// Mano abierta — agarrable (antes de arrastrar). + Grab, + /// Mano cerrada — arrastrando. + Grabbing, + /// Prohibido — drop no permitido / acción inválida. + NotAllowed, + /// Reloj/espera — operación bloqueante. + Wait, + /// Progreso — ocupado pero la UI responde. + Progress, + /// Interrogación — ayuda contextual. + Help, + /// Resize horizontal (columna / divisor vertical). + ColResize, + /// Resize vertical (fila / divisor horizontal). + RowResize, + /// Resize este-oeste. + EwResize, + /// Resize norte-sur. + NsResize, + /// Resize diagonal ↗↙. + NeswResize, + /// Resize diagonal ↖↘. + NwseResize, + /// Lupa + (zoom in). + ZoomIn, + /// Lupa − (zoom out). + ZoomOut, } pub struct MountedNode { @@ -315,10 +1193,28 @@ pub struct MountedNode { pub fill: Option, pub hover_fill: Option, pub radius: f64, + pub corner_radii: Option, + pub shadow: Option, + pub fill_gradient: Option, + pub border: Option, pub text: Option, pub image: Option, + /// Política de encaje de [`Self::image`] (ver [`ImageFit`]). `None` + /// = `Contain`. + pub image_fit: Option, + /// Máscara de luminancia del subárbol (CSS `mask-image`). Ver + /// [`View::mask_image`]. El paint aísla el subárbol y aplica la luminancia + /// de esta imagen como alpha. `None` = sin máscara. + pub mask_image: Option, + /// Encaje de [`Self::mask_image`] (size/position/repeat). `None` = estirar + /// al border-box. Ver [`MaskPlacement`]. Fase 7.1227. + pub mask_placement: Option, + /// Capas de máscara adicionales `(imagen, operador)` (ver [`View::mask_extra`]). + /// Comparten el `mask_placement` con la capa 0. Fase 7.1231. + pub mask_extra: Vec<(Image, MaskCompose)>, pub painter: Option, pub gpu_painter: Option, + pub over_painter: Option, pub on_click: Option, pub on_click_at: Option>, pub on_right_click: Option, @@ -326,21 +1222,92 @@ pub struct MountedNode { pub on_middle_click: Option, pub drag: Option>, pub drag_at: Option>, + pub drag_velocity: Option>, pub drag_payload: Option, pub on_drop: Option>, pub drop_hover_fill: Option, pub clip: bool, + pub clip_inset: Option<[f32; 4]>, + pub clip_ellipse: Option<[f32; 14]>, + pub clip_polygon: Option<(bool, Vec<[f32; 4]>)>, + pub clip_path_svg: Option<(bool, String)>, + pub clip_ref_inset: Option<[f32; 4]>, pub on_pointer_enter: Option, pub on_pointer_leave: Option, + pub on_pointer_move_at: Option>, pub on_scroll: Option>, + /// Handler de gesto de escala (pinch-to-zoom) de este nodo. Ver + /// [`View::on_scale`] y [`ScaleFn`]. + pub on_scale: Option>, + /// Handler de gesto de rotación (trackpad) de este nodo. Ver + /// [`View::on_rotate`] y [`RotateFn`]. + pub on_rotate: Option>, + /// Handlers de doble-tap (ver [`View::on_double_tap`]). + pub on_double_tap: Option, + pub on_double_tap_at: Option>, + /// Handlers de long-press (ver [`View::on_long_press`]). + pub on_long_press: Option, + pub on_long_press_at: Option>, pub focusable: Option, + /// Key estable de selección de texto (ver [`View::selectable`]). + pub text_select_key: Option, pub alpha: Option, + pub anim: Option, + /// Animación implícita de tamaño (ver [`View::animated_size`]). El + /// runtime ya parchó `style.size` antes del layout — este campo se + /// guarda principalmente para inspección/tests. + pub animated_size: Option, + /// Semántica accesible del nodo (ver [`View::semantics`]). El runtime la + /// lee en cada paint para reconstruir el árbol AccessKit del frame. + pub semantics: Option, + /// Marca de hero shared-element (ver [`View::hero`]). El runtime lo lee + /// en [`HeroRegistry::reconcile`] para enlazar identidad entre frames y + /// escribir `transform` con la afín "fly" cuando el rect cambia. + pub hero: Option, /// Transformación afín 2D del nodo (alrededor del centro de su rect). /// Ver [`View::transform`]. `paint` la compone con la del padre. pub transform: Option, + /// Traslación relativa al tamaño del nodo (fracciones de su rect). Ver + /// [`View::transform_rel`]. `paint`/`hit_test` la resuelven contra el rect. + pub transform_rel: Option<(f64, f64)>, + /// Pivote de `transform` (CSS `transform-origin`). `None` ⇒ centro. Ver + /// [`TransformPivot`] / [`View::transform_origin`]. + pub transform_origin: Option, /// Texto de tooltip de este nodo (ver [`View::tooltip`]). El consumidor lo /// lee tras un hit-test de hover para pintar el rótulo flotante. pub tooltip: Option, + /// Forma del puntero sobre este nodo (ver [`View::cursor`]). El runtime la + /// resuelve heredando del ancestro más cercano que la declare. + pub cursor: Option, + /// Ripple/InkWell de este nodo (ver [`View::ripple`]). El runtime lo + /// dispara en el press y lo pinta vía [`RippleRegistry`]. + pub ripple: Option, + /// `true` si este nodo era un [`View::layout_builder`] (constructor diferido) + /// al montarse. El runtime lo usa tras la primera pasada de layout para leer + /// el rect del slot (vía [`collect_builder_constraints`]) e invocar la + /// closure. Tras expandirse, el nodo final ya es normal (`false`). + pub is_layout_builder: bool, + /// **Backdrop blur** (CSS `backdrop-filter: blur(N)` / Flutter + /// `BackdropFilter`). Sigma del Gauss en pixels; el runtime aplica una + /// pasada separable (H+V) sobre la intermediate restringida al rect del + /// nodo, **antes** de pintar el subárbol del nodo. El subárbol se compone + /// sobre el backdrop ya borroso vía un buffer secundario. `None` = sin + /// blur (la abrumadora mayoría). Limitación v1: el nodo no debe estar + /// dentro de un ancestro con clip/alpha (los subárboles separados pintan + /// fuera de esas capas — documentado en `PARIDAD-FLUTTER.md` Bloque 11). + pub backdrop_blur: Option, + /// Filtros CSS (`filter: …`) sobre el propio subárbol (ver [`View::filter`] + /// / [`FilterOp`]). El runtime los recolecta con [`collect_filters`] y los + /// aplica como post-pasada GPU sobre la intermediate, restringidos al rect + /// del nodo, **después** de la rasterización. Vacío = sin filtro. Fase + /// 7.1232. + pub filter: Vec, + /// Modo de mezcla del nodo entero contra su backdrop (CSS `mix-blend-mode`). + /// Ver [`View::blend`] / [`MountedNode`]. `paint_range` abre una capa de + /// blend (`push_layer(bm, …)`) alrededor del rect del nodo que envuelve + /// fill + contenido + hijos y se cierra al fin del subárbol, mezclando el + /// resultado contra lo ya pintado. `None` = source-over. Fase 7.1237. + pub blend: Option, /// Índice (exclusivo) del fin del subárbol en `Mounted::nodes`. Los /// descendientes ocupan `[idx + 1, subtree_end)`. Hace de "barrera" en /// paint/hit_test para `pop_layer` y para saltar subárboles enteros. diff --git a/llimphi-compositor/src/render.rs b/llimphi-compositor/src/render.rs index 074e815..5cc9fee 100644 --- a/llimphi-compositor/src/render.rs +++ b/llimphi-compositor/src/render.rs @@ -21,10 +21,19 @@ pub fn mount_recursive( fill, hover_fill, radius, + corner_radii, + shadow, + fill_gradient, + border, text, image, + image_fit, + mask_image, + mask_placement, + mask_extra, painter, gpu_painter, + over_painter, on_click, on_click_at, on_right_click, @@ -32,17 +41,43 @@ pub fn mount_recursive( on_middle_click, drag, drag_at, + drag_velocity, drag_payload, on_drop, drop_hover_fill, clip, + clip_inset, + clip_ellipse, + clip_polygon, + clip_path_svg, + clip_ref_inset, on_pointer_enter, on_pointer_leave, + on_pointer_move_at, on_scroll, + on_scale, + on_rotate, + on_double_tap, + on_double_tap_at, + on_long_press, + on_long_press_at, focusable, + text_select_key, alpha, + anim, + animated_size, + semantics, + hero, transform, + transform_rel, + transform_origin, tooltip, + cursor, + ripple, + layout_builder, + backdrop_blur, + filter, + blend, children, } = v; let parent_idx = out.len(); @@ -51,10 +86,19 @@ pub fn mount_recursive( fill, hover_fill, radius, + corner_radii, + shadow, + fill_gradient, + border, text, image, + image_fit, + mask_image, + mask_placement, + mask_extra, painter, gpu_painter, + over_painter, on_click, on_click_at, on_right_click, @@ -62,17 +106,46 @@ pub fn mount_recursive( on_middle_click, drag, drag_at, + drag_velocity, drag_payload, on_drop, drop_hover_fill, clip, + clip_inset, + clip_ellipse, + clip_polygon, + clip_path_svg, + clip_ref_inset, on_pointer_enter, on_pointer_leave, + on_pointer_move_at, on_scroll, + on_scale, + on_rotate, + on_double_tap, + on_double_tap_at, + on_long_press, + on_long_press_at, focusable, + text_select_key, alpha, + anim, + animated_size, + semantics, + hero, transform, + transform_rel, + transform_origin, tooltip, + cursor, + ripple, + // Un layout_builder ya expandido llega como nodo normal; si llega sin + // expandir (caller no pasó por el runtime), se monta como hoja y este + // flag permite que el runtime lo detecte y resuelva. + is_layout_builder: layout_builder.is_some(), + backdrop_blur, + filter, + blend, subtree_end: 0, }); let mut child_ids = Vec::with_capacity(children.len()); @@ -101,6 +174,16 @@ pub fn mount_recursive( italic: text.italic, font_family: text.font_family.clone(), line_height: text.line_height, + weight: text.weight, + max_lines: text.max_lines, + ellipsis: text.ellipsis, + underline: text.underline, + strikethrough: text.strikethrough, + spans: text.spans.clone(), + letter_spacing: text.letter_spacing, + word_spacing: text.word_spacing, + no_wrap: text.no_wrap, + overflow_wrap: text.overflow_wrap, }, ); } @@ -122,26 +205,133 @@ pub fn measure_text_node( available: llimphi_layout::taffy::Size, ) -> llimphi_layout::taffy::Size { use llimphi_layout::taffy::AvailableSpace; - let max_width: Option = known.width.or(match available.width { - AvailableSpace::Definite(w) => Some(w), - AvailableSpace::MaxContent => None, - AvailableSpace::MinContent => Some(0.0), - }); - let block = llimphi_text::TextBlock { - text: &tm.content, - size_px: tm.size_px, - color: Color::BLACK, - origin: (0.0, 0.0), - max_width, - alignment: tm.alignment, - line_height: tm.line_height, - italic: tm.italic, - font_family: tm.font_family.clone(), + // `white-space: nowrap`/`pre`: el texto se mide en una sola línea (ancho + // completo) ignorando el `available`/`known` — equivale a MaxContent. + let max_width: Option = if tm.no_wrap { + None + } else { + known.width.or(match available.width { + AvailableSpace::Definite(w) => Some(w), + AvailableSpace::MaxContent => None, + AvailableSpace::MinContent => Some(0.0), + }) }; - let m = llimphi_text::measure(ts, &block); + // RichText: si hay spans, mediar con `layout_spans` para que taffy + // reserve el alto considerando overrides de tamaño por rango (un span + // con `size_px = 24` dentro de un párrafo de 14 px agranda esa línea). + // El clamp `max_lines`/`ellipsis` no se aplica al camino spans en v1 + // (RichText típico no clampea — los headings y links viven el bloque + // completo); el caller que necesite clamp con spans puede recortar el + // texto antes de pasarlo. + if let Some(spans) = tm.spans.as_ref() { + if !spans.is_empty() { + let layout = ts.layout_spans( + &tm.content, + tm.size_px, + vello::peniko::Color::from_rgba8(0, 0, 0, 255), + tm.weight, + tm.line_height, + tm.italic, + tm.font_family.as_deref(), + tm.underline, + tm.strikethrough, + spans, + max_width, + tm.alignment, + ); + return llimphi_layout::taffy::Size { + width: layout.width(), + height: layout.height(), + }; + } + } + // Camino directo a `layout_clamped` (no `TextBlock`) para transportar + // `weight` (bold mide más ancho) y `max_lines` (taffy reserva el alto de + // N líneas, no el del texto completo). Sin clamp, equivale a `layout`. + let layout = ts.layout_clamped( + &tm.content, + tm.size_px, + max_width, + tm.alignment, + tm.line_height, + tm.italic, + tm.font_family.as_deref(), + tm.weight, + tm.max_lines, + tm.ellipsis, + tm.underline, + tm.strikethrough, + tm.letter_spacing, + tm.word_spacing, + tm.overflow_wrap, + ); + let m = llimphi_text::measurement(&layout); llimphi_layout::taffy::Size { width: m.width, height: m.height } } +/// Construye el `RoundedRect` del nodo respetando radio por esquina si lo +/// hay (si no, el escalar uniforme), con un `inset` opcional restado al rect +/// y a cada radio (lo usa el borde, que pinta media línea hacia adentro). +pub(crate) fn node_rrect( + x0: f64, + y0: f64, + x1: f64, + y1: f64, + radius: f64, + corners: Option, + inset: f64, +) -> RoundedRect { + let radii = match corners { + Some(c) => RoundedRectRadii::new( + (c.top_left - inset).max(0.0), + (c.top_right - inset).max(0.0), + (c.bottom_right - inset).max(0.0), + (c.bottom_left - inset).max(0.0), + ), + None => { + let r = (radius - inset).max(0.0); + RoundedRectRadii::new(r, r, r, r) + } + }; + RoundedRect::new(x0 + inset, y0 + inset, x1 - inset, y1 - inset, radii) +} + +/// Resuelve un radio de `clip-path: circle()/ellipse()` a px, dado su +/// quíntuple `[px, pct_w, pct_h, pct_diag, side]`, el centro local `(cxl, +/// cyl)` (relativo al origen del rect), el tamaño `(w, h)` y si el radio es +/// del eje X (`is_x`). Con `side == 0` suma px + porcentajes (diag = +/// √(w²+h²)/√2). Con `side != 0` ignora px/pct y mide la distancia del centro +/// a los bordes: `1`/`2` = closest/farthest sobre los 4 lados (circle); +/// `3`/`4` = ídem sobre el eje del radio (ellipse). Fase 7.1222. +fn resolve_clip_radius(q: &[f32], cxl: f64, cyl: f64, w: f64, h: f64, is_x: bool) -> f64 { + let side = q[4] as i32; + if side == 0 { + let diag = (w * w + h * h).sqrt() / core::f64::consts::SQRT_2; + return q[0] as f64 + q[1] as f64 / 100.0 * w + q[2] as f64 / 100.0 * h + + q[3] as f64 / 100.0 * diag; + } + let (dx_near, dx_far) = (cxl.min(w - cxl), cxl.max(w - cxl)); + let (dy_near, dy_far) = (cyl.min(h - cyl), cyl.max(h - cyl)); + match side { + 1 => dx_near.min(dy_near), // closest-side, circle (4 lados) + 2 => dx_far.max(dy_far), // farthest-side, circle + 3 => { + if is_x { + dx_near + } else { + dy_near + } + } // closest-side, ellipse (eje) + _ => { + if is_x { + dx_far + } else { + dy_far + } + } // 4 = farthest-side, ellipse + } +} + pub fn paint( scene: &mut vello::Scene, mounted: &Mounted, @@ -150,26 +340,388 @@ pub fn paint( hover_idx: Option, drop_hover_idx: Option, ) { - // Stack de subtree_end de los `push_layer` activos (clip y/o alpha). - // Vello requiere pop_layer en orden LIFO estricto, así que mantenemos - // un único stack común y popeamos en el orden en que se pushearon. - // Dos entradas con el mismo `subtree_end` (alpha + clip sobre el - // mismo nodo) se cierran en el orden inverso al push. - let mut layer_stack: Vec = Vec::new(); + paint_range( + scene, + mounted, + computed, + typesetter, + hover_idx, + drop_hover_idx, + 0, + mounted.nodes.len(), + Affine::IDENTITY, + ); +} + +/// Recolecta los nodos con [`MountedNode::backdrop_blur`] activo del árbol +/// montado, junto con el sigma y el rect absoluto al cual restringir el +/// blur. El runtime (`llimphi-ui::eventloop`) los aplica como post-pasada +/// **después** de la rasterización vello, sobre la intermediate. +/// +/// La búsqueda **salta el subárbol** al encontrar un blur — sin anidamiento +/// en v1: un blur dentro de otro blur sería redundante (el padre ya borrona +/// el rect que cubre al hijo). +/// +/// **Limitación v1 (post-pasada)**: el blur ocurre tras vello, así que el +/// fill/text/imagen del nodo blur y sus descendientes — pintados antes en +/// la misma rasterización — quedan **borroneados** también. Útil para +/// paneles "vidrio sobre fondo" sin contenido propio (el contenido nítido +/// se compone como nodo hermano posterior con el mismo rect). La paridad +/// completa con CSS `backdrop-filter` requiere scene-split (Bloque 11.B +/// del roadmap). +pub fn collect_backdrop_blurs( + mounted: &Mounted, + computed: &ComputedLayout, +) -> Vec { + let mut out = Vec::new(); + let mut idx = 0; + while idx < mounted.nodes.len() { + let node = &mounted.nodes[idx]; + if let Some(sigma) = node.backdrop_blur { + if let Some(r) = computed.get(node.id) { + out.push(BackdropBlur { + sigma, + rect: (r.x, r.y, r.w, r.h), + }); + idx = node.subtree_end; + continue; + } + } + idx += 1; + } + out +} + +/// Datos de un backdrop blur listos para que el runtime lo aplique sobre +/// la intermediate vía `llimphi_hal::BlurCompositor::blur`. +#[derive(Debug, Clone, Copy)] +pub struct BackdropBlur { + /// Sigma del Gauss en pixels lógicos. + pub sigma: f32, + /// Rect absoluto `(x, y, w, h)` del nodo, en pixels lógicos del viewport. + pub rect: (f32, f32, f32, f32), +} + +/// Una operación de `filter` lista para que el runtime la aplique sobre la +/// intermediate, restringida a `rect`. Espeja [`BackdropBlur`] pero lleva una +/// [`FilterOp`] genérica (el runtime hace match por variante). Fase 7.1232. +#[derive(Debug, Clone)] +pub struct FilterPass { + /// Rect absoluto `(x, y, w, h)` del nodo, en pixels lógicos del viewport. + pub rect: (f32, f32, f32, f32), + /// La operación a aplicar (blur / color-matrix / …). + pub op: FilterOp, +} + +/// Recolecta los nodos con [`MountedNode::filter`] no vacío y los aplana en una +/// lista de [`FilterPass`] **en orden de árbol y en orden de la lista de cada +/// nodo** — así el runtime aplica la cadena `filter: a b c` en secuencia sobre +/// el rect (a, luego b, luego c). El runtime las consume tras la rasterización +/// vello, igual que [`collect_backdrop_blurs`]. +/// +/// Salta el subárbol al encontrar un nodo con filtro (como backdrop_blur): un +/// filtro anidado sobre el mismo rect sería redundante en la post-pasada v1. +pub fn collect_filters( + mounted: &Mounted, + computed: &ComputedLayout, +) -> Vec { + let mut out = Vec::new(); + let mut idx = 0; + while idx < mounted.nodes.len() { + let node = &mounted.nodes[idx]; + if !node.filter.is_empty() { + if let Some(r) = computed.get(node.id) { + let rect = (r.x, r.y, r.w, r.h); + for op in &node.filter { + // `DropShadow` se pinta en vello (paint_range), no como + // post-pasada GPU — la salteamos acá. Fase 7.1234. + if matches!(op, FilterOp::DropShadow(_)) { + continue; + } + out.push(FilterPass { rect, op: op.clone() }); + } + idx = node.subtree_end; + continue; + } + } + idx += 1; + } + out +} + +/// Resuelve el afín efectivo de un nodo a partir de su `transform` (afín fijo) +/// y/o `transform_rel` (traslación en fracción de su tamaño), centrado por +/// `transform-origin: 50% 50%` contra su rect computado `r`. El `transform_rel` +/// entra como factor más externo (`T_rel · transform`), igual que un +/// `translate(<%>)` al frente de la lista CSS. `None` si el nodo no tiene +/// ninguno de los dos (caso mayoritario → no se toca el stack de transform). +/// Lo usan `paint_range` y los walks de hit-test para mantenerse en sincronía. +pub(crate) fn resolve_node_transform( + transform: Option, + transform_rel: Option<(f64, f64)>, + transform_origin: Option, + r: llimphi_layout::Rect, +) -> Option { + if transform.is_none() && transform_rel.is_none() { + return None; + } + let mut local = transform.unwrap_or(Affine::IDENTITY); + if let Some((fx, fy)) = transform_rel { + local = Affine::translate((fx * r.w as f64, fy * r.h as f64)) * local; + } + // Pivote = `transform-origin`: `px + frac · tamaño` por eje contra el rect. + // `None` ⇒ centro (default CSS `50% 50%`). El afín se ancla al pivote: + // `T(pivote) · local · T(-pivote)`. + let pivot = transform_origin.unwrap_or_default(); + let ox = r.x as f64 + pivot.px.0 + pivot.frac.0 * r.w as f64; + let oy = r.y as f64 + pivot.px.1 + pivot.frac.1 * r.h as f64; + Some(Affine::translate((ox, oy)) * local * Affine::translate((-ox, -oy))) +} + +/// Pinta el rango de nodos `[start, end)` de `mounted` en `scene`, partiendo de +/// la transformación acumulada `base_xf`. [`paint`] lo llama con todo el árbol +/// (`0..len`, `IDENTITY`). El rango permite **capturar un subárbol** en una +/// escena aparte (p. ej. el snapshot de un nodo que va a animar su salida, ver +/// [`crate::AnimRegistry`]): se pasa `(start, subtree_end)` del nodo raíz. Las +/// coordenadas de los rects ya son absolutas, así que la subescena se puede +/// reproducir luego con `scene.append` aunque sus ancestros ya no existan. +/// +/// Las capas (clip/alpha) que el subárbol abre se cierran dentro del rango (su +/// `subtree_end ≤ end`) o por el drenaje final — la LIFO se respeta. `base_xf` +/// debería ser la transformación de los ancestros del nodo raíz; al capturar +/// se pasa `IDENTITY` (v1 no contempla raíces bajo ancestros transformados). +/// Cierra una capa de aislamiento de `mask-image` aplicando la máscara al +/// contenido ya pintado en la capa (el subárbol del nodo). Según `mask-mode` +/// (Fase 7.1228) abre una capa de **luminancia** (`push_luminance_mask_layer`) +/// o **alpha** (`Compose::DestIn`); dentro pinta la capa 0 (`img`) y las capas +/// extra (`extra`, Fase 7.1231) combinadas por su operador `mask-composite`. El +/// caller cierra la capa de aislamiento con su propio `pop_layer` tras esto. +/// +/// `placement` (Fase 7.1227+) fija el encaje con la misma aritmética que +/// `background-image` (size → tamaño del tile, position → offset del primero, +/// repeat → tiling por eje), el modo (`mask-mode`, Fase 7.1228) y las cajas de +/// referencia (`mask-clip` recorta la capa, `mask-origin` ancla el tiling, Fase +/// 7.1230). `None` = estirar al border-box en modo luminancia (Fase 7.1226). +fn paint_mask_close( + scene: &mut vello::Scene, + img: &Image, + extra: &[(Image, MaskCompose)], + rect: KurboRect, + xf: Affine, + placement: Option, +) { + // Cajas de referencia (Fase 7.1230): `mask-clip` recorta el efecto; + // `mask-origin` ancla el tiling/position. Se encoge el border-box `rect` + // por los insets resueltos. `None` = border-box (sin cambio). + let shrink = |r: KurboRect, inset: Option<[f32; 4]>| -> KurboRect { + match inset { + None => r, + Some([t, ri, b, le]) => KurboRect::new( + r.x0 + le as f64, + r.y0 + t as f64, + (r.x1 - ri as f64).max(r.x0 + le as f64), + (r.y1 - b as f64).max(r.y0 + t as f64), + ), + } + }; + let clip_rect = shrink(rect, placement.and_then(|p| p.clip_inset)); + let origin_rect = shrink(rect, placement.and_then(|p| p.origin_inset)); + // Apertura de la capa según `mask-mode` (Fase 7.1228), recortada a la caja + // de `mask-clip`: luminance usa la capa de luminancia nativa de vello; + // alpha compone la máscara con `Compose::DestIn` (mantiene el destino —el + // subárbol ya pintado— donde la fuente —la máscara— tiene alpha). Sin + // `MaskPlacement` el modo es luminancia (Fase 7.1226). + let mode = placement.map(|p| p.mode).unwrap_or(MaskMode::Luminance); + match mode { + MaskMode::Luminance => scene.push_luminance_mask_layer(Fill::NonZero, 1.0, xf, &clip_rect), + MaskMode::Alpha => scene.push_layer( + Fill::NonZero, + vello::peniko::BlendMode::new(Mix::Normal, vello::peniko::Compose::DestIn), + 1.0, + xf, + &clip_rect, + ), + } + // Capa 0 + capas extra (Fase 7.1231). Las extras comparten el `placement`. + // `add` (default) se dibuja directo (source-over acumula la máscara); el + // resto compone vía un `Compose` Porter-Duff en una sub-capa. + // + // NOTA: la composición multi-capa no está verificada a píxeles (CI sin GPU); + // el mapeo mask-composite → Compose es el de la spec. Para `mask-mode: + // luminance` con varias capas la combinación es aproximada (se compone la + // imagen y luego la capa toma su luminancia), exacta para `alpha`. + draw_mask_layer(scene, img, origin_rect, xf, placement); + for (eimg, op) in extra { + match op { + MaskCompose::Add => draw_mask_layer(scene, eimg, origin_rect, xf, placement), + _ => { + let compose = match op { + MaskCompose::Subtract => vello::peniko::Compose::SrcOut, + MaskCompose::Intersect => vello::peniko::Compose::SrcIn, + MaskCompose::Exclude => vello::peniko::Compose::Xor, + MaskCompose::Add => unreachable!(), + }; + scene.push_layer( + Fill::NonZero, + vello::peniko::BlendMode::new(Mix::Normal, compose), + 1.0, + xf, + &clip_rect, + ); + draw_mask_layer(scene, eimg, origin_rect, xf, placement); + scene.pop_layer(); + } + } + } + scene.pop_layer(); +} + +/// Pinta UNA imagen-máscara dentro de la capa de máscara ya abierta, con su +/// encaje (`placement`): `None` la estira a `origin_rect` (Fase 7.1226), `Some` +/// la tilea (size/position/repeat resueltos contra `origin_rect`, Fase +/// 7.1227/7.1230). La comparten la capa 0 y las extra (Fase 7.1231). No abre ni +/// cierra capas — el caller controla la capa de máscara y el compose. +fn draw_mask_layer( + scene: &mut vello::Scene, + img: &Image, + origin_rect: KurboRect, + xf: Affine, + placement: Option, +) { + let iw = img.image.width.max(1) as f64; + let ih = img.image.height.max(1) as f64; + match placement { + // Estirar la máscara a la caja de origen (= border-box si no hay + // mask-origin). + None => { + let fit = Affine::translate((origin_rect.x0, origin_rect.y0)) + * Affine::scale_non_uniform(origin_rect.width() / iw, origin_rect.height() / ih); + scene.draw_image(img, xf * fit); + } + // size/position/repeat estilo background-image, resueltos contra la caja + // de `mask-origin`. + Some(p) => { + let rw = origin_rect.width(); + let rh = origin_rect.height(); + // 1) Tamaño del tile (px). `Auto` por eje deriva el otro por aspecto. + let resolve = |l: MaskLen, basis: f64| -> Option { + match l { + MaskLen::Px(n) => Some(n as f64), + MaskLen::Pct(q) => Some(basis * q as f64 / 100.0), + MaskLen::Auto => None, + } + }; + let (tw, th) = match p.size { + MaskSize::Auto => (iw, ih), + MaskSize::Cover => { + let s = (rw / iw).max(rh / ih); + (iw * s, ih * s) + } + MaskSize::Contain => { + let s = (rw / iw).min(rh / ih); + (iw * s, ih * s) + } + MaskSize::Explicit { x, y } => match (resolve(x, rw), resolve(y, rh)) { + (Some(w), Some(h)) => (w, h), + (Some(w), None) => (w, w * ih / iw), + (None, Some(h)) => (h * iw / ih, h), + (None, None) => (iw, ih), + }, + }; + if tw > 0.5 && th > 0.5 { + // 2) Offset del primer tile. `Pct` = alineación CSS. + let pos_off = |l: MaskLen, basis: f64, tile: f64| -> f64 { + match l { + MaskLen::Px(n) => n as f64, + MaskLen::Pct(q) => (basis - tile) * q as f64 / 100.0, + MaskLen::Auto => 0.0, + } + }; + let ox = pos_off(p.pos_x, rw, tw); + let oy = pos_off(p.pos_y, rh, th); + // 3) Posiciones de inicio cubriendo [0, span] (o sólo el offset + // si el eje no repite). Cap defensivo contra tiles diminutos. + let axis = |off: f64, tile: f64, span: f64, rep: bool| -> Vec { + if !rep { + return vec![off]; + } + let mut start = off; + while start > 0.0 { + start -= tile; + } + let mut v = Vec::new(); + let mut q = start; + while q < span && v.len() < 4096 { + v.push(q); + q += tile; + } + v + }; + let xs = axis(ox, tw, rw, p.repeat_x); + let ys = axis(oy, th, rh, p.repeat_y); + let scale = Affine::scale_non_uniform(tw / iw, th / ih); + for &x in &xs { + for &y in &ys { + let tf = + Affine::translate((origin_rect.x0 + x, origin_rect.y0 + y)) * scale; + scene.draw_image(img, xf * tf); + } + } + } + } + } +} + +#[allow(clippy::too_many_arguments)] +pub fn paint_range( + scene: &mut vello::Scene, + mounted: &Mounted, + computed: &ComputedLayout, + typesetter: &mut llimphi_text::Typesetter, + hover_idx: Option, + drop_hover_idx: Option, + start: usize, + end: usize, + base_xf: Affine, +) { + // Stack de las capas `push_layer` activas. Vello requiere pop_layer en + // orden LIFO estricto, así que mantenemos un único stack común y popeamos + // en el orden inverso al push. Cada entrada es `(subtree_end, máscara?)`: + // la mayoría son `None` (clip y/o alpha — la capa sólo se cierra con + // `pop_layer`); las de `mask-image` llevan `Some((imagen, rect, xf))` y al + // cerrar aplican la luminancia de la máscara sobre el subárbol ya pintado + // (ver `paint_mask_close`) antes del `pop_layer` de la capa de aislamiento. + // Dos entradas con el mismo `subtree_end` (p. ej. alpha + mask + clip sobre + // el mismo nodo) se cierran en el orden inverso al push. + // Payload de máscara: (capa 0, capas extra `(img, op)`, rect border-box, xf, + // placement compartido). `paint_mask_close` lo consume al cerrar. + type MaskClose = ( + Image, + Vec<(Image, MaskCompose)>, + KurboRect, + Affine, + Option, + ); + let mut layer_stack: Vec<(usize, Option)> = Vec::new(); // Stack de transformaciones afines de subtree. Cada entrada guarda el // `subtree_end` y la `cur_xf` previa para restaurarla al salir del // subárbol. `cur_xf` es el producto acumulado de todos los `transform` // de los ancestros activos — se multiplica en cada draw call. Cuando - // ningún nodo transforma, queda en `IDENTITY` y el paint es idéntico + // ningún nodo transforma, queda en `base_xf` y el paint es idéntico // al previo (cero regresión). let mut xf_stack: Vec<(usize, Affine)> = Vec::new(); - let mut cur_xf = Affine::IDENTITY; - for (idx, node) in mounted.nodes.iter().enumerate() { - // Cierre de capas que ya quedaron atrás (idx ≥ subtree_end). - while let Some(&end) = layer_stack.last() { + let mut cur_xf = base_xf; + for idx in start..end { + let node = &mounted.nodes[idx]; + // Cierre de capas que ya quedaron atrás (idx ≥ subtree_end). Si la + // capa es una máscara, aplicamos su luminancia ANTES del pop. + while let Some(&(end, _)) = layer_stack.last() { if idx >= end { + let (_, mask) = layer_stack.pop().unwrap(); + if let Some((img, extra, rect, xf, placement)) = &mask { + paint_mask_close(scene, img, extra, *rect, *xf, *placement); + } scene.pop_layer(); - layer_stack.pop(); } else { break; } @@ -190,14 +742,27 @@ pub fn paint( // (`transform-origin: 50% 50%`) y se compone sobre la del padre. Se // empuja ANTES del alpha/fill para que toda la pintura del subtree // (incl. la capa de alpha y el clip) caiga en el espacio transformado. - if let Some(local) = node.transform { - let cx = (r.x + r.w * 0.5) as f64; - let cy = (r.y + r.h * 0.5) as f64; - let centered = - Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy)); + if let Some(centered) = resolve_node_transform(node.transform, node.transform_rel, node.transform_origin, r) { xf_stack.push((node.subtree_end, cur_xf)); cur_xf *= centered; } + // `mix-blend-mode` (Fase 7.1237): abrí una capa de mezcla para el + // subárbol del nodo con el modo CSS resuelto. Va ANTES del alpha (es la + // capa más externa) para que el elemento entero —incluida su propia + // opacidad— se mezcle como una unidad contra el backdrop ya pintado. Al + // cerrarse (loop de cierre / drain final) vello compone el subárbol + // aislado con el blend indicado. v1: el backdrop es la escena ya + // pintada, no un fondo aislado (exacto con contenido opaco debajo). + if let Some(bm) = node.blend { + let rect = KurboRect::new( + r.x as f64, + r.y as f64, + (r.x + r.w) as f64, + (r.y + r.h) as f64, + ); + scene.push_layer(Fill::NonZero, bm, 1.0, cur_xf, &rect); + layer_stack.push((node.subtree_end, None)); + } // Alpha de subtree: push ANTES de cualquier paint de este nodo // para que fill/text/image/painter/children entren en la misma // capa y se compongan juntos al alfa indicado. Si el nodo tiene @@ -213,52 +778,163 @@ pub fn paint( (r.x + r.w) as f64, (r.y + r.h) as f64, ); - scene.push_layer(Mix::Normal, a, cur_xf, &rect); - layer_stack.push(node.subtree_end); + scene.push_layer(Fill::NonZero, Mix::Normal, a, cur_xf, &rect); + layer_stack.push((node.subtree_end, None)); } - // Prioridad de pintura: drop-hover (drag activo) > hover normal > - // fill base. Solo aplica el override si el slot correspondiente - // está poblado; el siguiente cae como fallback. - let effective_fill = if Some(idx) == drop_hover_idx { - node.drop_hover_fill.or(node.hover_fill).or(node.fill) - } else if Some(idx) == hover_idx { - node.hover_fill.or(node.fill) - } else { - node.fill - }; - if let Some(color) = effective_fill { - let rr = RoundedRect::new( + // `mask-image` (Fase 7.1226): abrí una capa de aislamiento para el + // subárbol del nodo. La luminancia de la máscara se aplica al CERRARLA + // (en el loop de cierre / drain final, vía `paint_mask_close`), así + // recorta sólo a este nodo + hijos y no a los hermanos previos. Va + // DESPUÉS del alpha (afuera del clip-path, que se pushea al final del + // bloque) para envolver fill + contenido + hijos. + if let Some(mask_img) = node.mask_image.as_ref() { + let rect = KurboRect::new( r.x as f64, r.y as f64, (r.x + r.w) as f64, (r.y + r.h) as f64, - node.radius, ); + scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &rect); + layer_stack.push(( + node.subtree_end, + Some(( + mask_img.clone(), + node.mask_extra.clone(), + rect, + cur_xf, + node.mask_placement, + )), + )); + } + // Sombra (drop shadow): se pinta ANTES del relleno para quedar + // detrás. Usa el blur gaussiano nativo de vello sobre un rect + // redondeado offseteado + inflado por `spread`. + if let Some(sh) = node.shadow.as_ref() { + if sh.color.components[3] > 0.0 && r.w > 0.0 && r.h > 0.0 { + let rect = KurboRect::new( + (r.x as f64) + sh.dx - sh.spread, + (r.y as f64) + sh.dy - sh.spread, + (r.x + r.w) as f64 + sh.dx + sh.spread, + (r.y + r.h) as f64 + sh.dy + sh.spread, + ); + let radius = (node.radius + sh.spread).max(0.0); + scene.draw_blurred_rounded_rect(cur_xf, rect, sh.color, radius, sh.blur); + } + } + // `filter: drop-shadow(...)` (Fase 7.1234): una o más sombras Gaussianas + // del border-box, detrás del relleno. Misma primitiva que box-shadow; v1 + // pinta la sombra del rect, no de la silueta alpha del subárbol. En + // orden de la lista (la primera declarada queda más atrás). + for op in node.filter.iter().rev() { + if let FilterOp::DropShadow(sh) = op { + if sh.color.components[3] > 0.0 && r.w > 0.0 && r.h > 0.0 { + let rect = KurboRect::new( + (r.x as f64) + sh.dx - sh.spread, + (r.y as f64) + sh.dy - sh.spread, + (r.x + r.w) as f64 + sh.dx + sh.spread, + (r.y + r.h) as f64 + sh.dy + sh.spread, + ); + let radius = (node.radius + sh.spread).max(0.0); + scene.draw_blurred_rounded_rect(cur_xf, rect, sh.color, radius, sh.blur); + } + } + } + // Prioridad de pintura: drop-hover (drag activo) > hover normal > + // gradiente base > fill color base. Solo aplica el override si el + // slot correspondiente está poblado; el siguiente cae como fallback. + let hover_color = if Some(idx) == drop_hover_idx { + node.drop_hover_fill.or(node.hover_fill).or(node.fill) + } else if Some(idx) == hover_idx { + node.hover_fill.or(node.fill) + } else { + None + }; + let rr = node_rrect( + r.x as f64, + r.y as f64, + (r.x + r.w) as f64, + (r.y + r.h) as f64, + node.radius, + node.corner_radii, + 0.0, + ); + if let Some(color) = hover_color { + // Hover/drop gana sobre el gradiente y el fill base. + scene.fill(Fill::NonZero, cur_xf, color, None, &rr); + } else if let Some(grad) = node.fill_gradient.as_ref() { + // Gradiente autoreado en `[0,1]²`, mapeado al rect vía + // brush_transform (incluye la transformación acumulada). + let brush_xf = cur_xf + * Affine::translate((r.x as f64, r.y as f64)) + * Affine::scale_non_uniform(r.w as f64, r.h as f64); + scene.fill(Fill::NonZero, cur_xf, grad, Some(brush_xf), &rr); + } else if let Some(color) = node.fill { scene.fill(Fill::NonZero, cur_xf, color, None, &rr); } - if let Some(image) = node.image.as_ref() { - // Aspect-fit centrado: el min de las dos escalas ocupa - // todo el rect en el eje más restrictivo y deja banda en - // el otro. Defensivo: envolvemos en push_layer/pop_layer - // con el rect del nodo para que, aunque el caller pida - // un layout mal-dimensionado, la imagen nunca pinte fuera - // del nodo (visualmente preferible a un overflow opaco). - if image.width > 0 && image.height > 0 && r.w > 0.0 && r.h > 0.0 { - let sx = r.w as f64 / image.width as f64; - let sy = r.h as f64 / image.height as f64; - let s = sx.min(sy); - let disp_w = image.width as f64 * s; - let disp_h = image.height as f64 * s; - let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5; - let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5; - let transform = Affine::translate((tx, ty)) * Affine::scale(s); - let node_rect = KurboRect::new( + // Borde (stroke) sobre el relleno, inset media línea hacia adentro. + if let Some(b) = node.border.as_ref() { + if b.width > 0.0 && b.color.components[3] > 0.0 && r.w > 0.0 && r.h > 0.0 { + let inset = b.width * 0.5; + let brr = node_rrect( r.x as f64, r.y as f64, (r.x + r.w) as f64, (r.y + r.h) as f64, + node.radius, + node.corner_radii, + inset, ); - scene.push_layer(Mix::Clip, 1.0, cur_xf, &node_rect); + scene.stroke(&Stroke::new(b.width), cur_xf, b.color, None, &brr); + } + } + if let Some(image) = node.image.as_ref() { + // Encaje seleccionable (Bloque 12) — Contain/Cover/Fill/None. + // Siempre clippeamos al `node_rrect` para respetar + // `radius`/`corner_radii` (avatares + cards) y para que + // `Cover`/`None` no derramen fuera del nodo. + if image.image.width > 0 && image.image.height > 0 && r.w > 0.0 && r.h > 0.0 { + let sx = r.w as f64 / image.image.width as f64; + let sy = r.h as f64 / image.image.height as f64; + let fit = node.image_fit.unwrap_or(ImageFit::Contain); + let transform = match fit { + ImageFit::Contain => { + let s = sx.min(sy); + let disp_w = image.image.width as f64 * s; + let disp_h = image.image.height as f64 * s; + let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5; + let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5; + Affine::translate((tx, ty)) * Affine::scale(s) + } + ImageFit::Cover => { + let s = sx.max(sy); + let disp_w = image.image.width as f64 * s; + let disp_h = image.image.height as f64 * s; + let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5; + let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5; + Affine::translate((tx, ty)) * Affine::scale(s) + } + ImageFit::Fill => { + Affine::translate((r.x as f64, r.y as f64)) + * Affine::scale_non_uniform(sx, sy) + } + ImageFit::None => { + let disp_w = image.image.width as f64; + let disp_h = image.image.height as f64; + let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5; + let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5; + Affine::translate((tx, ty)) + } + }; + let clip_rr = node_rrect( + r.x as f64, + r.y as f64, + (r.x + r.w) as f64, + (r.y + r.h) as f64, + node.radius, + node.corner_radii, + 0.0, + ); + scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &clip_rr); scene.draw_image(image, cur_xf * transform); scene.pop_layer(); } @@ -276,7 +952,48 @@ pub fn paint( ); } if let Some(text) = node.text.as_ref() { - if let Some(runs) = text.runs.as_ref() { + let has_spans = text + .spans + .as_ref() + .map(|s| !s.is_empty()) + .unwrap_or(false); + if has_spans { + // RichText (Bloque 13): defaults a nivel bloque + spans + // sobreescriben size/weight/italic/family/color/underline/ + // strikethrough por rango de bytes. Respeta `max_width = r.w` + // (wrap a párrafo) y la alignment del bloque; para Center + // también centramos verticalmente como en el camino uniforme. + let spans = text.spans.as_ref().unwrap(); + let layout = typesetter.layout_spans( + &text.content, + text.size_px, + text.color, + text.weight, + text.line_height, + text.italic, + text.font_family.as_deref(), + text.underline, + text.strikethrough, + spans, + Some(r.w), + text.alignment, + ); + let origin = + if matches!(text.alignment, llimphi_text::Alignment::Center) { + let lh = layout.height() as f64; + ( + r.x as f64, + r.y as f64 + ((r.h as f64 - lh) * 0.5).max(0.0), + ) + } else { + (r.x as f64, r.y as f64) + }; + llimphi_text::draw_layout_runs_xf( + scene, + &layout, + cur_xf * Affine::translate(origin), + ); + } else if let Some(runs) = text.runs.as_ref() { // Texto multicolor (syntax highlighting): una sola pasada de // shaping con color por rango, anclado arriba-izquierda. Cae // por el flujo normal (clip/alpha se cierran como siempre). @@ -287,26 +1004,46 @@ pub fn paint( runs, text.alignment, text.line_height, + text.weight, + text.underline, + text.strikethrough, + ); + // `cur_xf *` para que el texto multicolor herede la + // transformación del subárbol (scroll/rotación del padre), igual + // que el texto de color único de abajo. Sin esto se pintaba en + // coords de layout crudas y se desalineaba al scrollear. + llimphi_text::draw_layout_runs_xf( + scene, + &layout, + cur_xf * Affine::translate((r.x as f64, r.y as f64)), ); - llimphi_text::draw_layout_runs(scene, &layout, (r.x as f64, r.y as f64)); } else { // Parley resuelve la alineación horizontal vía max_width + // alignment. Para Center también centramos verticalmente; para - // Start/End/Justify anclamos arriba (párrafo/editor). - let block = llimphi_text::TextBlock { - text: &text.content, - size_px: text.size_px, - color: text.color, - origin: (r.x as f64, r.y as f64), - max_width: Some(r.w), - alignment: text.alignment, - line_height: text.line_height, - italic: text.italic, - font_family: text.font_family.clone(), - }; - // Shaping una sola vez: el `Layout` retornado se reusa para - // medir (cuando hay centrado vertical) y para pintar. - let layout = llimphi_text::layout_block(typesetter, &block); + // Start/End/Justify anclamos arriba (párrafo/editor). Camino + // directo a `layout_clamped` para transportar `weight` y el + // clamp de `max_lines`/`ellipsis` del TextSpec. + // `white-space: nowrap`/`pre`: pintar en una sola línea (sin + // `max_width`), no envolver al ancho del rect — el texto + // desborda y lo recorta el `overflow` del contenedor si lo hay. + let paint_max_width = if text.no_wrap { None } else { Some(r.w) }; + let layout = typesetter.layout_clamped( + &text.content, + text.size_px, + paint_max_width, + text.alignment, + text.line_height, + text.italic, + text.font_family.as_deref(), + text.weight, + text.max_lines, + text.ellipsis, + text.underline, + text.strikethrough, + text.letter_spacing, + text.word_spacing, + text.overflow_wrap, + ); let origin = if matches!(text.alignment, llimphi_text::Alignment::Center) { let m = llimphi_text::measurement(&layout); @@ -315,7 +1052,7 @@ pub fn paint( r.y as f64 + ((r.h - m.height) as f64 * 0.5).max(0.0), ) } else { - block.origin + (r.x as f64, r.y as f64) }; llimphi_text::draw_layout_xf( scene, @@ -326,18 +1063,83 @@ pub fn paint( } } if node.clip { - let clip_rect = KurboRect::new( - r.x as f64, - r.y as f64, - (r.x + r.w) as f64, - (r.y + r.h) as f64, - ); - scene.push_layer(Mix::Clip, 1.0, cur_xf, &clip_rect); - layer_stack.push(node.subtree_end); + // El hit-test (más abajo) usa siempre el rect completo — el clip-path + // sólo afecta el pintado, una aproximación menor en su banda. + // Prioridad: path > polygon > elipse > inset/rect. `pushed` queda + // false sólo si un path() no parsea (no se abre capa → no se cierra). + let mut pushed = true; + // Caja de referencia (clip-path geometry-box, Fase 7.1225): encoge + // el rect del nodo por `clip_ref_inset` ANTES de resolver la forma, + // así circle/ellipse/polygon/path y sus % se miden contra esa caja. + let [rit, rir, rib, ril] = node.clip_ref_inset.unwrap_or([0.0; 4]); + let (bx, by) = ((r.x + ril) as f64, (r.y + rit) as f64); + let (bw, bh) = ((r.w - ril - rir).max(0.0) as f64, (r.h - rit - rib).max(0.0) as f64); + if let Some((evenodd, d)) = &node.clip_path_svg { + // `clip-path: path()` — parsea el SVG y lo traslada al origen + // de la caja de referencia (user units px). from_svg falla → no + // recorta. + match vello::kurbo::BezPath::from_svg(d) { + Ok(mut path) => { + path.apply_affine(Affine::translate((bx, by))); + let fill = if *evenodd { Fill::EvenOdd } else { Fill::NonZero }; + scene.push_layer(fill, BlendMode::default(), 1.0, cur_xf, &path); + } + Err(_) => pushed = false, + } + } else if let Some((evenodd, pts)) = &node.clip_polygon { + // `clip-path: polygon()` — capa con un path cerrado. Cada punto + // resuelve sus % contra la caja de referencia; move_to al 1º, + // line_to al resto, close_path. + let mut path = vello::kurbo::BezPath::new(); + for (i, p) in pts.iter().enumerate() { + let px = bx + p[0] as f64 + p[1] as f64 / 100.0 * bw; + let py = by + p[2] as f64 + p[3] as f64 / 100.0 * bh; + if i == 0 { + path.move_to((px, py)); + } else { + path.line_to((px, py)); + } + } + path.close_path(); + let fill = if *evenodd { Fill::EvenOdd } else { Fill::NonZero }; + scene.push_layer(fill, BlendMode::default(), 1.0, cur_xf, &path); + } else if let Some(s) = node.clip_ellipse { + // `clip-path: circle()/ellipse()` — capa elíptica. Centro y + // radios resuelven contra la caja de referencia. El centro local + // alimenta tanto la posición como el cómputo de los lados + // (closest/farthest-side). + let cxl = s[0] as f64 + s[1] as f64 / 100.0 * bw; + let cyl = s[2] as f64 + s[3] as f64 / 100.0 * bh; + let cx = bx + cxl; + let cy = by + cyl; + let rx = resolve_clip_radius(&s[4..9], cxl, cyl, bw, bh, true); + let ry = resolve_clip_radius(&s[9..14], cxl, cyl, bw, bh, false); + let ellipse = Ellipse::new((cx, cy), (rx, ry), 0.0); + scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &ellipse); + } else { + // `clip_inset` (clip-path: inset) encoge la caja de referencia + // desde cada borde; `None` (overflow:hidden / geometry-box solo) + // recorta a la caja de referencia completa. + let [ct, cr, cb, cl] = node.clip_inset.unwrap_or([0.0; 4]); + let clip_rect = KurboRect::new( + bx + cl as f64, + by + ct as f64, + bx + bw - cr as f64, + by + bh - cb as f64, + ); + scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &clip_rect); + } + if pushed { + layer_stack.push((node.subtree_end, None)); + } } } - // Cerrá capas (clip + alpha) que llegaron al final sin pop intermedio. - while layer_stack.pop().is_some() { + // Cerrá capas (clip + alpha + mask) que llegaron al final sin pop + // intermedio. Las de máscara aplican su luminancia antes del pop. + while let Some((_, mask)) = layer_stack.pop() { + if let Some((img, extra, rect, xf, placement)) = &mask { + paint_mask_close(scene, img, extra, *rect, *xf, *placement); + } scene.pop_layer(); } } @@ -392,6 +1194,52 @@ pub fn paint_gpu( any } +/// `true` si algún nodo del árbol registró un `over_painter` (vello +/// "over" vía [`View::paint_over`]). El eventloop lo usa para decidir si +/// vale la pena montar la pasada vello final + el composite sobre la +/// intermedia. Coste cero (loop barato) cuando nadie usa el over-layer. +pub fn has_over_painter(mounted: &Mounted) -> bool { + mounted.nodes.iter().any(|n| n.over_painter.is_some()) +} + +/// Pinta la pasada vello "over" en `scene`: recorre el árbol en orden +/// DFS pre-orden e invoca cada `over_painter` con el `Typesetter` +/// compartido y el rect absoluto del nodo. Espejo de [`paint_gpu`] pero +/// del lado vello — la diferencia de timing la pone el caller, que +/// rasteriza esta `scene` DESPUÉS del pase GPU y la compone sobre la +/// intermedia. No resetea `scene` (el caller decide); sólo agrega +/// primitivas. Como [`paint_gpu`], usa rects absolutos (no compone los +/// `transform` de ancestros — el over-layer es contenido posicionado en +/// coordenadas de pantalla, igual que el pintor GPU). +pub fn paint_over( + scene: &mut vello::Scene, + mounted: &Mounted, + computed: &ComputedLayout, + typesetter: &mut llimphi_text::Typesetter, +) -> bool { + let mut any = false; + for node in &mounted.nodes { + let Some(painter) = node.over_painter.as_ref() else { + continue; + }; + let Some(r) = computed.get(node.id) else { + continue; + }; + (painter)( + scene, + typesetter, + PaintRect { + x: r.x, + y: r.y, + w: r.w, + h: r.h, + }, + ); + any = true; + } + any +} + /// Hit-test parametrizado por elegibilidad. Devuelve el índice del nodo /// más al frente (último en pre-orden) cuyo rect contiene `(x, y)` y para /// el cual `pred` devuelve `true`, respetando `clip`: si el punto cae @@ -448,11 +1296,7 @@ where // Componé el transform de este nodo igual que `paint`, ANTES de // resolver el punto local (su propio rect ya cae en el espacio // transformado). - if let Some(local) = node.transform { - let cx = (r.x + r.w * 0.5) as f64; - let cy = (r.y + r.h * 0.5) as f64; - let centered = - Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy)); + if let Some(centered) = resolve_node_transform(node.transform, node.transform_rel, node.transform_origin, r) { xf_stack.push((node.subtree_end, cur_xf)); cur_xf *= centered; } @@ -499,6 +1343,7 @@ pub fn hit_test_click( || n.on_click_at.is_some() || n.drag.is_some() || n.drag_at.is_some() + || n.drag_velocity.is_some() }) } @@ -538,6 +1383,34 @@ pub fn hit_test_hover( hit_test_pred(mounted, computed, x, y, |n| n.hover_fill.is_some()) } +/// Hit-test para movimiento posicional del cursor (nodos con +/// `on_pointer_move_at`). El runtime lo invoca en cada `CursorMoved` para +/// reportar la posición local al nodo más al frente que lo declare. +pub fn hit_test_pointer_move( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| n.on_pointer_move_at.is_some()) +} + +/// Hit-test específico para la **forma del cursor**: devuelve el [`Cursor`] +/// del nodo más al frente bajo el punto que declare uno. Como un hijo sin +/// cursor no matchea el predicado, el cursor "cae" al ancestro más cercano que +/// lo declare — herencia estilo CSS sin recorrer el árbol a mano. `None` = +/// ningún nodo bajo el punto declara cursor (el runtime usa el default de la +/// ventana). Lo invoca `llimphi-ui` en la transición de hover. +pub fn hit_test_cursor( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| n.cursor.is_some()) + .and_then(|i| mounted.nodes[i].cursor) +} + /// Hit-test específico para drop targets (nodos con `on_drop`). Usado /// durante un drag activo para resaltar el destino y para invocar el /// handler al soltar. @@ -563,6 +1436,150 @@ pub fn hit_test_scroll( hit_test_pred(mounted, computed, x, y, |n| n.on_scroll.is_some()) } +/// Cadena de **scroll anidado**: devuelve todos los nodos con `on_scroll` +/// que contienen el punto, ordenados **front→back** (el primero es el más +/// al frente, igual que [`hit_test_scroll`]; los siguientes son sus +/// ancestros scrollables). El runtime itera la cadena al recibir la rueda +/// y se queda con el primer handler que devuelva `Some`: si un scroll +/// interno está en el extremo del eje y devuelve `None`, el evento "pasa" +/// al ancestro scrollable más cercano (lista dentro de panel, etc.). +/// Recorrido idéntico al de [`hit_test_pred`] pero acumulando todos los +/// hits en vez de pisar. +pub fn hit_test_scroll_chain( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Vec { + let mut chain: Vec = Vec::new(); + let mut clip_stack: Vec = Vec::new(); + let mut xf_stack: Vec<(usize, Affine)> = Vec::new(); + let mut cur_xf = Affine::IDENTITY; + let mut idx = 0; + while idx < mounted.nodes.len() { + while let Some(&end) = clip_stack.last() { + if idx >= end { + clip_stack.pop(); + } else { + break; + } + } + while let Some(&(end, prev)) = xf_stack.last() { + if idx >= end { + cur_xf = prev; + xf_stack.pop(); + } else { + break; + } + } + let node = &mounted.nodes[idx]; + let Some(r) = computed.get(node.id) else { + idx += 1; + continue; + }; + if let Some(centered) = resolve_node_transform(node.transform, node.transform_rel, node.transform_origin, r) { + xf_stack.push((node.subtree_end, cur_xf)); + cur_xf *= centered; + } + let (lx, ly) = if xf_stack.is_empty() { + (x as f64, y as f64) + } else if cur_xf.determinant().abs() < 1e-9 { + idx = node.subtree_end; + continue; + } else { + let p = cur_xf.inverse() * Point::new(x as f64, y as f64); + (p.x, p.y) + }; + let inside = lx >= r.x as f64 + && lx < (r.x + r.w) as f64 + && ly >= r.y as f64 + && ly < (r.y + r.h) as f64; + if node.clip { + if !inside { + idx = node.subtree_end; + continue; + } + clip_stack.push(node.subtree_end); + } + if inside && node.on_scroll.is_some() { + chain.push(idx); + } + idx += 1; + } + // El recorrido es pre-orden, así que los ancestros aparecen primero y + // los hijos después. Para front→back necesitamos el orden inverso. + chain.reverse(); + chain +} + +/// Hit-test específico para gestos de **escala** (pinch-to-zoom): el nodo más +/// al frente bajo el punto que declaró un `on_scale`. Como un hijo sin handler +/// no matchea el predicado, el gesto "cae" al ancestro más cercano que lo +/// declare (un canvas grande zoomeable con widgets encima que no zoomean). El +/// runtime lo invoca al recibir Ctrl+rueda o un pinch de trackpad. `None` = +/// ningún nodo zoomeable bajo el cursor (el evento cae al scroll/`on_wheel`). +pub fn hit_test_scale( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| n.on_scale.is_some()) +} + +/// Hit-test específico para gestos de **rotación** (trackpad): el nodo más al +/// frente bajo el punto que declaró un `on_rotate`. Análogo a +/// [`hit_test_scale`]; el runtime lo invoca al recibir un `RotationGesture`. +/// `None` = ningún nodo rotable bajo el cursor. +pub fn hit_test_rotate( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| n.on_rotate.is_some()) +} + +/// Hit-test para **doble-tap**: el nodo más al frente bajo el punto que +/// declaró `on_double_tap`/`on_double_tap_at`. El runtime lo usa al detectar +/// dos presses rápidos y cercanos. +pub fn hit_test_double_tap( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| { + n.on_double_tap.is_some() || n.on_double_tap_at.is_some() + }) +} + +/// Hit-test para **long-press**: el nodo más al frente bajo el punto que +/// declaró `on_long_press`/`on_long_press_at`. El runtime lo usa al armar el +/// gesto en el press (que vence por tiempo si no hay movimiento ni release). +pub fn hit_test_long_press( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| { + n.on_long_press.is_some() || n.on_long_press_at.is_some() + }) +} + +/// Hit-test para **ripple**: el nodo más al frente bajo el punto que declaró +/// un [`Ripple`] (vía [`View::ripple`]). El runtime lo usa en el press para +/// disparar la salpicadura. Aditivo — no compite con click/drag. +pub fn hit_test_ripple( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| n.ripple.is_some()) +} + /// Hit-test para foco: el id `focusable` del nodo más al frente bajo el /// cursor (click-to-focus). `None` si no se clickeó nada enfocable. pub fn hit_test_focusable( @@ -575,6 +1592,20 @@ pub fn hit_test_focusable( .and_then(|i| mounted.nodes[i].focusable) } +/// Hit-test para **selección de texto**: el índice del nodo de texto +/// seleccionable (`text_select_key`) más al frente bajo el cursor. El runtime +/// lo usa para arrancar/extender una selección; devuelve el índice (no la key) +/// para que el caller acceda al `text` + rect del nodo. `None` si no hay texto +/// seleccionable bajo el punto. +pub fn hit_test_selectable( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| n.text_select_key.is_some()) +} + /// Ids enfocables en orden de Tab (pre-orden del árbol = orden de /// inserción de `Mounted::nodes`). Sólo nodos con rect computado /// (presentes en el layout). Es el orden DOM-like de tabulación. @@ -625,6 +1656,70 @@ mod tests { use llimphi_layout::{LayoutTree, Style}; use vello::kurbo::Affine; + #[test] + fn transform_origin_fija_el_pivote() { + // El pivote de `transform-origin` queda FIJO bajo la transformación: + // top-left (frac 0,0) fija la esquina (0,0); el default (None) fija el + // centro (50,50). Rect 100×100 en el origen, rotación 90°. + use super::resolve_node_transform; + use crate::TransformPivot; + use vello::kurbo::Point; + let r = llimphi_layout::Rect { x: 0.0, y: 0.0, w: 100.0, h: 100.0 }; + let rot = Affine::rotate(std::f64::consts::FRAC_PI_2); + + let tl = TransformPivot { px: (0.0, 0.0), frac: (0.0, 0.0) }; + let xf_tl = resolve_node_transform(Some(rot), None, Some(tl), r).unwrap(); + let p = xf_tl * Point::new(0.0, 0.0); + assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6, "pivote top-left fijo, fue {p:?}"); + + // Default (None) ⇒ centro: (50,50) queda fijo. + let xf_c = resolve_node_transform(Some(rot), None, None, r).unwrap(); + let c = xf_c * Point::new(50.0, 50.0); + assert!( + (c.x - 50.0).abs() < 1e-6 && (c.y - 50.0).abs() < 1e-6, + "centro fijo con pivote default, fue {c:?}" + ); + // Y el centro NO queda fijo con pivote top-left (distingue los dos casos). + let c2 = xf_tl * Point::new(50.0, 50.0); + assert!((c2.x - 50.0).abs() > 1.0 || (c2.y - 50.0).abs() > 1.0, "top-left mueve el centro"); + } + + #[test] + fn resolve_clip_radius_lados_y_porcentajes() { + use super::resolve_clip_radius; + // Caja 200×100, centro al (50%,50%) = (100,50) local. + let (w, h, cxl, cyl): (f64, f64, f64, f64) = (200.0, 100.0, 100.0, 50.0); + // side 0: px + pct_w·w + pct_h·h + pct_diag·diag. + let diag = (w * w + h * h).sqrt() / core::f64::consts::SQRT_2; + let r = resolve_clip_radius(&[10.0, 0.0, 0.0, 50.0, 0.0], cxl, cyl, w, h, true); + assert!((r - (10.0 + 0.5 * diag)).abs() < 1e-6); + // closest-side circle (1): min(100,100,50,50) = 50. + assert_eq!( + resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 1.0], cxl, cyl, w, h, true), + 50.0 + ); + // farthest-side circle (2): max(...) = 100. + assert_eq!( + resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 2.0], cxl, cyl, w, h, true), + 100.0 + ); + // closest-side ellipse eje X (3, is_x): min(cxl, w-cxl) = 100. + assert_eq!( + resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 3.0], cxl, cyl, w, h, true), + 100.0 + ); + // closest-side ellipse eje Y (3, !is_x): min(cyl, h-cyl) = 50. + assert_eq!( + resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 3.0], cxl, cyl, w, h, false), + 50.0 + ); + // Centro descentrado (30, 20): closest circle = min(30,170,20,80)=20. + assert_eq!( + resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 1.0], 30.0, 20.0, w, h, true), + 20.0 + ); + } + /// Un hijo clickeable de 100×100 anclado arriba-izquierda. Devuelve /// `(mounted, computed)` ya layouteados sobre un viewport 400×400. fn fixture( @@ -684,6 +1779,149 @@ mod tests { assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None); } + /// Como `fixture` pero seteando `transform_rel` (traslación en fracción + /// del tamaño del nodo) en vez del afín fijo. + fn fixture_rel( + rel: (f64, f64), + ) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) { + let child = View::<()>::new(Style { + size: Size { width: length(100.0), height: length(100.0) }, + ..Default::default() + }) + .on_click(()) + .transform_rel(rel); + let root = View::<()>::new(Style { + align_items: Some(AlignItems::FlexStart), + justify_content: Some(JustifyContent::FlexStart), + ..Default::default() + }) + .children(vec![child]); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, root); + let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout"); + (mounted, computed) + } + + /// Replica la estructura de dominium: un wrapper que clipea y lleva + /// `on_click_at` + `draggable_at`, con un hijo "canvas" que pinta por + /// uno de dos caminos. Devuelve `(mounted, computed)`. `gpu` elige el + /// camino: `false` = `paint_with` (camino LEGACY), `true` = + /// `gpu_paint_with().paint_over()` (camino Tier 1 nuevo). + fn dominium_like(gpu: bool) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) { + let mut canvas = View::<()>::new(Style { + size: Size { width: percent(1.0), height: percent(1.0) }, + ..Default::default() + }); + canvas = if gpu { + canvas + .gpu_paint_with(|_d, _q, _e, _t, _r, _vp| {}) + .paint_over(|_s, _ts, _r| {}) + } else { + canvas.paint_with(|_s, _ts, _r| {}) + }; + let wrapper = View::<()>::new(Style { + size: Size { width: percent(1.0), height: percent(1.0) }, + ..Default::default() + }) + .clip(true) + .on_click_at(|_lx, _ly, _rw, _rh| Some(())) + .draggable_at(|_phase, _dx, _dy, _x0, _y0| Some(())) + .children(vec![canvas]); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, wrapper); + let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout"); + (mounted, computed) + } + + #[test] + fn canvas_gpu_only_es_clickeable_igual_que_legacy() { + // BUG 2: el wrapper con `on_click_at`/`draggable_at` debe ser + // hit-testeable en AMBOS caminos. El click cae sobre el wrapper + // (índice 0, raíz) — un punto interior lo encuentra sin importar el + // tipo de painter del hijo. + let (m_leg, c_leg) = dominium_like(false); + assert_eq!(hit_test_click(&m_leg, &c_leg, 200.0, 200.0), Some(0), "LEGACY (paint_with)"); + let (m_gpu, c_gpu) = dominium_like(true); + assert_eq!(hit_test_click(&m_gpu, &c_gpu, 200.0, 200.0), Some(0), "GPU (gpu_paint_with+paint_over)"); + } + + #[test] + fn nodo_gpu_paint_with_solo_es_hittable_por_si_mismo() { + // Crítico para el motor voxel futuro: una vista 3D GPU-only que + // lleve su PROPIO `on_click_at` debe ser clickeable, aunque NO + // tenga `paint_with` ni contenido vello — sólo `gpu_painter`. + let canvas = View::<()>::new(Style { + size: Size { width: length(100.0), height: length(100.0) }, + ..Default::default() + }) + .gpu_paint_with(|_d, _q, _e, _t, _r, _vp| {}) + .on_click(()); + let root = View::<()>::new(Style { + align_items: Some(AlignItems::FlexStart), + justify_content: Some(JustifyContent::FlexStart), + ..Default::default() + }) + .children(vec![canvas]); + let mut layout = LayoutTree::new(); + let m = mount(&mut layout, root); + let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); + assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), Some(1), "gpu-only con on_click debe ser hittable"); + } + + #[test] + fn transform_rel_resuelve_contra_el_tamano_del_nodo() { + // El nodo es 100×100 en (0,0). `transform_rel(-0.5,-0.5)` = + // `translate(-50%,-50%)` = correr -50px,-50px (la mitad de 100). El + // área pintada pasa a (-50,-50)..(50,50): el centro del rect original + // (50,50) queda ahora en (0,0). + let (m, c) = fixture_rel((-0.5, -0.5)); + // Donde se ve ahora (el viejo centro corrido a 0,0; y la esquina + // inferior-derecha del original (100,100) ahora en (50,50)). + assert_eq!(hit_test_click(&m, &c, 25.0, 25.0), Some(1)); // dentro del corrido + assert_eq!(hit_test_click(&m, &c, 49.0, 49.0), Some(1)); // casi esquina nueva + // Donde estaba antes pero ya NO (el rect se corrió fuera de ahí). + assert_eq!(hit_test_click(&m, &c, 75.0, 75.0), None); + // Sin transform_rel ese mismo punto SÍ caería dentro (control). + let (m0, c0) = fixture_rel((0.0, 0.0)); // (0,0) = no-op + assert_eq!(hit_test_click(&m0, &c0, 75.0, 75.0), Some(1)); + } + + #[test] + fn hit_test_cursor_directo_y_por_herencia() { + use crate::{hit_test_cursor, Cursor}; + // Padre 200×200 con cursor Text; dentro un hijo 100×100 (arriba-izq) + // SIN cursor propio; y un segundo hijo 50×50 con cursor Pointer. + let hijo_sin = View::<()>::new(Style { + size: Size { width: length(100.0), height: length(100.0) }, + ..Default::default() + }); + let hijo_con = View::<()>::new(Style { + size: Size { width: length(50.0), height: length(50.0) }, + ..Default::default() + }) + .cursor(Cursor::Pointer); + let root = View::<()>::new(Style { + size: Size { width: length(200.0), height: length(200.0) }, + flex_direction: FlexDirection::Column, + align_items: Some(AlignItems::FlexStart), + justify_content: Some(JustifyContent::FlexStart), + ..Default::default() + }) + .cursor(Cursor::Text) + .children(vec![hijo_sin, hijo_con]); + let mut layout = LayoutTree::new(); + let m = mount(&mut layout, root); + let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); + // Sobre el hijo sin cursor (0..100, 0..100) → hereda Text del padre. + assert_eq!(hit_test_cursor(&m, &c, 50.0, 50.0), Some(Cursor::Text)); + // Sobre el hijo con cursor propio (apilado debajo: y 100..150) → Pointer. + assert_eq!(hit_test_cursor(&m, &c, 25.0, 120.0), Some(Cursor::Pointer)); + // Sobre el padre pero fuera de ambos hijos (x>100) → Text del padre. + assert_eq!(hit_test_cursor(&m, &c, 150.0, 50.0), Some(Cursor::Text)); + // Fuera del padre → None (la ventana usa su default). + assert_eq!(hit_test_cursor(&m, &c, 350.0, 350.0), None); + } + #[test] fn tab_traversal_envuelve_en_los_extremos() { use crate::next_focus; @@ -702,4 +1940,155 @@ mod tests { // Lista vacía. assert_eq!(next_focus(&[], Some(10), false), None); } + + #[test] + fn hit_test_scale_directo_y_por_herencia() { + use crate::{hit_test_scale, GesturePhase}; + // Canvas zoomeable 200×200 (declara on_scale); dentro un widget 50×50 + // (arriba-izq) SIN on_scale (no zoomea). El gesto sobre el widget debe + // "caer" al canvas ancestro (herencia, como el cursor), y fuera de + // todo debe dar None (el evento cae al scroll/on_wheel). + let widget = View::<()>::new(Style { + size: Size { width: length(50.0), height: length(50.0) }, + ..Default::default() + }); + let canvas = View::<()>::new(Style { + size: Size { width: length(200.0), height: length(200.0) }, + align_items: Some(AlignItems::FlexStart), + justify_content: Some(JustifyContent::FlexStart), + ..Default::default() + }) + .on_scale(|_phase: GesturePhase, _f, _fx, _fy| None) + .children(vec![widget]); + let mut layout = LayoutTree::new(); + let m = mount(&mut layout, canvas); + let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); + // Sobre el widget sin on_scale (0..50,0..50) → cae al canvas (idx 0). + assert_eq!(hit_test_scale(&m, &c, 25.0, 25.0), Some(0)); + // Sobre el canvas fuera del widget (x>50) → el canvas (idx 0). + assert_eq!(hit_test_scale(&m, &c, 150.0, 25.0), Some(0)); + // Fuera del canvas → None. + assert_eq!(hit_test_scale(&m, &c, 350.0, 350.0), None); + } + + #[test] + fn hit_test_rotate_directo_y_por_herencia() { + use crate::{hit_test_rotate, GesturePhase}; + // Mismo patrón que escala: canvas rotable con un widget no-rotable + // encima; el gesto cae al ancestro que declara on_rotate. + let widget = View::<()>::new(Style { + size: Size { width: length(50.0), height: length(50.0) }, + ..Default::default() + }); + let canvas = View::<()>::new(Style { + size: Size { width: length(200.0), height: length(200.0) }, + align_items: Some(AlignItems::FlexStart), + justify_content: Some(JustifyContent::FlexStart), + ..Default::default() + }) + .on_rotate(|_phase: GesturePhase, _d, _fx, _fy| None) + .children(vec![widget]); + let mut layout = LayoutTree::new(); + let m = mount(&mut layout, canvas); + let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); + assert_eq!(hit_test_rotate(&m, &c, 25.0, 25.0), Some(0)); + assert_eq!(hit_test_rotate(&m, &c, 150.0, 25.0), Some(0)); + assert_eq!(hit_test_rotate(&m, &c, 350.0, 350.0), None); + } + + #[test] + fn hit_test_selectable_solo_sobre_texto_seleccionable() { + use crate::hit_test_selectable; + // Un label seleccionable 100×30 arriba-izq dentro de un panel 200×200 + // SIN selectable. Sólo el label matchea; el resto del panel da None. + let label = View::<()>::new(Style { + size: Size { width: length(100.0), height: length(30.0) }, + ..Default::default() + }) + .text("hola", 14.0, vello::peniko::Color::from_rgba8(255, 255, 255, 255)) + .selectable(7); + let panel = View::<()>::new(Style { + size: Size { width: length(200.0), height: length(200.0) }, + align_items: Some(AlignItems::FlexStart), + justify_content: Some(JustifyContent::FlexStart), + ..Default::default() + }) + .children(vec![label]); + let mut layout = LayoutTree::new(); + let m = mount(&mut layout, panel); + let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); + // Sobre el label (0..100, 0..30) → el label (idx 1). + assert_eq!(hit_test_selectable(&m, &c, 50.0, 15.0), Some(1)); + // Sobre el panel fuera del label → None (el panel no es selectable). + assert_eq!(hit_test_selectable(&m, &c, 150.0, 150.0), None); + } + + #[test] + fn hit_test_scroll_chain_devuelve_front_to_back() { + use crate::hit_test_scroll_chain; + // Padre scrollable 200×200 con un hijo scrollable 100×100 (arriba-izq). + // Bajo el hijo: chain = [hijo, padre]. Bajo el padre pero fuera del + // hijo: chain = [padre]. Fuera de ambos: chain vacío. + let hijo = View::<()>::new(Style { + size: Size { width: length(100.0), height: length(100.0) }, + ..Default::default() + }) + .on_scroll(|_dx, _dy| None::<()>); + let padre = View::<()>::new(Style { + size: Size { width: length(200.0), height: length(200.0) }, + align_items: Some(AlignItems::FlexStart), + justify_content: Some(JustifyContent::FlexStart), + ..Default::default() + }) + .on_scroll(|_dx, _dy| None::<()>) + .children(vec![hijo]); + let mut layout = LayoutTree::new(); + let m = mount(&mut layout, padre); + let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); + // Sobre el hijo (0..100,0..100) → chain = [hijo=1, padre=0]. + let ch = hit_test_scroll_chain(&m, &c, 50.0, 50.0); + assert_eq!(ch, vec![1, 0]); + // Sobre el padre fuera del hijo (x>100) → chain = [padre=0]. + let ch = hit_test_scroll_chain(&m, &c, 150.0, 50.0); + assert_eq!(ch, vec![0]); + // Fuera del padre → chain vacío. + let ch = hit_test_scroll_chain(&m, &c, 350.0, 350.0); + assert!(ch.is_empty()); + } + + #[test] + fn hit_test_double_tap_y_long_press() { + use crate::{hit_test_double_tap, hit_test_long_press}; + // Un nodo 100×100 con doble-tap; otro 100×100 apilado debajo con + // long-press. Cada hit-test sólo ve su propio gesto. + let arriba = View::<()>::new(Style { + size: Size { width: length(100.0), height: length(100.0) }, + ..Default::default() + }) + .on_double_tap(()); + let abajo = View::<()>::new(Style { + size: Size { width: length(100.0), height: length(100.0) }, + ..Default::default() + }) + .on_long_press(()); + let root = View::<()>::new(Style { + flex_direction: FlexDirection::Column, + align_items: Some(AlignItems::FlexStart), + justify_content: Some(JustifyContent::FlexStart), + ..Default::default() + }) + .children(vec![arriba, abajo]); + let mut layout = LayoutTree::new(); + let m = mount(&mut layout, root); + let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); + // Nodo de arriba (y 0..100): doble-tap sí, long-press no. + assert_eq!(hit_test_double_tap(&m, &c, 50.0, 50.0), Some(1)); + assert_eq!(hit_test_long_press(&m, &c, 50.0, 50.0), None); + // Nodo de abajo (y 100..200): long-press sí, doble-tap no. + assert_eq!(hit_test_long_press(&m, &c, 50.0, 150.0), Some(2)); + assert_eq!(hit_test_double_tap(&m, &c, 50.0, 150.0), None); + // Fuera de ambos. + assert_eq!(hit_test_double_tap(&m, &c, 300.0, 300.0), None); + assert_eq!(hit_test_long_press(&m, &c, 300.0, 300.0), None); + } } diff --git a/llimphi-compositor/src/ripple.rs b/llimphi-compositor/src/ripple.rs new file mode 100644 index 0000000..8fc1912 --- /dev/null +++ b/llimphi-compositor/src/ripple.rs @@ -0,0 +1,277 @@ +//! **Ripple / InkWell** — el feedback de tap de Material: un círculo que se +//! expande desde el punto donde el dedo/cursor presionó, clipeado al contorno +//! del nodo, desvaneciéndose mientras crece. Es puro feedback visual: no vive +//! en el `Model` de la app (igual que las animaciones implícitas de +//! [`crate::AnimRegistry`]) sino en un registro retenido por el runtime entre +//! frames. +//! +//! **Flujo.** Un `View` se marca ripple-capaz con [`crate::View::ripple`] +//! (key estable + color). Cuando un press izquierdo cae sobre ese nodo, el +//! runtime hace [`crate::hit_test_ripple`], calcula el punto local del tap y +//! llama [`RippleRegistry::trigger`] — que guarda una "salpicadura" con su +//! reloj. En cada redraw, DESPUÉS del paint del contenido, el runtime llama +//! [`RippleRegistry::paint`], que por cada salpicadura viva resuelve el rect +//! actual del nodo (puede haber cambiado de tamaño), dibuja el círculo +//! expansivo recortado al rrect del nodo y devuelve `true` si alguna sigue +//! viva → el runtime pide otro frame (ticker autodetenido, sin `spawn_periodic`). +//! +//! **Aditivo.** El ripple NO toca el camino click/drag: se dispara en el press +//! por su propio hit-test, conviva o no el nodo con `on_click`. Un botón normal +//! (`on_click` + `.ripple(...)`) recibe ambos. +//! +//! **Limitación v1.** Como la captura de subescenas del fade-out +//! ([`crate::AnimRegistry`]), el paint usa el rect en coordenadas absolutas del +//! layout e ignora los `transform` de ancestros — alcanza para botones/cards +//! (rara vez transformados). La salpicadura es one-shot (expande + se desvanece +//! en `duration`); no hay "mantener mientras se sostiene el press" (Material +//! `hold`), que requeriría rastrear el release por key. + +use std::time::{Duration, Instant}; + +use vello::kurbo::{Affine, Circle}; +use vello::peniko::{BlendMode, Color, Fill}; +use vello::Scene; + +use crate::{ComputedLayout, Mounted}; + +/// Declara que este nodo emite un **ripple** (salpicadura Material) al recibir +/// un press. `key` debe ser estable entre rebuilds del `View` (igual que la +/// key de [`crate::Anim`]) — es lo que enlaza la salpicadura retenida con el +/// nodo entre frames. `color` es el tinte de la onda (típicamente +/// semitransparente, p. ej. blanco a alpha ~0.25 sobre superficies oscuras o +/// negro a alpha ~0.12 sobre claras); su alpha se multiplica por el fade. +#[derive(Clone, Copy, Debug)] +pub struct Ripple { + pub key: u64, + pub color: Color, + pub duration: Duration, +} + +/// Una salpicadura viva: el punto de origen **relativo al rect del nodo** al +/// momento del press, su color/duración y el reloj de expansión. +struct Splash { + key: u64, + /// Origen del tap relativo a la esquina superior-izquierda del rect del + /// nodo (mismo espacio que los handlers `*_at`). Se reancla al rect actual + /// del nodo en cada frame, así la onda sigue al nodo si éste se mueve. + lx: f32, + ly: f32, + color: Color, + start: Instant, + duration: Duration, + easing: fn(f32) -> f32, +} + +impl Splash { + /// Progreso `[0,1]` sin easing (lineal en el tiempo). + fn raw(&self, now: Instant) -> f32 { + if self.duration.is_zero() { + return 1.0; + } + let elapsed = now.saturating_duration_since(self.start).as_secs_f32(); + (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0) + } + + fn done(&self, now: Instant) -> bool { + now.saturating_duration_since(self.start) >= self.duration + } +} + +/// Registro de ripples vivos, retenido por el runtime entre frames. Una +/// instancia por ventana; el runtime llama [`Self::trigger`] en el press y +/// [`Self::paint`] tras el paint del contenido. +#[derive(Default)] +pub struct RippleRegistry { + splashes: Vec, +} + +impl RippleRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Registra una salpicadura nueva sobre el nodo de key `key`, originada en + /// `(lx, ly)` relativo a su rect. `now` es el instante del press. Varios + /// presses rápidos apilan ondas concurrentes (como Material). + pub fn trigger( + &mut self, + key: u64, + lx: f32, + ly: f32, + color: Color, + duration: Duration, + now: Instant, + ) { + self.splashes.push(Splash { + key, + lx, + ly, + color, + start: now, + duration, + easing: crate::ease_out_cubic, + }); + } + + /// `true` si hay alguna salpicadura viva (el runtime ya lo sabe por el + /// retorno de [`Self::paint`], pero es cómodo para decidir antes). + pub fn animating(&self) -> bool { + !self.splashes.is_empty() + } + + /// Pinta las salpicaduras vivas sobre `scene`, cada una como un círculo que + /// crece (radio con ease-out hasta cubrir el nodo) y se desvanece, recortado + /// al contorno redondeado del nodo. Resuelve el rect de cada nodo por su + /// `ripple.key` en `mounted`/`computed` (así sigue al nodo si se redimensiona). + /// Descarta las agotadas. Devuelve `true` si queda alguna viva → pedir frame. + /// + /// Llamar DESPUÉS del paint del contenido (la onda va encima, translúcida). + pub fn paint( + &mut self, + scene: &mut Scene, + mounted: &Mounted, + computed: &ComputedLayout, + now: Instant, + ) -> bool { + // Descartá primero las agotadas (no dependen del nodo). + self.splashes.retain(|s| !s.done(now)); + if self.splashes.is_empty() { + return false; + } + for s in &self.splashes { + // Resolvé el nodo ripple de esta key (el primero que la declare). + let Some(node) = mounted.nodes.iter().find(|n| { + n.ripple.map(|r| r.key) == Some(s.key) + }) else { + continue; + }; + let Some(r) = computed.get(node.id) else { + continue; + }; + if r.w <= 0.0 || r.h <= 0.0 { + continue; + } + let cx = r.x as f64 + s.lx as f64; + let cy = r.y as f64 + s.ly as f64; + // Radio máximo = distancia al rincón más lejano, así la onda llega a + // cubrir todo el nodo cualquiera sea el punto de origen. + let corners = [ + (r.x as f64, r.y as f64), + ((r.x + r.w) as f64, r.y as f64), + (r.x as f64, (r.y + r.h) as f64), + ((r.x + r.w) as f64, (r.y + r.h) as f64), + ]; + let max_radius = corners + .iter() + .map(|(px, py)| ((px - cx).powi(2) + (py - cy).powi(2)).sqrt()) + .fold(0.0_f64, f64::max); + let t = s.raw(now); + let radius = (s.easing)(t) as f64 * max_radius; + if radius <= 0.0 { + continue; + } + // Fade: la onda arranca a su alpha y se apaga al expandirse. + let fade = 1.0 - t; + let mut col = s.color; + col.components[3] *= fade; + if col.components[3] <= 0.0 { + continue; + } + // Recorte al contorno del nodo (respeta radio/esquinas), para que la + // onda no sangre fuera de un botón redondeado. + let rrect = crate::render::node_rrect( + r.x as f64, + r.y as f64, + (r.x + r.w) as f64, + (r.y + r.h) as f64, + node.radius, + node.corner_radii, + 0.0, + ); + scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, Affine::IDENTITY, &rrect); + let circle = Circle::new((cx, cy), radius); + scene.fill(Fill::NonZero, Affine::IDENTITY, col, None, &circle); + scene.pop_layer(); + } + !self.splashes.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{mount, View}; + use llimphi_layout::taffy::prelude::*; + use llimphi_layout::{LayoutTree, Style}; + + fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color { + Color::from_rgba8(r, g, b, a) + } + + /// Monta un botón 100×100 con ripple(key=5) y devuelve (mounted, computed). + fn boton() -> (Mounted<()>, ComputedLayout) { + let v = View::<()>::new(Style { + size: Size { width: length(100.0), height: length(100.0) }, + ..Default::default() + }) + .ripple(5, rgba(255, 255, 255, 80)); + let mut layout = LayoutTree::new(); + let m = mount(&mut layout, v); + let c = layout.compute(m.root, (200.0, 200.0)).expect("layout"); + (m, c) + } + + #[test] + fn sin_trigger_no_anima() { + let mut reg = RippleRegistry::new(); + let (m, c) = boton(); + let mut scene = Scene::new(); + assert!(!reg.paint(&mut scene, &m, &c, Instant::now())); + assert!(!reg.animating()); + } + + #[test] + fn trigger_anima_y_se_autodetiene() { + let mut reg = RippleRegistry::new(); + let (m, c) = boton(); + let t0 = Instant::now(); + reg.trigger(5, 50.0, 50.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0); + assert!(reg.animating(), "tras el trigger hay onda viva"); + let mut scene = Scene::new(); + // A mitad de la duración sigue animando. + assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(100))); + // Pasada la duración, se descarta y el ticker para. + assert!(!reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(250))); + assert!(!reg.animating()); + } + + #[test] + fn presses_concurrentes_apilan_ondas() { + let mut reg = RippleRegistry::new(); + let t0 = Instant::now(); + reg.trigger(5, 10.0, 10.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0); + reg.trigger(5, 90.0, 90.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0 + Duration::from_millis(20)); + assert_eq!(reg.splashes.len(), 2); + let (m, c) = boton(); + let mut scene = Scene::new(); + // En t0+100 la primera vive (80ms restantes) y la segunda también. + assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(100))); + assert_eq!(reg.splashes.len(), 2); + // En t0+210 la primera murió (210>200) pero la segunda vive (190<200). + assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(210))); + assert_eq!(reg.splashes.len(), 1); + } + + #[test] + fn key_inexistente_se_descarta_al_agotarse_sin_panico() { + // Una onda cuya key no existe en el árbol no debe pintar ni panico; + // simplemente no encuentra nodo y se descarta cuando su reloj vence. + let mut reg = RippleRegistry::new(); + let t0 = Instant::now(); + reg.trigger(999, 0.0, 0.0, rgba(255, 255, 255, 80), Duration::from_millis(100), t0); + let (m, c) = boton(); + let mut scene = Scene::new(); + assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(50))); + assert!(!reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(150))); + } +} diff --git a/llimphi-compositor/src/semantics.rs b/llimphi-compositor/src/semantics.rs new file mode 100644 index 0000000..c484d27 --- /dev/null +++ b/llimphi-compositor/src/semantics.rs @@ -0,0 +1,226 @@ +//! Modelo de **semántica accesible** de un nodo. Es el dato que el runtime +//! traduce a un árbol [AccessKit](https://accesskit.dev) por frame para +//! alimentar lectores de pantalla (NVDA, VoiceOver, Orca, TalkBack) y otras +//! ayudas técnicas — TTS, navegación por voz, switch control. +//! +//! Este módulo es **pura data**: define los tipos sin acoplarse al crate +//! `accesskit`. La conversión a `accesskit::Node` vive en `llimphi-ui::a11y` +//! (iter 2 del plan), donde el cableado del adapter winit ya importa la +//! librería. Tener acá solo el modelo permite: +//! +//! - Compilar el compositor con o sin la integración AccessKit habilitada. +//! - Testear semántica a nivel "qué declaran los widgets" sin levantar un +//! adapter ni un lector real. +//! - Mantener la API estable aunque cambien versiones de `accesskit`. +//! +//! ## Cuándo declarar semántica +//! +//! - **Siempre** en controles interactivos: botones, inputs, checkboxes, tabs, +//! ítems de menú, sliders. Sin rol declarado, el lector no sabe que el nodo +//! ES un botón aunque tenga `on_click`. +//! - **Para texto significativo** que no es un botón: títulos (`Heading`), +//! etiquetas asociadas, valores (`Label` / `Static`). El text de un nodo se +//! lee igual aunque no tenga `semantics`, pero un rol explícito mejora la +//! navegación por rol de los lectores. +//! - **Para grouping**: tabbar, dock, toolbars, listas — `Role::Group` o un +//! rol específico (`TabList`, `Menu`, `Toolbar`) ayuda a saltar bloques. +//! +//! ## Cuándo NO declarar +//! +//! Decorativo puro (un divider, un fondo con gradiente, una sombra) **no debe** +//! declarar semántica — los lectores ya filtran texto vacío, pero un rol +//! superfluo (`Role::Group` en cada `View` envoltorio) ensucia la navegación. + +use std::sync::Arc; + +/// Rol semántico del nodo. Los nombres y la granularidad siguen los roles de +/// AccessKit / ARIA. Subset acotado: agregamos lo que falte cuando aparezca un +/// caller real (regla del repo — no diseñamos para lo hipotético). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Role { + /// Botón clickeable. El lector dice "botón