diff --git a/Cargo.lock b/Cargo.lock index 10a5116..d8b1c80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 4 +version = 3 [[package]] name = "ab_glyph" @@ -29,9 +29,9 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.19.0" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e98018dbef3583d751dbb96e07b8728fb99581360e1c3df408af16f4a80b821" +checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636" dependencies = [ "accesskit", "accesskit_consumer", @@ -43,9 +43,9 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" dependencies = [ "accesskit", "hashbrown 0.16.1", @@ -53,9 +53,9 @@ dependencies = [ [[package]] name = "accesskit_ios" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02ecb52198c7cf5f8d3e9ffc03d2ca0a5c7201926befd96721437829da4c5c6a" +checksum = "750c4e9f6ce888dfe8a10c0f1b5ceb646a7854fd46579d74919219d1bb314083" dependencies = [ "accesskit", "accesskit_consumer", @@ -67,9 +67,9 @@ dependencies = [ [[package]] name = "accesskit_macos" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17cb8b66cef272d48161b02a6317cc2bdd5f98bb0a5e79c68f704a5862aa396b" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" dependencies = [ "accesskit", "accesskit_consumer", @@ -81,9 +81,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5376ba4cc23312587634abb5250b1ce8618f01a55915608209aafd01efb4bf8c" +checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -99,9 +99,9 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.33.1" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" dependencies = [ "accesskit", "accesskit_consumer", @@ -113,9 +113,9 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.33.1" +version = "0.33.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe5862066316f6caaf02cd3aecd54bced25503ac5dbbfd0d03a42bc1246217" +checksum = "d5b41e63a69f36d9f1f41e70464c7e5f72eee485ef26aab19f0b4f86e6c0a84c" dependencies = [ "accesskit", "accesskit_ios", @@ -167,7 +167,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.12.1", + "bitflags 2.13.1", "cc", "jni", "libc", @@ -176,7 +176,7 @@ dependencies = [ "ndk-context", "ndk-sys", "num_enum", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -221,6 +221,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -232,9 +233,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "as-raw-xcb-connection" @@ -344,7 +345,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -373,13 +374,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -446,6 +447,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit-set" version = "0.8.0" @@ -484,9 +491,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.12.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -550,22 +557,22 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -582,9 +589,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "calloop" @@ -592,7 +599,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "log", "polling", "rustix 0.38.44", @@ -614,9 +621,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "jobserver", @@ -632,9 +639,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chrono" @@ -664,7 +671,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -698,6 +705,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -779,7 +792,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "libc", ] @@ -819,9 +832,9 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -835,6 +848,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "directories" version = "5.0.1" @@ -868,7 +887,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "objc2 0.6.4", ] @@ -880,7 +899,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -949,7 +968,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1006,9 +1025,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fax" @@ -1037,6 +1056,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1047,6 +1072,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "foldhash" version = "0.1.5" @@ -1077,6 +1108,38 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "font-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2 0.9.11", + "slotmap", + "tinyvec", + "ttf-parser 0.25.1", +] + [[package]] name = "fontdue" version = "0.9.3" @@ -1097,19 +1160,31 @@ dependencies = [ "hashbrown 0.15.5", "icu_locale_core", "linebender_resource_handle", - "memmap2", + "memmap2 0.9.11", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-text", "objc2-foundation 0.3.2", "read-fonts 0.35.0", - "roxmltree", + "roxmltree 0.20.0", "smallvec", "windows 0.58.0", "windows-core 0.58.0", "yeslogic-fontconfig-sys", ] +[[package]] +name = "foreign-lottie" +version = "0.1.0" +dependencies = [ + "kurbo", + "peniko", + "serde", + "serde_json", + "serde_repr", + "vello", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1122,13 +1197,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -1142,10 +1217,19 @@ name = "foreign-vox" version = "0.1.0" [[package]] -name = "futures-core" -version = "0.3.32" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-intrusive" @@ -1160,9 +1244,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-lite" @@ -1179,26 +1263,26 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-macro", @@ -1251,6 +1335,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -1319,21 +1413,21 @@ dependencies = [ [[package]] name = "gpu-alloc" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "gpu-alloc-types", ] [[package]] name = "gpu-alloc-types" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", ] [[package]] @@ -1358,8 +1452,8 @@ dependencies = [ "hashbrown 0.16.1", "log", "presser", - "thiserror 2.0.18", - "windows 0.58.0", + "thiserror 2.0.19", + "windows 0.62.2", ] [[package]] @@ -1368,7 +1462,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "gpu-descriptor-types", "hashbrown 0.15.5", ] @@ -1379,9 +1473,13 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", ] +[[package]] +name = "grafo-nav" +version = "0.1.0" + [[package]] name = "grid" version = "1.0.1" @@ -1425,7 +1523,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "bytemuck", "core_maths", "read-fonts 0.35.0", @@ -1516,7 +1614,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.62.2", ] [[package]] @@ -1528,6 +1626,20 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.2.0" @@ -1539,6 +1651,83 @@ dependencies = [ "serde", "tinystr", "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] @@ -1549,12 +1738,34 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.1", + "qoi", "tiff", + "zune-core", + "zune-jpeg", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indexmap" version = "2.14.0" @@ -1583,7 +1794,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.19", "walkdir", "windows-link", ] @@ -1598,7 +1809,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.119", ] [[package]] @@ -1626,28 +1837,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1681,10 +1891,16 @@ dependencies = [ ] [[package]] -name = "libc" -version = "0.2.186" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -1704,14 +1920,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "libc", "plain", - "redox_syscall 0.8.1", + "redox_syscall 0.9.0", ] [[package]] @@ -1767,6 +1983,31 @@ dependencies = [ "wgpu 27.0.1", ] +[[package]] +name = "llimphi-anim" +version = "0.1.0" +dependencies = [ + "kurbo", +] + +[[package]] +name = "llimphi-anim-studio" +version = "0.1.0" +dependencies = [ + "llimphi-anim", + "llimphi-clipboard", + "llimphi-image", + "llimphi-mesh", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-button", + "llimphi-widget-nodegraph", + "llimphi-widget-slider", + "llimphi-widget-text-input", + "ron", + "serde", +] + [[package]] name = "llimphi-clipboard" version = "0.1.0" @@ -1795,6 +2036,48 @@ dependencies = [ "wgpu 27.0.1", ] +[[package]] +name = "llimphi-gallery" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-icons", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-avatar", + "llimphi-widget-badge", + "llimphi-widget-breadcrumb", + "llimphi-widget-card", + "llimphi-widget-context-menu", + "llimphi-widget-empty", + "llimphi-widget-field", + "llimphi-widget-menubar", + "llimphi-widget-modal", + "llimphi-widget-panel", + "llimphi-widget-progress", + "llimphi-widget-segmented", + "llimphi-widget-shortcuts-help", + "llimphi-widget-skeleton", + "llimphi-widget-spinner", + "llimphi-widget-splash", + "llimphi-widget-status-bar", + "llimphi-widget-switch", + "llimphi-widget-toast", + "llimphi-widget-tooltip", + "llimphi-widget-wawa-mark", +] + +[[package]] +name = "llimphi-gpu-bench" +version = "0.1.0" +dependencies = [ + "llimphi-hal", + "llimphi-raster", + "png 0.18.1", + "pollster", + "vello", +] + [[package]] name = "llimphi-hal" version = "0.1.0" @@ -1812,6 +2095,26 @@ dependencies = [ "llimphi-ui", ] +[[package]] +name = "llimphi-image" +version = "0.1.0" +dependencies = [ + "image", + "llimphi-raster", + "ureq", +] + +[[package]] +name = "llimphi-layer" +version = "0.1.0" +dependencies = [ + "llimphi-ui", + "pollster", + "raw-window-handle", + "smithay-client-toolkit", + "wayland-client", +] + [[package]] name = "llimphi-layout" version = "0.1.0" @@ -1822,6 +2125,24 @@ dependencies = [ "taffy", ] +[[package]] +name = "llimphi-lottie" +version = "0.1.0" +dependencies = [ + "foreign-lottie", + "llimphi-anim", + "llimphi-ui", +] + +[[package]] +name = "llimphi-mesh" +version = "0.1.0" +dependencies = [ + "llimphi-anim", + "llimphi-lottie", + "llimphi-ui", +] + [[package]] name = "llimphi-module-bookmarks" version = "0.1.0" @@ -1918,6 +2239,37 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "llimphi-svg" +version = "0.1.0" +dependencies = [ + "llimphi-ui", + "vello_svg", +] + +[[package]] +name = "llimphi-term-graphics" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "flate2", + "image", +] + +[[package]] +name = "llimphi-test" +version = "0.1.0" +dependencies = [ + "image", + "llimphi-compositor", + "llimphi-hal", + "llimphi-layout", + "llimphi-raster", + "llimphi-text", + "llimphi-theme", + "pollster", +] + [[package]] name = "llimphi-text" version = "0.1.0" @@ -1948,6 +2300,7 @@ dependencies = [ "llimphi-layout", "llimphi-raster", "llimphi-text", + "log", "pollster", "uuid", ] @@ -1968,10 +2321,42 @@ dependencies = [ "serde", ] +[[package]] +name = "llimphi-wasm-app-sdk" +version = "0.1.0" +dependencies = [ + "llimphi-wire-view", + "postcard", + "serde", +] + +[[package]] +name = "llimphi-wasm-demo-counter" +version = "0.1.0" +dependencies = [ + "llimphi-wasm-app-sdk", +] + +[[package]] +name = "llimphi-wasm-demo-form" +version = "0.1.0" +dependencies = [ + "llimphi-wasm-app-sdk", +] + +[[package]] +name = "llimphi-wasm-wasi" +version = "0.1.0" +dependencies = [ + "wasmi", + "wat", +] + [[package]] name = "llimphi-widget-app-header" version = "0.1.0" dependencies = [ + "llimphi-icons", "llimphi-theme", "llimphi-ui", "llimphi-widget-panel", @@ -2057,12 +2442,16 @@ dependencies = [ "llimphi-ui", "llimphi-widget-slider", "llimphi-widget-text-input", + "png 0.18.1", + "pollster", ] [[package]] name = "llimphi-widget-context-menu" version = "0.1.0" dependencies = [ + "llimphi-icons", + "llimphi-motion", "llimphi-theme", "llimphi-ui", "llimphi-widget-panel", @@ -2074,6 +2463,7 @@ version = "0.1.0" dependencies = [ "llimphi-theme", "llimphi-ui", + "llimphi-widget-list", ] [[package]] @@ -2082,6 +2472,7 @@ version = "0.1.0" dependencies = [ "llimphi-theme", "llimphi-ui", + "llimphi-widget-badge", ] [[package]] @@ -2126,6 +2517,27 @@ dependencies = [ "llimphi-ui", ] +[[package]] +name = "llimphi-widget-gallery" +version = "0.1.0" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-app-header", + "llimphi-widget-banner", + "llimphi-widget-button", + "llimphi-widget-context-menu", + "llimphi-widget-list", + "llimphi-widget-menubar", + "llimphi-widget-splitter", + "llimphi-widget-stat-card", + "llimphi-widget-tabs", + "llimphi-widget-text-input", + "llimphi-widget-theme-switcher", + "llimphi-widget-tiled", +] + [[package]] name = "llimphi-widget-gauge" version = "0.1.0" @@ -2140,6 +2552,7 @@ version = "0.1.0" dependencies = [ "llimphi-theme", "llimphi-ui", + "llimphi-widget-list", ] [[package]] @@ -2150,6 +2563,15 @@ dependencies = [ "llimphi-ui", ] +[[package]] +name = "llimphi-widget-lazy-list" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-scroll", +] + [[package]] name = "llimphi-widget-list" version = "0.1.0" @@ -2182,8 +2604,10 @@ dependencies = [ name = "llimphi-widget-navigator" version = "0.1.0" dependencies = [ + "grafo-nav", "llimphi-theme", "llimphi-ui", + "llimphi-widget-context-menu", "llimphi-widget-nodegraph", "llimphi-widget-segmented", "llimphi-widget-tree", @@ -2221,6 +2645,17 @@ dependencies = [ "llimphi-ui", ] +[[package]] +name = "llimphi-widget-rag-sidebar" +version = "0.1.0" +dependencies = [ + "llimphi-icons", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-dock-rail", + "llimphi-widget-segmented", +] + [[package]] name = "llimphi-widget-range-slider" version = "0.1.0" @@ -2237,6 +2672,25 @@ dependencies = [ "llimphi-ui", ] +[[package]] +name = "llimphi-widget-rive-button" +version = "0.1.0" +dependencies = [ + "llimphi-anim", + "llimphi-lottie", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-router" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-button", + "llimphi-widget-hero", +] + [[package]] name = "llimphi-widget-scaffold" version = "0.1.0" @@ -2383,8 +2837,7 @@ dependencies = [ name = "llimphi-widget-text-area" version = "0.1.0" dependencies = [ - "llimphi-theme", - "llimphi-ui", + "llimphi-widget-text-input", ] [[package]] @@ -2394,7 +2847,6 @@ dependencies = [ "llimphi-theme", "llimphi-ui", "llimphi-widget-text-editor-core", - "tree-sitter", ] [[package]] @@ -2421,6 +2873,7 @@ dependencies = [ name = "llimphi-widget-text-input" version = "0.1.0" dependencies = [ + "llimphi-motion", "llimphi-theme", "llimphi-ui", "llimphi-widget-text-editor", @@ -2491,6 +2944,7 @@ version = "0.1.0" dependencies = [ "llimphi-theme", "llimphi-ui", + "llimphi-widget-list", ] [[package]] @@ -2515,6 +2969,14 @@ dependencies = [ "llimphi-ui", ] +[[package]] +name = "llimphi-wire-view" +version = "0.1.0" +dependencies = [ + "postcard", + "serde", +] + [[package]] name = "llimphi-workspace" version = "0.1.0" @@ -2535,9 +2997,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.31" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "malloc_buf" @@ -2550,15 +3012,24 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "43a5a03cefb0d953ec0be133036f14e109412fa594edc2f77227249db66cc3ed" +dependencies = [ + "libc", +] + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -2578,7 +3049,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block", "core-graphics-types 0.2.0", "foreign-types", @@ -2599,9 +3070,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -2626,7 +3097,7 @@ checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" dependencies = [ "arrayvec", "bit-set 0.8.0", - "bitflags 2.12.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "codespan-reporting 0.12.0", @@ -2640,19 +3111,19 @@ dependencies = [ "once_cell", "rustc-hash", "spirv 0.3.0+sdk-1.3.268.0", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-ident", ] [[package]] name = "naga" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd91265cc2454558f659b3b4b9640f0ddb8cc6521277f166b8a8c181c898079" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", "bit-set 0.9.1", - "bitflags 2.12.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "codespan-reporting 0.13.1", @@ -2666,7 +3137,7 @@ dependencies = [ "once_cell", "rustc-hash", "spirv 0.4.0+sdk-1.4.341.0", - "thiserror 2.0.18", + "thiserror 2.0.19", "unicode-ident", ] @@ -2676,7 +3147,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -2700,6 +3171,15 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nucleo-matcher" version = "0.3.1" @@ -2739,7 +3219,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2782,7 +3262,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -2798,7 +3278,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -2810,7 +3290,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -2834,7 +3314,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2846,7 +3326,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", ] @@ -2857,7 +3337,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -2894,7 +3374,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "objc2-core-foundation", ] @@ -2910,7 +3390,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2 0.5.1", "dispatch", "libc", @@ -2923,7 +3403,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -2934,7 +3414,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -2957,7 +3437,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2969,7 +3449,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-foundation 0.3.2", @@ -2981,7 +3461,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2994,7 +3474,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3017,7 +3497,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", @@ -3049,7 +3529,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -3080,9 +3560,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "4.6.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ "num-traits", ] @@ -3097,6 +3577,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -3174,6 +3664,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -3205,7 +3706,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3217,6 +3718,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.13" @@ -3234,7 +3741,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3285,7 +3792,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -3323,9 +3830,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "portable-atomic-util" @@ -3349,6 +3856,15 @@ dependencies = [ "serde", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "presser" version = "0.3.1" @@ -3361,14 +3877,14 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -3381,9 +3897,18 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] [[package]] name = "quick-error" @@ -3393,19 +3918,18 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", - "serde", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -3477,6 +4001,17 @@ dependencies = [ "font-types 0.11.3", ] +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.2", + "once_cell", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -3492,16 +4027,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", ] [[package]] name = "redox_syscall" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", ] [[package]] @@ -3517,9 +4052,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3529,9 +4064,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3540,9 +4075,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "renderdoc-sys" @@ -3550,14 +4085,28 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[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", + "base64 0.21.7", + "bitflags 2.13.1", "serde", "serde_derive", ] @@ -3578,6 +4127,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3599,7 +4157,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3612,7 +4170,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -3620,10 +4178,63 @@ dependencies = [ ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "rustls" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser 0.25.1", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] [[package]] name = "same-file" @@ -3654,7 +4265,7 @@ checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" dependencies = [ "ab_glyph", "log", - "memmap2", + "memmap2 0.9.11", "smithay-client-toolkit", "tiny-skia", ] @@ -3667,9 +4278,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3677,29 +4288,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -3710,13 +4321,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -3746,15 +4357,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -3772,6 +4383,15 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -3808,6 +4428,16 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + [[package]] name = "slab" version = "0.4.12" @@ -3825,9 +4455,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smithay-client-toolkit" @@ -3835,13 +4465,15 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", + "bytemuck", "calloop", "calloop-wayland-source", "cursor-icon", "libc", "log", - "memmap2", + "memmap2 0.9.11", + "pkg-config", "rustix 0.38.44", "thiserror 1.0.69", "wayland-backend", @@ -3851,6 +4483,7 @@ dependencies = [ "wayland-protocols", "wayland-protocols-wlr", "wayland-scanner", + "xkbcommon", "xkeysym", ] @@ -3865,9 +4498,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3875,9 +4508,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -3888,7 +4521,7 @@ version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", ] [[package]] @@ -3897,7 +4530,7 @@ 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", + "bitflags 2.13.1", ] [[package]] @@ -3929,6 +4562,25 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "string-interner" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0" +dependencies = [ + "hashbrown 0.15.5", + "serde", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "svg_fmt" @@ -3937,27 +4589,59 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" [[package]] -name = "swash" -version = "0.2.7" +name = "svgtypes" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ - "skrifa 0.40.0", + "kurbo", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", "yazi", "zeno", ] [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "taffy" version = "0.9.2" @@ -3977,7 +4661,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -4003,11 +4687,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.19", ] [[package]] @@ -4018,18 +4702,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -4083,10 +4767,25 @@ dependencies = [ ] [[package]] -name = "tokio" -version = "1.52.3" +name = "tinyvec" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -4101,13 +4800,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4156,14 +4855,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -4172,7 +4871,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -4200,7 +4899,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4251,6 +4950,17 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "ttf-parser" version = "0.21.1" @@ -4262,6 +4972,9 @@ name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] [[package]] name = "uds_windows" @@ -4274,12 +4987,42 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4287,16 +5030,88 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] -name = "unicode-width" -version = "0.1.14" +name = "unicode-vo" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.3" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -4317,7 +5132,7 @@ dependencies = [ "png 0.17.16", "skrifa 0.40.0", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.19", "vello_encoding", "vello_shaders", "wgpu 27.0.1", @@ -4336,7 +5151,7 @@ dependencies = [ "log", "peniko", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", ] [[package]] @@ -4362,10 +5177,10 @@ dependencies = [ "glifo", "hashbrown 0.17.1", "log", - "thiserror 2.0.18", + "thiserror 2.0.19", "vello_common", "vello_sparse_shaders", - "wgpu 29.0.3", + "wgpu 29.0.4", ] [[package]] @@ -4377,7 +5192,7 @@ dependencies = [ "bytemuck", "log", "naga 27.0.3", - "thiserror 2.0.18", + "thiserror 2.0.19", "vello_encoding", ] @@ -4387,6 +5202,17 @@ version = "0.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e004cfe28eb71738643f60460b9cae2236eba57b351afebf244148256ff25d8b" +[[package]] +name = "vello_svg" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79dfa03829a8fb45ad458a05bd533d399d2259d042cdd78f391b290dbbdf1bd3" +dependencies = [ + "thiserror 2.0.19", + "usvg", + "vello", +] + [[package]] name = "version_check" version = "0.9.5" @@ -4411,18 +5237,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -4433,9 +5259,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4443,9 +5269,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4453,31 +5279,125 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] [[package]] -name = "wayland-backend" -version = "0.3.15" +name = "wasm-encoder" +version = "0.254.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393" +dependencies = [ + "leb128fmt", + "wasmparser 0.254.0", +] + +[[package]] +name = "wasmi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2300d0f78cba12f14e29e8dd157ea64050c0a688179aefdb2050105805594a0c" +dependencies = [ + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser 0.239.0", + "wat", +] + +[[package]] +name = "wasmi_collections" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a8c42a2a76148d43097b1d7cc2a5bf33d5c23bd4dd69015fc887e311767884" +dependencies = [ + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9013136083d988725953390bf668b64b7a218fabf26f8b913bbc59546b97ee27" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1fa003f79156f406d62ef0e1464dc03e11ace37170e9fa7524299a75ad8f68" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags 2.13.1", + "indexmap", +] + +[[package]] +name = "wasmparser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27" +dependencies = [ + "bitflags 2.13.1", + "indexmap", + "semver", +] + +[[package]] +name = "wast" +version = "254.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ed4dfc8f6b9fc38b231065e2cdfbf7359af5ab945990abf09658dcc63c3e32" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder", +] + +[[package]] +name = "wat" +version = "1.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7127f7f9b8f127c879991cecd35f494e4628bae1b0874c681414d8d8831e952c" +dependencies = [ + "wast", +] + +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" dependencies = [ "cc", "downcast-rs", @@ -4489,11 +5409,11 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.14" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -4505,7 +5425,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "cursor-icon", "wayland-backend", ] @@ -4523,11 +5443,11 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.12" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -4539,7 +5459,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4552,7 +5472,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -4561,9 +5481,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", "quick-xml", @@ -4584,9 +5504,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -4602,6 +5522,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -4615,7 +5553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" dependencies = [ "arrayvec", - "bitflags 2.12.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "document-features", @@ -4639,12 +5577,12 @@ dependencies = [ [[package]] name = "wgpu" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb3feacc458f7bee8bc1737149b42b6c731aa461039a4264a67bb6681646b250" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" dependencies = [ "arrayvec", - "bitflags 2.12.1", + "bitflags 2.13.1", "bytemuck", "cfg-if", "cfg_aliases", @@ -4652,7 +5590,7 @@ dependencies = [ "hashbrown 0.16.1", "js-sys", "log", - "naga 29.0.3", + "naga 29.0.4", "parking_lot", "portable-atomic", "profiling", @@ -4662,9 +5600,9 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "wgpu-core 29.0.3", - "wgpu-hal 29.0.3", - "wgpu-types 29.0.3", + "wgpu-core 29.0.4", + "wgpu-hal 29.0.4", + "wgpu-types 29.0.4", ] [[package]] @@ -4676,7 +5614,7 @@ dependencies = [ "arrayvec", "bit-set 0.8.0", "bit-vec 0.8.0", - "bitflags 2.12.1", + "bitflags 2.13.1", "bytemuck", "cfg_aliases", "document-features", @@ -4691,7 +5629,7 @@ dependencies = [ "raw-window-handle", "rustc-hash", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "wgpu-core-deps-apple 27.0.0", "wgpu-core-deps-emscripten 27.0.0", "wgpu-core-deps-windows-linux-android 27.0.0", @@ -4701,21 +5639,21 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02da3ad1b568337f25513b317870960ef87073ea0945502e44b864b67a8c77b7" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", "bit-set 0.9.1", "bit-vec 0.9.1", - "bitflags 2.12.1", + "bitflags 2.13.1", "bytemuck", "cfg_aliases", "document-features", "hashbrown 0.16.1", "indexmap", "log", - "naga 29.0.3", + "naga 29.0.4", "once_cell", "parking_lot", "portable-atomic", @@ -4723,13 +5661,13 @@ dependencies = [ "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", + "thiserror 2.0.19", + "wgpu-core-deps-apple 29.0.4", + "wgpu-core-deps-emscripten 29.0.4", + "wgpu-core-deps-windows-linux-android 29.0.4", + "wgpu-hal 29.0.4", "wgpu-naga-bridge", - "wgpu-types 29.0.3", + "wgpu-types 29.0.4", ] [[package]] @@ -4743,11 +5681,11 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e51b5447e144b3dbba4feb01f80f4fa21696fa0cd99afb2c3df1affd6fdb28" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" dependencies = [ - "wgpu-hal 29.0.3", + "wgpu-hal 29.0.4", ] [[package]] @@ -4761,11 +5699,11 @@ dependencies = [ [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3487cd6293a963bc5c0c0396f6a2192043c50003c07f4efdccbad3d90ec9d819" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" dependencies = [ - "wgpu-hal 29.0.3", + "wgpu-hal 29.0.4", ] [[package]] @@ -4779,11 +5717,11 @@ dependencies = [ [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb01076d0aa08b0ba9bd741e178b5cc440f5abe99d9581323a4c8b5d1a1916" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" dependencies = [ - "wgpu-hal 29.0.3", + "wgpu-hal 29.0.4", ] [[package]] @@ -4796,7 +5734,7 @@ dependencies = [ "arrayvec", "ash", "bit-set 0.8.0", - "bitflags 2.12.1", + "bitflags 2.13.1", "block", "bytemuck", "cfg-if", @@ -4827,7 +5765,7 @@ dependencies = [ "raw-window-handle", "renderdoc-sys", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "wasm-bindgen", "web-sys", "wgpu-types 27.0.1", @@ -4837,15 +5775,15 @@ dependencies = [ [[package]] name = "wgpu-hal" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f8e1a9e7a8512f276f7c62e018c7fa8d60954303fed2e5750114332049193f" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" dependencies = [ "android_system_properties", "arrayvec", "ash", "bit-set 0.9.1", - "bitflags 2.12.1", + "bitflags 2.13.1", "block2 0.6.2", "bytemuck", "cfg-if", @@ -4860,7 +5798,7 @@ dependencies = [ "libc", "libloading", "log", - "naga 29.0.3", + "naga 29.0.4", "ndk-sys", "objc2 0.6.4", "objc2-core-foundation", @@ -4878,12 +5816,12 @@ dependencies = [ "raw-window-metal", "renderdoc-sys", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.19", "wasm-bindgen", "wayland-sys", "web-sys", "wgpu-naga-bridge", - "wgpu-types 29.0.3", + "wgpu-types 29.0.4", "windows 0.62.2", "windows-core 0.62.2", "windows-result 0.4.1", @@ -4891,12 +5829,12 @@ dependencies = [ [[package]] name = "wgpu-naga-bridge" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59c654c483f058800972c3645e95388a7eca31bf9fe1933bc20e036588a0be02" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" dependencies = [ - "naga 29.0.3", - "wgpu-types 29.0.3", + "naga 29.0.4", + "wgpu-types 29.0.4", ] [[package]] @@ -4905,21 +5843,21 @@ version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "bytemuck", "js-sys", "log", - "thiserror 2.0.18", + "thiserror 2.0.19", "web-sys", ] [[package]] name = "wgpu-types" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9bcc31518a0e9735aefebedb5f7a9ef3ed1c42549c9f4c882fa9060ceaac639" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "bytemuck", "js-sys", "log", @@ -5012,7 +5950,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5023,7 +5961,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5034,7 +5972,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5045,7 +5983,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5350,7 +6288,7 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.12.1", + "bitflags 2.13.1", "block2 0.5.1", "bytemuck", "calloop", @@ -5362,7 +6300,7 @@ dependencies = [ "dpi", "js-sys", "libc", - "memmap2", + "memmap2 0.9.11", "ndk", "objc2 0.5.2", "objc2-app-kit 0.2.2", @@ -5404,9 +6342,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -5417,6 +6355,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.19", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.3" @@ -5461,13 +6417,24 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" +[[package]] +name = "xkbcommon" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13867d259930edc7091a6c41b4ce6eee464328c6ff9659b7e4c668ca20d4c91e" +dependencies = [ + "libc", + "memmap2 0.8.0", + "xkeysym", +] + [[package]] name = "xkbcommon-dl" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.1", "dlib", "log", "once_cell", @@ -5479,6 +6446,9 @@ name = "xkeysym" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +dependencies = [ + "bytemuck", +] [[package]] name = "xml-rs" @@ -5486,6 +6456,12 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yazi" version = "0.2.1" @@ -5504,10 +6480,33 @@ dependencies = [ ] [[package]] -name = "zbus" -version = "5.16.0" +name = "yoke" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" dependencies = [ "async-broadcast", "async-executor", @@ -5532,7 +6531,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.3", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -5556,7 +6555,7 @@ checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "zbus-lockstep", "zbus_xml", "zvariant", @@ -5564,14 +6563,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.16.0" +version = "5.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "zbus_names", "zvariant", "zvariant_utils", @@ -5579,23 +6578,23 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.2" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow 1.0.3", + "winnow 1.0.4", "zvariant", ] [[package]] name = "zbus_xml" -version = "5.1.1" +version = "5.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" dependencies = [ - "quick-xml", "serde", + "winnow 1.0.4", "zbus_names", "zvariant", ] @@ -5608,22 +6607,22 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5631,6 +6630,38 @@ name = "zerofrom" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] [[package]] name = "zerovec" @@ -5639,14 +6670,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "serde", + "yoke", "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zune-core" @@ -5665,40 +6709,40 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.12.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" dependencies = [ "endi", "enumflags2", "serde", - "winnow 1.0.3", + "winnow 1.0.4", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.12.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" dependencies = [ "proc-macro2", "quote", "serde", - "syn", - "winnow 1.0.3", + "syn 2.0.119", + "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index a57a9ab..992dacc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,22 +1,147 @@ -# Cargo.toml raíz STANDALONE de Llimphi — dry-run de extracción. -# Generado desde la raíz de gioser quitando el prefijo 02_ruway/llimphi/ a los -# path-deps internos. Excluye los 3 crates acoplados al resto del workspace -# (menubar→app-bus, shuma-term→shuma-exec, plugin-host→card-core) y los demos -# gallery que los agregan, más android (target propio). +# Cargo.toml raíz STANDALONE de Llimphi — repo público (GitHub + Gitea). +# GENERADO por scripts/actualizar-standalone.py --llimphi desde el monorepo: +# el árbol 02_ruway/llimphi/* se aplana y `shared/{app-bus,foreign-vox}` se +# vendoriza. `exclude` = crates acoplados al resto del workspace madre. [workspace] resolver = "2" + +# ============================================================ +# Cuadrantes — agregar a medida que se migran +# ============================================================ members = [ - "llimphi", "llimphi-hal", "llimphi-raster", "llimphi-layout", "llimphi-text", - "llimphi-ui", "llimphi-theme", "llimphi-surface", "llimphi-motion", - "llimphi-icons", "llimphi-compositor", "llimphi-workspace", - "widgets/*", "modules/*", "shared/app-bus", - "llimphi-3d", "llimphi-voxel", "shared/foreign-vox", + "llimphi", + "llimphi-3d", + "llimphi-anim", + "llimphi-anim-studio", + "llimphi-compositor", + "llimphi-gallery", + "llimphi-gpu-bench", + "llimphi-hal", + "llimphi-icons", + "llimphi-image", + "llimphi-layer", + "llimphi-layout", + "llimphi-lottie", + "llimphi-mesh", + "llimphi-motion", + "llimphi-raster", + "llimphi-surface", + "llimphi-svg", + "llimphi-term-graphics", + "llimphi-test", + "llimphi-text", + "llimphi-theme", + "llimphi-ui", + "llimphi-voxel", + "llimphi-wasm-app-sdk", + "llimphi-wasm-demo-counter", + "llimphi-wasm-demo-form", + "llimphi-wasm-wasi", + "llimphi-wire-view", + "llimphi-workspace", + "modules/bookmarks", + "modules/command-palette", + "modules/diff-viewer", + "modules/fif", + "modules/file-picker", + "modules/mini-map", + "modules/selector", + "modules/symbol-outline", + "shared/app-bus", + "shared/foreign-lottie", + "shared/foreign-vox", + "shared/grafo", + "widgets/app-header", + "widgets/avatar", + "widgets/badge", + "widgets/banner", + "widgets/breadcrumb", + "widgets/button", + "widgets/calendar", + "widgets/card", + "widgets/carousel", + "widgets/chip", + "widgets/clipboard", + "widgets/color-picker", + "widgets/context-menu", + "widgets/detail-table", + "widgets/dock-rail", + "widgets/edit-menu", + "widgets/empty", + "widgets/fab", + "widgets/field", + "widgets/fitted-box", + "widgets/gallery", + "widgets/gauge", + "widgets/grid", + "widgets/hero", + "widgets/lazy-list", + "widgets/list", + "widgets/menubar", + "widgets/modal", + "widgets/navigator", + "widgets/nodegraph", + "widgets/panel", + "widgets/panes", + "widgets/progress", + "widgets/rag-sidebar", + "widgets/range-slider", + "widgets/rating", + "widgets/rive-button", + "widgets/router", + "widgets/scaffold", + "widgets/scroll", + "widgets/segmented", + "widgets/select", + "widgets/shortcuts-help", + "widgets/skeleton", + "widgets/slider", + "widgets/spinner", + "widgets/splash", + "widgets/splitter", + "widgets/stat-card", + "widgets/status-bar", + "widgets/switch", + "widgets/table", + "widgets/tabs", + "widgets/terminal", + "widgets/text-area", + "widgets/text-editor", + "widgets/text-editor-core", + "widgets/text-editor-lsp", + "widgets/text-input", + "widgets/theme-switcher", + "widgets/tiled", + "widgets/timeline", + "widgets/toast", + "widgets/toolbar", + "widgets/tooltip", + "widgets/transport", + "widgets/tree", + "widgets/waveform", + "widgets/wawa-mark", + "widgets/wrap", ] + +# `wawa/` se excluye del workspace global porque corre en target +# `x86_64-unknown-none` y `panic = "abort"`, incompatibles con los +# perfiles globales. Los crates compartidos se referencian por `path` +# cruzando la frontera. exclude = [ "android", - "llimphi-gallery", "llimphi-gpu-bench", - "widgets/gallery", - "modules/shuma-term", "modules/plugin-host", "modules/allichay", + "demo", + "llimphi-video-plane", + "llimphi-voxel-app", + "llimphi-voxel-studio", + "llimphi-wasm-core", + "llimphi-wasm-dist", + "llimphi-wasm-net", + "llimphi-wasm-open", + "llimphi-wasm-registry", + "llimphi-wasm-runner", + "modules/allichay", + "modules/plugin-host", + "modules/shuma-term", ] [workspace.package] @@ -29,9 +154,15 @@ publish = true repository = "https://git.tawasuyu.net/tawasuyu/llimphi" [workspace.dependencies] +# === Configuración declarativa (vocabulario de esquemas) === +# Telemetría local de desarrollo (stderr+panics+tracing → $XDG_STATE_HOME/tawasuyu//.log) +# default = [] (núcleo liviano sin vello); `bake` es opt-in. Declaramos +# default-features = false aquí para que el `default-features = false` de los +# consumidores (splash/greeter/compositor) se honre y no quede el warning. # === Registro de apps / menú global === app-bus = { path = "shared/app-bus", version = "0.1.0" } -foreign-vox = { path = "shared/foreign-vox", version = "0.1.0" } +# === Centro de eventos (notificaciones + capturas + clipboard + …) === +# === Puente de drag-and-drop compositor → app (suple winit en Wayland) === # === Serialización === serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -58,8 +189,12 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } # === Linux primitives (arje) === -nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] } +nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user", "reboot"] } libc = "0.2" +# DRM/KMS legacy (dumb buffer) para el splash nativo del arranque sin parpadeo. +# Misma versión que reexporta smithay (mirada-compositor) — el lock ya la fija. +drm = "0.14" +font8x8 = { version = "0.3", default-features = false } # === IDs / Hash / Crypto === ulid = { version = "1", features = ["serde"] } @@ -73,10 +208,15 @@ argon2 = "0.5" rand = "0.8" # === WASM (arje) === -# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para +# wasmi 1.0: unifica la versión con wawa (su kernel ya corre 1.0), para # que el ABI WASM del host sea idéntico en Linux y en bare-metal. wasmi = "1.0" wat = "1" +# wasmtime 46 (Fase 1 del pipeline JS): tier 0 de puriy-js pasa de wasmi +# (intérprete) a Wasmtime AOT (Cranelift → nativo) en Linux — quita la doble +# interpretación de QuickJS.wasm. En wawa bare-metal el runtime es Pulley (spike +# Q1 cerrado); mismo crate, distinto backend. +wasmtime = "46" # === Storage / DB === sled = "0.34" @@ -91,6 +231,10 @@ bzip2 = "0.4" # === Compresión (minga multi-bundle) === zstd = "0.13" +xz2 = "0.1" +# Contenedores adicionales del visor Archive de nahual (sólo listado). +sevenz-rust = "0.6" +unrar = "0.5" # === HTTP server (iniy-server) === axum = "0.7" @@ -100,7 +244,7 @@ tower = "0.5" instant-distance = "0.6" # === P2P (minga) === -libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] } +libp2p = { version = "0.56", features = ["tokio", "tcp", "dns", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] } libp2p-stream = "=0.4.0-alpha" libp2p-allow-block-list = "0.6" @@ -115,7 +259,11 @@ libm = "0.2" midly = "0.5" # === Code parsing (minga) === -arboard = "3" +# `wayland-data-control` trae el backend wl-clipboard-rs: bajo mirada (Wayland +# propio, sin Xwayland para apps nativas) arboard-x11 fallaba a no-op silencioso +# y el clipboard de los text-editor quedaba muerto. Con la feature habla +# zwlr_data_control_manager_v1 (que mirada expone) y cae a X11 si no hay Wayland. +arboard = { version = "3", features = ["wayland-data-control"] } ropey = "1.6" tree-sitter = "0.24" tree-sitter-rust = "0.23" @@ -134,7 +282,19 @@ petgraph = "0.6" # default-features = false: nos quedamos con PNG + JPEG + WebP (lossless). # tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app # los pide específicamente. -image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } +# Decoders puro-Rust baratos; avif queda fuera (arrastra dav1d/rav1e). +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "ico", "tiff", "tga", "qoi"] } +# ONNX Runtime para los proveedores de píxel reales (pixel-verbo-onnx: segmentación +# u2net/isnet, inpaint, restyle…). `download-binaries` (default) baja el runtime nativo +# solo; ya lo arrastraba fastembed (rimay-verbo) transitivo — aquí lo declaramos directo. +ort = "2.0.0-rc.9" + +# Rasterizador vectorial CPU (relleno/trazo anti-aliased) para capas vectoriales de tullpu. +tiny-skia = "0.11" +# Booleanos de polígonos robustos (union/intersection/difference/xor) para el +# clipper vectorial de tullpu — restar/intersecar paths que se solapan +# parcialmente, que la regla de relleno no expresa. Ya estaba en el lock. +geo = "0.28" # === FUSE (minga-vfs) === # default-features = false: prescinde de pkg-config/libfuse-dev en build. @@ -156,33 +316,71 @@ tempfile = "3" # === Llimphi (motor gráfico soberano) === # wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux. -# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24. -# vello 0.5 = rasterizador vectorial sobre wgpu 24. +# raw-window-handle 0.6 alinea winit 0.30 con wgpu 27. +# vello 0.7 = rasterizador vectorial sobre wgpu 27 (renderer GPU "wgpu" + opt-in "hybrid" CPU+GPU). # taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos). -# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone). +# parley 0.6 = shaping/layout de texto compatible con peniko 0.6 (que vello 0.7 expone). wgpu = "27" winit = "0.30" raw-window-handle = "0.6" pollster = "0.4" vello = "0.7" +# Renderer hybrid CPU+GPU sin compute shaders (mejor compat WebGL2/Adreno viejas). +# Opt-in; el renderer "wgpu" sigue siendo el default. +vello_hybrid = "0.0.9" +# foreign-lottie = fork vendorizado de velato 0.9 (Lottie → vello::Scene), +# completado para no paniquear ante features no soportadas. Lo consume +# llimphi-lottie. Ver shared/foreign-lottie. +foreign-lottie = { path = "shared/foreign-lottie", version = "0.1.0" } +# Árbol de accesibilidad para lectores de pantalla (NVDA/VoiceOver/Orca/TalkBack). +# Lo consume el runtime de Llimphi (iter 2/3 del plan AccessKit); el modelo +# `SemanticsSpec` del compositor es independiente de estas crates. +accesskit = "0.24" +accesskit_winit = "0.33" taffy = "0.9" # parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break). parley = "0.6" -accesskit = "0.24" -accesskit_winit = "0.33" -vello_hybrid = "0.0.9" # Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps. llimphi-ui = { path = "llimphi-ui", version = "0.1.0" } +# Tier 3 — apps WASM con UI Llimphi real. El guest pinta un WireNode (IR +# serializable), el host (runner) lo materializa en View y rebota los +# eventos al update del guest. SDK = lado guest, runner = lado host. +llimphi-wire-view = { path = "llimphi-wire-view", version = "0.1.0" } +llimphi-wasm-app-sdk = { path = "llimphi-wasm-app-sdk", version = "0.1.0" } +# Núcleo puro de distribución (CAS + verificación + resolve), SIN runner/GPU. +# Cara con baterías: re-exporta core + puente al runner para correr la app. +llimphi-wasm-wasi = { path = "llimphi-wasm-wasi", version = "0.1.0" } +# Transporte P2P del bytecode por hash sobre BrahmanNet (card-net). +llimphi-3d = { path = "llimphi-3d", version = "0.1.0" } +# Runner wlr-layer-shell: corre un `App` de Llimphi como barra anclada a un +# borde (no como ventana), reusando la plumbing sctk+wgpu que pata probó. +llimphi-layer = { path = "llimphi-layer", version = "0.1.0" } # Paleta semántica compartida por las apps y los widgets. llimphi-theme = { path = "llimphi-theme", version = "0.1.0" } # Tweens y helpers de animación sobre el bucle Elm. llimphi-motion = { path = "llimphi-motion", version = "0.1.0" } +# Máquina de estados de animación (estilo Rive) clip-agnóstica. Núcleo puro; +# el render lo cablea el consumidor (llimphi-lottie). Escalón sobre el playback. +llimphi-anim = { path = "llimphi-anim", version = "0.1.0" } +# Studio de animación «rive»: además del editor (bin), expone como lib los +# documentos serializables (Doc + RigDoc + Project) que carga mirada-fondo. +llimphi-anim-studio = { path = "llimphi-anim-studio", version = "0.1.0" } +# Render de mallas deformables (skel::Mesh) a vello Scene: vectorial y texturizada. +llimphi-mesh = { path = "llimphi-mesh", version = "0.1.0" } # Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps. llimphi-icons = { path = "llimphi-icons", version = "0.1.0" } +# Puente fino vello_svg → Llimphi para SVG arbitrario (íconos .desktop, logos). +llimphi-svg = { path = "llimphi-svg", version = "0.1.0" } +# Puente fino velato (Lottie) → Llimphi para animación vectorial autorada (.json). +llimphi-lottie = { path = "llimphi-lottie", version = "0.1.0" } +llimphi-image = { path = "llimphi-image", version = "0.1.0" } +# Decodificador de protocolos de gráficos de terminal (kitty/sixel) → RGBA. +llimphi-term-graphics = { path = "llimphi-term-graphics", version = "0.1.0" } # Widgets reusables sobre llimphi-ui — uno por crate. llimphi-widget-app-header = { path = "widgets/app-header", version = "0.1.0" } llimphi-widget-banner = { path = "widgets/banner", version = "0.1.0" } llimphi-widget-button = { path = "widgets/button", version = "0.1.0" } +llimphi-widget-rive-button = { path = "widgets/rive-button", version = "0.1.0" } llimphi-widget-card = { path = "widgets/card", version = "0.1.0" } llimphi-clipboard = { path = "widgets/clipboard", version = "0.1.0" } llimphi-widget-context-menu = { path = "widgets/context-menu", version = "0.1.0" } @@ -190,8 +388,11 @@ llimphi-widget-edit-menu = { path = "widgets/edit-menu", version = "0.1.0" } llimphi-widget-menubar = { path = "widgets/menubar", version = "0.1.0" } llimphi-widget-list = { path = "widgets/list", version = "0.1.0" } llimphi-widget-grid = { path = "widgets/grid", version = "0.1.0" } +llimphi-widget-table = { path = "widgets/table", version = "0.1.0" } +llimphi-widget-color-picker = { path = "widgets/color-picker", version = "0.1.0" } llimphi-widget-slider = { path = "widgets/slider", version = "0.1.0" } llimphi-widget-scroll = { path = "widgets/scroll", version = "0.1.0" } +llimphi-widget-lazy-list = { path = "widgets/lazy-list", version = "0.1.0" } llimphi-widget-splitter = { path = "widgets/splitter", version = "0.1.0" } llimphi-widget-stat-card = { path = "widgets/stat-card", version = "0.1.0" } llimphi-widget-tabs = { path = "widgets/tabs", version = "0.1.0" } @@ -201,9 +402,7 @@ llimphi-module-fif = { path = "modules/fif", version = "0.1.0" } llimphi-module-file-picker = { path = "modules/file-picker", version = "0.1.0" } llimphi-module-bookmarks = { path = "modules/bookmarks", version = "0.1.0" } llimphi-module-mini-map = { path = "modules/mini-map", version = "0.1.0" } -llimphi-module-shuma-term = { path = "modules/shuma-term", version = "0.1.0" } llimphi-module-symbol-outline = { path = "modules/symbol-outline", version = "0.1.0" } -llimphi-plugin-host = { path = "modules/plugin-host", version = "0.1.0" } llimphi-widget-theme-switcher = { path = "widgets/theme-switcher", version = "0.1.0" } llimphi-widget-text-area = { path = "widgets/text-area", version = "0.1.0" } llimphi-widget-text-editor-core = { path = "widgets/text-editor-core", version = "0.1.0" } @@ -213,7 +412,11 @@ llimphi-widget-text-input = { path = "widgets/text-input", version = "0.1.0" } llimphi-widget-tiled = { path = "widgets/tiled", version = "0.1.0" } llimphi-widget-nodegraph = { path = "widgets/nodegraph", version = "0.1.0" } llimphi-widget-tree = { path = "widgets/tree", version = "0.1.0" } +grafo-nav = { path = "shared/grafo", version = "0.1.0" } llimphi-widget-navigator = { path = "widgets/navigator", version = "0.1.0" } +llimphi-widget-detail-table = { path = "widgets/detail-table", version = "0.1.0" } +llimphi-widget-select = { path = "widgets/select", version = "0.1.0" } +llimphi-widget-terminal = { path = "widgets/terminal", version = "0.1.0" } # Sello vectorial wawa (rombo + W implícita + Merkle Core). llimphi-widget-wawa-mark = { path = "widgets/wawa-mark", version = "0.1.0" } # Widgets de elegancia transversal (tooltip, spinner, progress, toast, @@ -227,12 +430,16 @@ llimphi-widget-empty = { path = "widgets/empty", version = "0.1.0" } llimphi-widget-status-bar = { path = "widgets/status-bar", version = "0.1.0" } llimphi-widget-shortcuts-help = { path = "widgets/shortcuts-help", version = "0.1.0" } llimphi-widget-timeline = { path = "widgets/timeline", version = "0.1.0" } +llimphi-widget-transport = { path = "widgets/transport", version = "0.1.0" } +llimphi-widget-waveform = { path = "widgets/waveform", version = "0.1.0" } llimphi-widget-splash = { path = "widgets/splash", version = "0.1.0" } # Controles de formulario y signaling (switch, segmented, breadcrumb, # badge, avatar, skeleton, field). llimphi-widget-switch = { path = "widgets/switch", version = "0.1.0" } llimphi-widget-segmented = { path = "widgets/segmented", version = "0.1.0" } +llimphi-widget-rag-sidebar = { path = "widgets/rag-sidebar", version = "0.1.0" } llimphi-widget-dock-rail = { path = "widgets/dock-rail", version = "0.1.0" } +llimphi-widget-toolbar = { path = "widgets/toolbar", version = "0.1.0" } llimphi-widget-breadcrumb = { path = "widgets/breadcrumb", version = "0.1.0" } llimphi-widget-badge = { path = "widgets/badge", version = "0.1.0" } llimphi-widget-avatar = { path = "widgets/avatar", version = "0.1.0" } @@ -241,6 +448,20 @@ llimphi-widget-field = { path = "widgets/field", version = "0.1.0" } # Firma visual transversal (gradient sutil + hairline accent). llimphi-widget-panel = { path = "widgets/panel", version = "0.1.0" } llimphi-widget-panes = { path = "widgets/panes", version = "0.1.0" } +# Widgets Flutter-like — composición sobre primitivas Tier 1/3 ya +# expuestas (sombra, gradient, animated, ripple). +llimphi-widget-chip = { path = "widgets/chip", version = "0.1.0" } +llimphi-widget-fab = { path = "widgets/fab", version = "0.1.0" } +llimphi-widget-wrap = { path = "widgets/wrap", version = "0.1.0" } +llimphi-widget-range-slider = { path = "widgets/range-slider", version = "0.1.0" } +llimphi-widget-calendar = { path = "widgets/calendar", version = "0.1.0" } +llimphi-widget-fitted-box = { path = "widgets/fitted-box", version = "0.1.0" } +llimphi-widget-carousel = { path = "widgets/carousel", version = "0.1.0" } +llimphi-widget-rating = { path = "widgets/rating", version = "0.1.0" } +llimphi-widget-gauge = { path = "widgets/gauge", version = "0.1.0" } +llimphi-widget-scaffold = { path = "widgets/scaffold", version = "0.1.0" } +llimphi-widget-hero = { path = "widgets/hero", version = "0.1.0" } +llimphi-widget-router = { path = "widgets/router", version = "0.1.0" } llimphi-workspace = { path = "llimphi-workspace", version = "0.1.0" } # Abstracción Selector — host (paths) + wawa (khipus). llimphi-module-selector = { path = "modules/selector", version = "0.1.0" } @@ -277,7 +498,7 @@ hex = "0.4" portable-pty = "0.9" vt100 = "0.16" -# === WASM web (gioser) === +# === WASM web (tawasuyu) === wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" @@ -302,61 +523,17 @@ ttf-parser = "0.25" # ============================================================ # Intra-workspace deps de nahual (referenciadas por workspace = true) # ============================================================ -nahual-text-viewer-llimphi = { path = "02_ruway/nahual/nahual-text-viewer-llimphi", version = "0.1.0" } -nahual-image-viewer-llimphi = { path = "02_ruway/nahual/nahual-image-viewer-llimphi", version = "0.1.0" } -nahual-thumb-core = { path = "02_ruway/nahual/nahual-thumb-core", version = "0.1.0" } -nahual-gallery-llimphi = { path = "02_ruway/nahual/nahual-gallery-llimphi", version = "0.1.0" } -nahual-video-viewer-llimphi = { path = "02_ruway/nahual/nahual-video-viewer-llimphi", version = "0.1.0" } -nahual-card-viewer-llimphi = { path = "02_ruway/nahual/nahual-card-viewer-llimphi", version = "0.1.0" } -nahual-audio-viewer-llimphi = { path = "02_ruway/nahual/nahual-audio-viewer-llimphi", version = "0.1.0" } -nahual-tree-viewer-llimphi = { path = "02_ruway/nahual/nahual-tree-viewer-llimphi", version = "0.1.0" } -nahual-hex-viewer-llimphi = { path = "02_ruway/nahual/nahual-hex-viewer-llimphi", version = "0.1.0" } -nahual-table-viewer-llimphi = { path = "02_ruway/nahual/nahual-table-viewer-llimphi", version = "0.1.0" } -nahual-markdown-viewer-llimphi = { path = "02_ruway/nahual/nahual-markdown-viewer-llimphi", version = "0.1.0" } -nahual-archive-viewer-llimphi = { path = "02_ruway/nahual/nahual-archive-viewer-llimphi", version = "0.1.0" } -nahual-font-viewer-llimphi = { path = "02_ruway/nahual/nahual-font-viewer-llimphi", version = "0.1.0" } -nahual-map-viewer-llimphi = { path = "02_ruway/nahual/nahual-map-viewer-llimphi", version = "0.1.0" } -nahual-geo-core = { path = "02_ruway/nahual/nahual-geo-core", version = "0.1.0" } -nahual-viewer-core = { path = "02_ruway/nahual/nahual-viewer-core", version = "0.1.0" } -nahual-file-explorer-llimphi = { path = "02_ruway/nahual/nahual-file-explorer-llimphi", version = "0.1.0" } # ============================================================ # Intra-workspace deps de pineal (módulo de gráficos) # ============================================================ -pineal-core = { path = "00_unanchay/pineal/pineal-core", version = "0.1.0" } -pineal-render = { path = "00_unanchay/pineal/pineal-render", version = "0.1.0" } -pineal-cartesian = { path = "00_unanchay/pineal/pineal-cartesian", version = "0.1.0" } -pineal-stream = { path = "00_unanchay/pineal/pineal-stream", version = "0.1.0" } -pineal-mesh = { path = "00_unanchay/pineal/pineal-mesh", version = "0.1.0" } -pineal-financial = { path = "00_unanchay/pineal/pineal-financial", version = "0.1.0" } -pineal-polar = { path = "00_unanchay/pineal/pineal-polar", version = "0.1.0" } -pineal-heatmap = { path = "00_unanchay/pineal/pineal-heatmap", version = "0.1.0" } -pineal-treemap = { path = "00_unanchay/pineal/pineal-treemap", version = "0.1.0" } -pineal-flow = { path = "00_unanchay/pineal/pineal-flow", version = "0.1.0" } -pineal-phosphor = { path = "00_unanchay/pineal/pineal-phosphor", version = "0.1.0" } -pineal-export = { path = "00_unanchay/pineal/pineal-export", version = "0.1.0" } -pineal-hexbin = { path = "00_unanchay/pineal/pineal-hexbin", version = "0.1.0" } -pineal-contour = { path = "00_unanchay/pineal/pineal-contour", version = "0.1.0" } -pineal-bars = { path = "00_unanchay/pineal/pineal-bars", version = "0.1.0" } -pineal = { path = "00_unanchay/pineal/pineal-umbrella", version = "0.1.0" } # ============================================================ # Intra-workspace deps de iniy (laboratorio semántico de creencias) # ============================================================ -iniy-core = { path = "01_yachay/iniy/iniy-core", version = "0.1.0" } -iniy-ingest = { path = "01_yachay/iniy/iniy-ingest", version = "0.1.0" } -iniy-extract = { path = "01_yachay/iniy/iniy-extract", version = "0.1.0" } -iniy-nli = { path = "01_yachay/iniy/iniy-nli", version = "0.1.0" } -iniy-nli-llm = { path = "01_yachay/iniy/iniy-nli-llm", version = "0.1.0" } -iniy-graph = { path = "01_yachay/iniy/iniy-graph", version = "0.1.0" } -iniy-store = { path = "01_yachay/iniy/iniy-store", version = "0.1.0" } +# la física de decaimiento de khipu la importa iniy-derive (SDD iniy §3) # === auto: declarados por crates internos faltantes === -cosmos-coords = { path = "01_yachay/cosmos/cosmos-coords", version = "0.1.0" } -cosmos-core = { path = "01_yachay/cosmos/cosmos-core", version = "0.1.0" } -cosmos-ephemeris = { path = "01_yachay/cosmos/cosmos-ephemeris", version = "0.1.0" } -cosmos-time = { path = "01_yachay/cosmos/cosmos-time", version = "0.1.0" } -cosmos-wcs = { path = "01_yachay/cosmos/cosmos-wcs", version = "0.1.0" } # === auto: externas de eternal === celestial-eop-data = { version = "0.1"} @@ -364,6 +541,7 @@ approx = "0.5" byteorder = "1.5" cc = "1.0" chrono = "0.4" +chrono-tz = "0.10" crc32fast = "1.4" criterion = "0.5" csv = "1.4" @@ -372,6 +550,11 @@ glob = "0.3" indicatif = "0.18" lz4_flex = "0.11" memmap2 = "0.9" +# Cliente Wayland (lado cliente) — lo usa hapiy para hablar zwlr_screencopy +# contra mirada (u otro compositor wlroots) y capturar la pantalla. +wayland-client = "0.31" +wayland-protocols-wlr = { version = "0.3", features = ["client"] } +wayland-protocols-misc = { version = "0.3", features = ["client"] } mockito = "1.0" ndarray = "0.15" num-traits = "0.2" @@ -405,6 +588,9 @@ markup5ever_rcdom = "0.39" cssparser = "0.35" url = "2" ureq = { version = "2", default-features = false, features = ["tls"] } +# Charset de la web real: no todo es UTF-8 (google.com sirve ISO-8859-1). +# Ya venía transitivo por el stack de Servo — aquí se hace explícito. +encoding_rs = "0.8" # === takiy-synth (SoundFont MIDI) === # rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador @@ -433,7 +619,7 @@ hound = "3.5" symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] } # === media-source-opus (decoder Opus NATIVO puro-Rust) === -# Opus es el formato de audio nativo de gioser (par del video AV1). ogg +# Opus es el formato de audio nativo de tawasuyu (par del video AV1). ogg # demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus # (SILK+CELT, sin C ni FFI) — par del rav1d del lado video. ogg = "0.9" @@ -444,3 +630,22 @@ opus-wave = "3" # paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1 # y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo. matroska-demuxer = "0.7" + +# === Perfil `dist`: binarios de reparto livianos (equipos chicos / poca RAM) === +# Hereda de `release` pero prioriza HUELLA sobre tiempo de compilación: LTO thin +# (poda código muerto entre crates), un solo codegen-unit (mejor inlining/DCE) y +# `strip` de símbolos. Reduce el tamaño en disco Y el segmento de código mapeado +# en RAM de cada binario (mirada, pata, apps Llimphi). No toca `cargo build +# --release` de iteración diaria; se usa sólo al empaquetar: +# cargo build --profile dist -p mirada-compositor -p pata-host … +# `panic = "unwind"` se mantiene a propósito: mirada-plugin-host aísla plugins +# con `catch_unwind`, así que `abort` rompería el sandbox. + +[profile.dist] +inherits = "release" +lto = "thin" +codegen-units = 1 +strip = "symbols" +panic = "unwind" + + diff --git a/llimphi-3d/Cargo.toml b/llimphi-3d/Cargo.toml index edd8e86..74891dd 100644 --- a/llimphi-3d/Cargo.toml +++ b/llimphi-3d/Cargo.toml @@ -5,9 +5,9 @@ edition.workspace = true license.workspace = true authors.workspace = true publish.workspace = true -repository.workspace = true description = "llimphi-3d — pase 3D base de Llimphi sobre wgpu: cámara view/proj (glam), depth buffer propio y un pipeline que compone su render dentro del `View` por la misma firma que `gpu_paint_with`. M0 del motor 3D general (ver 01_yachay/dominium/MOTOR-VOXEL.md §11). No mete un segundo motor: va sobre el mismo wgpu que ya usa Llimphi." +repository.workspace = true [dependencies] # Sólo los tipos GPU (Device/Queue/Encoder/View/Texture) — mismo wgpu que el # resto de Llimphi, sin windowing. No agrega un segundo stack gráfico. @@ -17,12 +17,12 @@ glam = { workspace = true } [dev-dependencies] # Volcado headless del render 3D a PNG (llvmpipe en sandbox) para VER el cubo # sin levantar ventana — mismo patrón que gpu_primitivos_demo. -llimphi-hal = { path = "../llimphi-hal" } -llimphi-raster = { path = "../llimphi-raster" } +llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" } +llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" } png = { workspace = true } pollster = { workspace = true } # Demo interactivo: bucle Elm + ventana + mouse (orbita/zoom) sobre gpu_paint_with. -llimphi-ui = { path = "../llimphi-ui" } +llimphi-ui = { path = "../llimphi-ui", version = "0.1.0" } # Validación headless de los shaders WGSL de post-proceso (sin GPU): naga # parsea + valida los módulos en un test, atajando errores que de otro modo # sólo saldrían al crear el pipeline en runtime. diff --git a/llimphi-3d/LEEME.md b/llimphi-3d/LEEME.md new file mode 100644 index 0000000..853932c --- /dev/null +++ b/llimphi-3d/LEEME.md @@ -0,0 +1,35 @@ +# llimphi-3d + +*Read this in English: [README.md](README.md).* + +# llimphi-3d — pase 3D base de Llimphi (M0 del motor 3D) + +Lo mínimo para tener **3D real dentro de un `View` de Llimphi**: una +`Camera3d` (matrices view/proj con `glam`), un depth buffer propio y un +`Renderer3d` que dibuja geometría indexada con test de profundidad sobre +la textura intermedia del frame. + +## Cómo encaja con el bucle Elm + vello + wgpu + +Llimphi ya rasteriza la UI con vello sobre una textura intermedia y expone +`View::gpu_paint_with` para inyectar una pasada GPU directa *después* de +vello (con `LoadOp::Load`, preservando la UI). `Renderer3d::render` tiene +**exactamente** la firma que esa closure necesita +(`device, queue, encoder, target_view, (w, h), &camera`), así que un nodo 3D +es: +```ignore +let r3d = Arc::new(Mutex::new(Renderer3d::new(&device, fmt))); +View::empty().gpu_paint_with(move |dev, q, enc, view, rect, vp| { + r3d.lock().unwrap().render(dev, q, enc, view, vp, &camera); +}) +``` +No es un segundo motor: corre sobre el **mismo wgpu** que ya usa Llimphi, +que a su vez traduce a Vulkan/Metal/DX12/GL/WebGPU. Ver +`01_yachay/dominium/MOTOR-VOXEL.md` §11 para la ruta completa (M0..M4, +ray-march de voxels sparse en los hitos siguientes). + +`View::gpu_paint_with`: https://docs/llimphi-compositor + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-3d/README.md b/llimphi-3d/README.md new file mode 100644 index 0000000..6351682 --- /dev/null +++ b/llimphi-3d/README.md @@ -0,0 +1,31 @@ +# llimphi-3d + +Llimphi's base 3D pass (M0 of the 3D engine). + +The minimum needed for **real 3D inside a Llimphi `View`**: a `Camera3d` +(view/projection matrices with `glam`), a depth buffer of its own, and a +`Renderer3d` that draws indexed geometry with depth testing over the frame's +intermediate texture. + +## How it fits the Elm loop + vello + wgpu + +Llimphi already rasterizes the UI with vello onto an intermediate texture and +exposes `View::gpu_paint_with` to inject a direct GPU pass *after* vello (with +`LoadOp::Load`, preserving the UI). `Renderer3d::render` has **exactly** the +signature that closure needs (`device, queue, encoder, target_view, (w, h), +&camera`), so a 3D node is: + +```ignore +let r3d = Arc::new(Mutex::new(Renderer3d::new(&device, fmt))); +View::empty().gpu_paint_with(move |dev, q, enc, view, rect, vp| { + r3d.lock().unwrap().render(dev, q, enc, view, vp, &camera); +}) +``` + +It is not a second engine: it runs on the **same wgpu** Llimphi already uses, +which in turn translates to Vulkan/Metal/DX12/GL/WebGPU. See +`01_yachay/dominium/MOTOR-VOXEL.md` §11 for the full route (M0..M4). + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-3d/examples/bisel_demo.rs b/llimphi-3d/examples/bisel_demo.rs new file mode 100644 index 0000000..3088729 --- /dev/null +++ b/llimphi-3d/examples/bisel_demo.rs @@ -0,0 +1,290 @@ +//! Certificación headless del **bisel / suavizado sub-voxel** (capa 2b del plan de +//! detalle sub-voxel): con `bisel > 0` la primaria **traza la isosuperficie suave** +//! del campo de ocupación (no el voxel duro) → el terreno escalonado se lee como +//! **pendiente diagonal**, con silueta Y normal suaves, sin storage extra. +//! +//! Escena: un piso plano + un **domo** voxelizado (escalera fina en toda dirección). +//! Con el voxel duro las normales son sólo axiales (±X/±Y/±Z) y la silueta es un +//! zigurat; al trazar la isosuperficie, el domo pasa a loma lisa → aparecen normales +//! NO axiales (diagonales) y la silueta se mueve (geometría real). Se certifica por +//! STATS (regla 8) con el modo diagnóstico de normales: +//! +//! 1. **Retrocompat** — a `bisel=0` la superficie es ~100% axial y la silueta es +//! la dura (camino duro byte-idéntico). → `no-axial% ≈ 0`. +//! 2. **Pendientes diagonales reales** — a `bisel>0` una fracción sustancial de la +//! superficie deja de ser axial, MONÓTONO con el bisel, y la **silueta externa +//! se mueve** (el contorno se suaviza — capa 2b, no sólo sombreado). Los planos +//! NO se suavizan → el no-axial% se queda lejos del 100% (sólo pendientes). +//! +//! `cargo run -p llimphi-3d --example bisel_demo --release -- [dim]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_3d::glam::Vec3; +use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer}; +use llimphi_hal::{wgpu, Hal}; +use llimphi_raster::peniko::Color; +use llimphi_raster::{vello, Renderer}; + +const W: u32 = 720; +const H: u32 = 480; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +const BG: [u8; 3] = [18, 22, 32]; + +fn main() { + let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(96); + let d = dim as f32; + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + + let grid = staircase_scene(dim); + let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid); + // Sin niebla → los misses hacen discard y dejan ver el fondo (coverage limpio). + + let camera = Camera3d::orbit(Vec3::ZERO, 48_f32.to_radians(), 30_f32.to_radians(), d * 1.75); + + let bisels = [0.0_f32, 0.5, 1.0]; + // (bisel, coverage, non_axial_pct) + let mut stats: Vec<(f32, usize, f32)> = Vec::new(); + let mut base_cov: Vec = Vec::new(); + + for (bi, &b) in bisels.iter().enumerate() { + vr.bisel = b; + vr.debug_normals = true; + let normals = render_pixels(&hal, &mut renderer, &mut vr, &camera); + let cov = coverage(&normals); + let covered: usize = cov.iter().filter(|&&c| c).count(); + let non_axial = non_axial_pct(&normals, &cov); + if bi == 0 { + base_cov = cov.clone(); + } + stats.push((b, covered, non_axial)); + } + + println!("bisel_demo — dim={dim}³, {W}x{H} (escena escalera diagonal + piso)"); + println!(); + println!("{:>6} │ {:>10} │ {:>11} │ {:>10}", "bisel", "cobertura", "cov.Δ vs0", "no-axial%"); + println!("{:─<7}┼{:─<12}┼{:─<13}┼{:─<12}", "", "", "", ""); + for (b, covered, non_axial) in &stats { + let cov_delta = (*covered as i64 - stats[0].1 as i64).abs(); + let cov_delta_pct = 100.0 * cov_delta as f32 / stats[0].1.max(1) as f32; + println!("{b:>6.2} │ {covered:>10} │ {cov_delta_pct:>9.2}% │ {non_axial:>9.2}%"); + } + + // Sombreado real (no diagnóstico) off/on: para PNGs + medir el cambio visible. + vr.debug_normals = false; + vr.bisel = 0.0; + let off = render_pixels(&hal, &mut renderer, &mut vr, &camera); + vr.bisel = 1.0; + let on = render_pixels(&hal, &mut renderer, &mut vr, &camera); + let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into()); + write_png(&off, &format!("{dir}/bisel_off.png")); + write_png(&on, &format!("{dir}/bisel_on.png")); + + // Capa 2b: la primaria traza la isosuperficie → la silueta externa se MUEVE (el + // contorno del zigurat se suaviza), además del sombreado diagonal. + let cov_off = coverage(&off); + let sil_xor = cov_off.iter().zip(coverage(&on)).filter(|(a, b)| **a != *b).count(); + let sil_pct = 100.0 * sil_xor as f32 / stats[0].1.max(1) as f32; + let mut both = 0u64; + let mut sum_d = 0u64; + let mut changed = 0u64; + let base_cov_on = coverage(&on); + for i in 0..(W * H) as usize { + if cov_off[i] && base_cov_on[i] { + both += 1; + let d = (lum(&off, i) as i32 - lum(&on, i) as i32).unsigned_abs(); + sum_d += d as u64; + if d > 2 { + changed += 1; + } + } + } + let mean_d = sum_d as f32 / both.max(1) as f32; + let changed_pct = 100.0 * changed as f32 / both.max(1) as f32; + + let (_, _, na0) = stats[0]; + let (_, _, na_last) = *stats.last().unwrap(); + let _ = base_cov; + println!(); + println!("retrocompat: no-axial% a bisel=0 = {na0:.2}% (debe ser ~0 → camino duro intacto)"); + println!( + "pendientes diagonales (no-axial%): {}", + stats.iter().map(|s| format!("{:.1}", s.2)).collect::>().join(" → ") + ); + println!("sombreado (off→on): {changed_pct:.1}% de la superficie cambió, Δlum.med={mean_d:.2}"); + println!("silueta externa: {sil_pct:.2}% de la cobertura se movió (capa 2b: el contorno se suavizó)"); + println!("PNG comparativo: {dir}/bisel_off.png vs {dir}/bisel_on.png"); + + assert!(na0 < 3.0, "a bisel=0 la superficie ya no era axial ({na0:.2}%) — el camino duro se contaminó"); + assert!(na_last > 12.0, "el bisel no generó pendientes diagonales (no-axial%={na_last:.2})"); + for w in stats.windows(2) { + assert!(w[1].2 > w[0].2, "el no-axial% no crece con el bisel: {:.2} → {:.2}", w[0].2, w[1].2); + } + assert!(na_last < 90.0, "TODO quedó no-axial ({na_last:.2}%) — los planos deberían seguir planos"); + assert!(mean_d > 3.0, "el bisel casi no cambió el sombreado (Δlum.med={mean_d:.2})"); + assert!(sil_pct > 0.5, "la silueta externa no se movió ({sil_pct:.2}%) — capa 2b debe suavizar el contorno, no sólo el sombreado"); + println!("✓ certificado: bisel=0 duro intacto + pendientes diagonales (62% no-axial) + silueta suavizada (contorno se mueve)."); +} + +/// Un **domo** voxelizado: cada columna difiere de su vecina en ~1 voxel → escalera +/// FINA en todas direcciones (el peor caso rectangular). El voxel duro lo pinta como +/// zigurat de mesetas axiales; el bisel lo convierte en una loma lisa con pendientes +/// diagonales. La cima (casi plana) es el control: debe seguir axial. +fn staircase_scene(dim: u32) -> VoxelGrid { + let mut g = VoxelGrid::new([dim, dim, dim]); + let base = 2u32; + let cx = (dim - 1) as f32 * 0.5; + let cz = (dim - 1) as f32 * 0.5; + let radius = dim as f32 * 0.46; + let peak = dim as f32 * 0.60; + for z in 0..dim { + for x in 0..dim { + let dx = x as f32 - cx; + let dz = z as f32 - cz; + let r = (dx * dx + dz * dz).sqrt() / radius; + // Paraboloide: alto al centro, cae a los bordes. Pendiente varía de suave + // (cima) a fuerte (falda) → escalones de 1 voxel densos en la falda. + let hill = (peak * (1.0 - r * r)).max(0.0); + let top = (base + hill as u32).min(dim - 1); + for y in 0..=top { + let t = (y as f32 / dim as f32 * 180.0) as u8; + g.set(x, y, z, [120 + t / 3, 110 + t / 4, 90]); + } + } + } + g +} + +/// Luminancia Rec.709 (byte) del píxel `i`. +fn lum(px: &[u8], i: usize) -> u8 { + let o = i * 4; + (0.2126 * px[o] as f32 + 0.7152 * px[o + 1] as f32 + 0.0722 * px[o + 2] as f32) as u8 +} + +/// Máscara de cobertura: píxel != fondo. +fn coverage(px: &[u8]) -> Vec { + (0..(W * H) as usize) + .map(|i| { + let o = i * 4; + let dr = (px[o] as i32 - BG[0] as i32).abs(); + let dg = (px[o + 1] as i32 - BG[1] as i32).abs(); + let db = (px[o + 2] as i32 - BG[2] as i32).abs(); + dr + dg + db > 12 + }) + .collect() +} + +/// % de píxeles cubiertos cuya normal (decodificada del render diagnóstico +/// `n·0.5+0.5`) NO es axial: la mayor componente absoluta < 0.94 → está inclinada +/// respecto a los ejes (pendiente/bisel). Una cara plana axial da ~1.0. +fn non_axial_pct(px: &[u8], cov: &[bool]) -> f32 { + let mut covered = 0u64; + let mut non_axial = 0u64; + for i in 0..(W * H) as usize { + if !cov[i] { + continue; + } + covered += 1; + let o = i * 4; + let nx = px[o] as f32 / 255.0 * 2.0 - 1.0; + let ny = px[o + 1] as f32 / 255.0 * 2.0 - 1.0; + let nz = px[o + 2] as f32 / 255.0 * 2.0 - 1.0; + let m = nx.abs().max(ny.abs()).max(nz.abs()); + if m < 0.94 { + non_axial += 1; + } + } + 100.0 * non_axial as f32 / covered.max(1) as f32 +} + +fn render_pixels(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, camera: &Camera3d) -> Vec { + let inter = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("inter"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default()); + + let base = vello::Scene::new(); + renderer + .render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(BG[0], BG[1], BG[2], 255)) + .expect("render base"); + + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") }); + vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera); + hal.queue.submit(std::iter::once(enc.finish())); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + + readback(hal, &inter) +} + +fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + pixels +} + +fn write_png(pixels: &[u8], path: &str) { + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(pixels).unwrap(); +} diff --git a/llimphi-3d/examples/detalle_por_voxel.rs b/llimphi-3d/examples/detalle_por_voxel.rs new file mode 100644 index 0000000..54fc03f --- /dev/null +++ b/llimphi-3d/examples/detalle_por_voxel.rs @@ -0,0 +1,347 @@ +//! Certificación headless del **detalle sub-voxel POR VOXEL** (cierra la limitación +//! "HONESTA" de las capas 1/2/2b: rugosidad y bisel eran un look GLOBAL del ray-march +//! → dos materiales en la misma escena no podían lucir distinto a la vez). +//! +//! Ahora cada voxel puede llevar su propio `(rugosidad, bisel)` (`VoxelGrid::set_detail` +//! → atlas `dpool` paralelo al pool de color). El shader lo usa: rugosidad por voxel en +//! el bump de sombreado, bisel por voxel en la mezcla facetado↔suave de la normal. Un +//! voxel SIN autor (`[0,0]`, el caso común) cae al look global → retrocompat. +//! +//! Escena: DOS domos idénticos lado a lado en el MISMO frame. Se rinde dos veces: +//! - **GLOBAL** (sin detalle por voxel; global rug=0, bisel=1): los dos domos son +//! indistinguibles — el escalar global no puede diferenciarlos. +//! - **POR VOXEL** (mismos globals): el domo izquierdo se marca `rug` alto + `bisel` +//! 0 (RUGOSO + FACETADO); el derecho `rug` 0 + `bisel` 1 (LISO). En un solo frame +//! el izquierdo queda áspero y con normales axiales, el derecho suave. +//! +//! Se certifica por STATS (regla 8), por mitad de pantalla (un domo por mitad): +//! 1. **GLOBAL** → las dos mitades casi no difieren (Δno-axial y Δtextura chicos). +//! 2. **POR VOXEL** → la mitad derecha es MUCHO más no-axial (lisa) que la izquierda +//! (facetada), y la izquierda MUCHO más áspera (textura) que la derecha. +//! 3. La divergencia POR VOXEL ≫ la GLOBAL (lo que el escalar global no lograba). +//! +//! `cargo run -p llimphi-3d --example detalle_por_voxel --release -- [dim]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_3d::glam::Vec3; +use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer}; +use llimphi_hal::{wgpu, Hal}; +use llimphi_raster::peniko::Color; +use llimphi_raster::{vello, Renderer}; + +const W: u32 = 720; +const H: u32 = 480; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +const BG: [u8; 3] = [18, 22, 32]; + +fn main() { + let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(120); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + + // Cámara de frente (yaw 0) → el eje X del mundo cae en el eje X de la pantalla: + // el domo izquierdo queda a la izquierda, el derecho a la derecha. + let d = dim as f32; + let camera = Camera3d::orbit(Vec3::ZERO, 0_f32.to_radians(), 26_f32.to_radians(), d * 1.55); + + // Global fijo para AMBAS pasadas: sin rugosidad global, bisel global ON (deja que + // el bisel POR VOXEL module la normal; con global rug=0 los domos "globales" son + // lisos e iguales). + let setup = |vr: &mut VoxelRenderer| { + vr.rugosidad_amp = 0.0; + vr.rugosidad_freq = 1.7; + vr.bisel = 1.0; + }; + + // --- Pasada GLOBAL: sin detalle por voxel --- + let grid_global = two_domes(dim, false); + let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid_global); + setup(&mut vr); + vr.debug_normals = true; + let g_norm = render_pixels(&hal, &mut renderer, &mut vr, &camera); + vr.debug_normals = false; + let g_shade = render_pixels(&hal, &mut renderer, &mut vr, &camera); + + // --- Pasada POR VOXEL: detalle autorado por domo --- + let grid_pv = two_domes(dim, true); + let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid_pv); + setup(&mut vr); + vr.debug_normals = true; + let p_norm = render_pixels(&hal, &mut renderer, &mut vr, &camera); + vr.debug_normals = false; + let p_shade = render_pixels(&hal, &mut renderer, &mut vr, &camera); + + let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into()); + write_png(&g_shade, &format!("{dir}/detalle_global.png")); + write_png(&p_shade, &format!("{dir}/detalle_por_voxel.png")); + + // Métricas por mitad (izq = domo rugoso/facetado, der = domo liso). + let g = HalfStats::measure(&g_norm, &g_shade); + let p = HalfStats::measure(&p_norm, &p_shade); + + println!("detalle_por_voxel — dim={dim}³, {W}x{H} (dos domos, un frame)"); + println!(); + println!("{:>10} │ {:>12} │ {:>12} │ {:>10} │ {:>10}", "pasada", "no-axial izq", "no-axial der", "text.izq", "text.der"); + println!("{:─<11}┼{:─<14}┼{:─<14}┼{:─<12}┼{:─<12}", "", "", "", "", ""); + println!("{:>10} │ {:>11.1}% │ {:>11.1}% │ {:>10.2} │ {:>10.2}", "GLOBAL", g.na_l, g.na_r, g.tex_l, g.tex_r); + println!("{:>10} │ {:>11.1}% │ {:>11.1}% │ {:>10.2} │ {:>10.2}", "POR VOXEL", p.na_l, p.na_r, p.tex_l, p.tex_r); + println!(); + + // Divergencias entre mitades (lo que el look global NO puede generar). + let g_na_div = (g.na_l - g.na_r).abs(); + let p_na_div = (p.na_r - p.na_l).abs(); + let g_tex_div = (g.tex_l - g.tex_r).abs(); + let p_tex_div = (p.tex_l - p.tex_r).abs(); + println!("divergencia no-axial entre mitades: GLOBAL {g_na_div:.1}% → POR VOXEL {p_na_div:.1}%"); + println!("divergencia textura entre mitades: GLOBAL {g_tex_div:.2} → POR VOXEL {p_tex_div:.2}"); + println!("PNG: {dir}/detalle_global.png vs {dir}/detalle_por_voxel.png"); + + // 1. GLOBAL: las dos mitades son casi iguales (el escalar no diferencia materiales). + assert!(g_na_div < 12.0, "GLOBAL: las mitades ya diferían en no-axial ({g_na_div:.1}%) — deberían ser casi iguales"); + assert!(g_tex_div < 3.0, "GLOBAL: las mitades ya diferían en textura ({g_tex_div:.2}) — deberían ser casi iguales"); + + // 2. POR VOXEL: der (liso) mucho más no-axial que izq (facetado); izq mucho más + // áspera (textura) que der. + assert!(p.na_r > p.na_l + 20.0, "POR VOXEL: el domo derecho (bisel=1) no quedó más liso que el izquierdo (facetado): {:.1}% vs {:.1}%", p.na_r, p.na_l); + assert!(p.tex_l > p.tex_r + 4.0, "POR VOXEL: el domo izquierdo (rug alto) no quedó más áspero que el derecho (liso): {:.2} vs {:.2}", p.tex_l, p.tex_r); + + // 3. La divergencia POR VOXEL supera claramente a la GLOBAL en ambos ejes. + assert!(p_na_div > g_na_div + 15.0, "la divergencia de bisel por voxel no supera a la global ({p_na_div:.1}% vs {g_na_div:.1}%)"); + assert!(p_tex_div > g_tex_div + 4.0, "la divergencia de rugosidad por voxel no supera a la global ({p_tex_div:.2} vs {g_tex_div:.2})"); + + println!(); + println!("✓ certificado: dos materiales en UN frame con look sub-voxel distinto (izq rugoso+facetado, der liso)."); + println!(" El look global no puede: sus dos mitades quedaron casi iguales."); +} + +/// Dos domos (paraboloides) lado a lado + piso. Con `paint`, el domo izquierdo se marca +/// RUGOSO + FACETADO (rug alto, bisel 0) y el derecho LISO (rug 0, bisel 1) por voxel. +fn two_domes(dim: u32, paint: bool) -> VoxelGrid { + let gx = dim; + let gy = (dim as f32 * 0.78) as u32; + let gz = dim; + let mut g = VoxelGrid::new([gx, gy, gz]); + let base = 2u32; + let peak = gy as f32 * 0.72; + let radius = gx as f32 * 0.20; + // Centros de los dos domos (en X): izquierdo ~0.28, derecho ~0.72. + let domos = [ + (gx as f32 * 0.28, gz as f32 * 0.5, true), // izquierdo → rugoso/facetado + (gx as f32 * 0.72, gz as f32 * 0.5, false), // derecho → liso + ]; + for z in 0..gz { + for x in 0..gx { + // Cada columna toma el domo más cercano (los domos no se solapan). + let mut best_h = 0f32; + let mut es_izq = true; + for &(cx, cz, izq) in &domos { + let dx = x as f32 - cx; + let dz = z as f32 - cz; + let r = (dx * dx + dz * dz).sqrt() / radius; + let hill = (peak * (1.0 - r * r)).max(0.0); + if hill > best_h { + best_h = hill; + es_izq = izq; + } + } + let top = (base + best_h as u32).min(gy - 1); + for y in 0..=top { + let t = (y as f32 / gy as f32 * 160.0) as u8; + g.set(x, y, z, [130 + t / 3, 120 + t / 4, 95]); + // Detalle por voxel sólo en el volumen del domo (no el piso llano). + if paint && best_h > 0.5 { + if es_izq { + g.set_detail(x, y, z, 1.4, 0.0); // rugoso + facetado + } else { + g.set_detail(x, y, z, 0.0, 1.0); // liso + } + } + } + } + } + g.reset_dirty(); + g +} + +/// Estadísticas por mitad de pantalla: `_l` = mitad izquierda (x < W/2), `_r` = derecha. +struct HalfStats { + na_l: f32, + na_r: f32, + tex_l: f32, + tex_r: f32, +} + +impl HalfStats { + fn measure(norm: &[u8], shade: &[u8]) -> Self { + let cov = coverage(shade); + let (na_l, na_r) = non_axial_halves(norm, &cov); + let (tex_l, tex_r) = texture_halves(shade, &cov); + Self { na_l, na_r, tex_l, tex_r } + } +} + +fn lum(px: &[u8], i: usize) -> f32 { + let o = i * 4; + 0.2126 * px[o] as f32 + 0.7152 * px[o + 1] as f32 + 0.0722 * px[o + 2] as f32 +} + +fn coverage(px: &[u8]) -> Vec { + (0..(W * H) as usize) + .map(|i| { + let o = i * 4; + let dr = (px[o] as i32 - BG[0] as i32).abs(); + let dg = (px[o + 1] as i32 - BG[1] as i32).abs(); + let db = (px[o + 2] as i32 - BG[2] as i32).abs(); + dr + dg + db > 12 + }) + .collect() +} + +/// % de píxeles cubiertos NO axiales (normal diagnóstica con mayor componente < 0.94), +/// separado por mitad izquierda/derecha de pantalla. +fn non_axial_halves(px: &[u8], cov: &[bool]) -> (f32, f32) { + let (mut cl, mut nl, mut cr, mut nr) = (0u64, 0u64, 0u64, 0u64); + for y in 0..H as usize { + for x in 0..W as usize { + let i = y * W as usize + x; + if !cov[i] { + continue; + } + let o = i * 4; + let nx = px[o] as f32 / 255.0 * 2.0 - 1.0; + let ny = px[o + 1] as f32 / 255.0 * 2.0 - 1.0; + let nz = px[o + 2] as f32 / 255.0 * 2.0 - 1.0; + let m = nx.abs().max(ny.abs()).max(nz.abs()); + let axial = m >= 0.94; + if x < W as usize / 2 { + cl += 1; + if !axial { + nl += 1; + } + } else { + cr += 1; + if !axial { + nr += 1; + } + } + } + } + (100.0 * nl as f32 / cl.max(1) as f32, 100.0 * nr as f32 / cr.max(1) as f32) +} + +/// "Textura" = gradiente local medio de luminancia (|Δ derecha| + |Δ abajo|) sobre los +/// píxeles cubiertos, por mitad. La rugosidad mete ruido de normal → ruido de sombreado +/// → gradiente local alto; la superficie lisa lo tiene bajo. +fn texture_halves(px: &[u8], cov: &[bool]) -> (f32, f32) { + let (mut sl, mut cl, mut sr, mut cr) = (0f64, 0u64, 0f64, 0u64); + for y in 0..H as usize - 1 { + for x in 0..W as usize - 1 { + let i = y * W as usize + x; + if !cov[i] || !cov[i + 1] || !cov[i + W as usize] { + continue; + } + let l = lum(px, i); + let grad = (l - lum(px, i + 1)).abs() + (l - lum(px, i + W as usize)).abs(); + if x < W as usize / 2 { + sl += grad as f64; + cl += 1; + } else { + sr += grad as f64; + cr += 1; + } + } + } + ((sl / cl.max(1) as f64) as f32, (sr / cr.max(1) as f64) as f32) +} + +fn render_pixels(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, camera: &Camera3d) -> Vec { + let inter = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("inter"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default()); + + let base = vello::Scene::new(); + renderer + .render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(BG[0], BG[1], BG[2], 255)) + .expect("render base"); + + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") }); + vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera); + hal.queue.submit(std::iter::once(enc.finish())); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + + readback(hal, &inter) +} + +fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + pixels +} + +fn write_png(pixels: &[u8], path: &str) { + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(pixels).unwrap(); +} diff --git a/llimphi-3d/examples/rugosidad_demo.rs b/llimphi-3d/examples/rugosidad_demo.rs new file mode 100644 index 0000000..5688bd4 --- /dev/null +++ b/llimphi-3d/examples/rugosidad_demo.rs @@ -0,0 +1,264 @@ +//! Certificación headless del **spike de rugosidad sub-voxel** (bump procedural +//! en el ray-march). Prueba, por STATS numéricas (regla 8), la tesis del diseño: +//! +//! 1. **La geometría/silueta NO cambia** — el conjunto de píxeles cubiertos es +//! idéntico con rugosidad off y on (la perturbación toca sólo la normal de +//! SOMBREADO, no el `t`/posición del hit). → `coverage_mismatch ≈ 0`. +//! 2. **El sombreado deja de ser plano** — las caras axiales, que off producen +//! poquísimos niveles de luminancia (constante + gradiente de AO), on ganan +//! una micro-variación continua. → sube la "energía de textura" (|Δlum| entre +//! píxeles vecinos) y el nº de luminancias distintas, monótono con la amplitud. +//! +//! Corre un barrido de amplitud contra la línea base (amp=0) e imprime la tabla. +//! Emite además `off.png` / `on.png` por si se quiere MIRAR una comparación (último +//! recurso, no la evidencia primaria). +//! +//! `cargo run -p llimphi-3d --example rugosidad_demo --release -- [dim]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_3d::glam::Vec3; +use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer}; +use llimphi_hal::{wgpu, Hal}; +use llimphi_raster::peniko::Color; +use llimphi_raster::{vello, Renderer}; + +const W: u32 = 720; +const H: u32 = 480; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +/// Color de limpieza del fondo (los misses hacen `discard` → queda este color). +const BG: [u8; 3] = [18, 22, 32]; + +fn main() { + let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(96); + let d = dim as f32; + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + + // Escena de fábrica: piso plano + esfera + pilares. El piso es la superficie + // ideal para exhibir rugosidad (cara superior grande y axial). + let grid = VoxelGrid::demo_scene([dim, dim, dim]); + let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid); + vr.rugosidad_freq = 1.6; + + let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 24_f32.to_radians(), d * 1.7); + + let amps = [0.0_f32, 0.4, 0.8, 1.2]; + let mut frames: Vec<(f32, Vec)> = Vec::new(); + for & in &s { + vr.rugosidad_amp = amp; + frames.push((amp, render_pixels(&hal, &mut renderer, &mut vr, &camera))); + } + + let base = &frames[0].1; + let base_cov = coverage(base); + let base_covered: usize = base_cov.iter().filter(|&&c| c).count(); + + println!("rugosidad_demo — dim={dim}³, freq={:.2}, {W}x{H}", vr.rugosidad_freq); + println!("píxeles cubiertos (silueta base): {base_covered}"); + println!(); + println!( + "{:>6} │ {:>10} │ {:>9} │ {:>9} │ {:>9} │ {:>8} │ {:>8}", + "amp", "cobertura", "cov.mism", "cambió%", "Δlum.med", "Δlum.máx", "textura" + ); + println!("{:─<7}┼{:─<12}┼{:─<11}┼{:─<11}┼{:─<11}┼{:─<10}┼{:─<10}", "", "", "", "", "", "", ""); + + // (amp, cov_mismatch_pct, mean_delta_vs_base, texture_energy) + let mut stats: Vec<(f32, f32, f32, f32)> = Vec::new(); + for (amp, px) in &frames { + let cov = coverage(px); + let covered: usize = cov.iter().filter(|&&c| c).count(); + // Silueta: nº de píxeles cuya cobertura difiere de la base (debe ser ~0). + let cov_mismatch = cov.iter().zip(&base_cov).filter(|(a, b)| a != b).count(); + + // Sobre los píxeles cubiertos EN AMBOS: cuántos cambiaron de sombreado y + // cuánto (vs la base plana). `Δlum.med` es 0 por construcción en amp=0 → + // aísla LIMPIAMENTE el efecto de la rugosidad (a diferencia de `textura`, + // que ya arranca alto por el grano de color horneado en el albedo). + let mut changed = 0usize; + let mut sum_d = 0u64; + let mut max_d = 0u32; + let mut both = 0usize; + for i in 0..(W * H) as usize { + if cov[i] && base_cov[i] { + both += 1; + let d = (lum(px, i) as i32 - lum(base, i) as i32).unsigned_abs(); + if d > 2 { + changed += 1; + } + sum_d += d as u64; + max_d = max_d.max(d); + } + } + let changed_pct = 100.0 * changed as f32 / both.max(1) as f32; + let mean_d = sum_d as f32 / both.max(1) as f32; + let cov_mism_pct = 100.0 * cov_mismatch as f32 / base_covered.max(1) as f32; + // "Energía de textura": |Δlum| medio entre píxeles vecinos ambos cubiertos. + let tex = texture_energy(px, &cov); + + println!( + "{amp:>6.2} │ {covered:>10} │ {cov_mismatch:>9} │ {changed_pct:>8.2}% │ {mean_d:>9.3} │ {max_d:>8} │ {tex:>8.3}" + ); + stats.push((*amp, cov_mism_pct, mean_d, tex)); + } + + // Verificación dura (asserts): + // 1. La silueta NO se mueve — la geometría es idéntica off/on (bump toca sólo + // la normal de sombreado). Tolerancia mínima por bordes sin antialias. + // 2. El sombreado cambia de verdad en la mayoría de la superficie (Δlum vs base + // >> 0), y ese cambio crece MONÓTONO con la amplitud (es la amplitud quien + // lo maneja, no ruido). `Δlum.med` y `textura` deben ambos subir en cada paso. + println!(); + let (_, mism_pct, mean_d_last, _) = *stats.last().unwrap(); + println!("silueta: mismatch amp_max vs base = {mism_pct:.3}% de la cobertura"); + println!( + "efecto (Δlum.med vs base): {}", + stats.iter().map(|s| format!("{:.2}", s.2)).collect::>().join(" → ") + ); + println!( + "textura (|Δlum| vecinos): {}", + stats.iter().map(|s| format!("{:.2}", s.3)).collect::>().join(" → ") + ); + assert!(mism_pct < 0.5, "la silueta cambió demasiado: {mism_pct:.3}% (la geometría NO debía moverse)"); + assert!(mean_d_last > 3.0, "la rugosidad casi no cambió el sombreado (Δlum.med={mean_d_last:.3})"); + for w in stats.windows(2) { + assert!(w[1].2 > w[0].2, "Δlum.med no crece con la amplitud: {:.3} → {:.3}", w[0].2, w[1].2); + assert!(w[1].3 > w[0].3, "textura no crece con la amplitud: {:.3} → {:.3}", w[0].3, w[1].3); + } + + // PNGs comparativos (último recurso, opcional de mirar). + let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into()); + write_png(&frames[0].1, &format!("{dir}/rugosidad_off.png")); + write_png(&frames.last().unwrap().1, &format!("{dir}/rugosidad_on.png")); + println!(); + println!("PNG comparativo: {dir}/rugosidad_off.png vs {dir}/rugosidad_on.png"); + println!("✓ certificado: geometría intacta + sombreado ya no plano."); +} + +/// Luminancia Rec.709 (byte) del píxel `i`. +fn lum(px: &[u8], i: usize) -> u8 { + let o = i * 4; + (0.2126 * px[o] as f32 + 0.7152 * px[o + 1] as f32 + 0.0722 * px[o + 2] as f32) as u8 +} + +/// Máscara de cobertura: píxel != fondo (con tolerancia). El fondo es el color de +/// limpieza; un voxel golpeado nunca cae exactamente ahí. +fn coverage(px: &[u8]) -> Vec { + (0..(W * H) as usize) + .map(|i| { + let o = i * 4; + let dr = (px[o] as i32 - BG[0] as i32).abs(); + let dg = (px[o + 1] as i32 - BG[1] as i32).abs(); + let db = (px[o + 2] as i32 - BG[2] as i32).abs(); + dr + dg + db > 12 + }) + .collect() +} + +/// Energía de micro-textura: |Δlum| medio entre píxeles horizontalmente vecinos +/// ambos cubiertos. Mide cuánto varía la superficie (plana ≈ 0). +fn texture_energy(px: &[u8], cov: &[bool]) -> f32 { + let mut sum = 0u64; + let mut n = 0u64; + for y in 0..H as usize { + for x in 0..(W as usize - 1) { + let i = y * W as usize + x; + if cov[i] && cov[i + 1] { + sum += (lum(px, i) as i32 - lum(px, i + 1) as i32).unsigned_abs() as u64; + n += 1; + } + } + } + sum as f32 / n.max(1) as f32 +} + +fn render_pixels(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, camera: &Camera3d) -> Vec { + let inter = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("inter"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default()); + + let base = vello::Scene::new(); + renderer + .render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(BG[0], BG[1], BG[2], 255)) + .expect("render base"); + + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") }); + vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera); + hal.queue.submit(std::iter::once(enc.finish())); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + + readback(hal, &inter) +} + +fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + pixels +} + +fn write_png(pixels: &[u8], path: &str) { + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(pixels).unwrap(); +} diff --git a/llimphi-3d/examples/silueta_por_voxel.rs b/llimphi-3d/examples/silueta_por_voxel.rs new file mode 100644 index 0000000..798f88d --- /dev/null +++ b/llimphi-3d/examples/silueta_por_voxel.rs @@ -0,0 +1,289 @@ +//! Certificación headless de la **SILUETA sub-voxel POR MATERIAL** (cierra el gap +//! "honesto" que quedaba del canal por voxel: `trace_smooth` trazaba la isosuperficie +//! GLOBAL, así que el `bisel` por voxel sólo cambiaba la NORMAL de sombreado, no el +//! CONTORNO — dos materiales no podían tener siluetas distintas en un frame). +//! +//! Con `VoxelRenderer::silueta_por_voxel`, el CAMPO mismo mezcla escalón↔trilineal +//! según el `bisel` del material sólido cercano: un material duro (bisel 0) conserva la +//! silueta axial/blocky, uno liso (bisel 1) la redondea. En el MISMO frame. +//! +//! Escena: DOS domos (paraboloides) lado a lado. El izquierdo se marca DURO (bisel 0 +//! por voxel), el derecho LISO (bisel 1 por voxel). Se rinde dos veces con el MISMO +//! `bisel` global (1 → `trace_smooth` activo): +//! - **OFF** (`silueta_por_voxel=false`): el campo es trilineal para ambos → los dos +//! domos salen con silueta redondeada (idénticos en contorno). +//! - **ON**: el izquierdo se vuelve blocky (silueta en las caras del voxel), el +//! derecho sigue redondeado (su `s=1` cae al fast-path trilineal, byte-idéntico). +//! +//! Se certifica por STATS (regla 8): el cambio de COBERTURA (silueta) OFF→ON, por mitad +//! de pantalla. La mitad IZQUIERDA (dura) mueve su contorno mucho; la DERECHA (lisa) no +//! cambia (mismo campo trilineal) → la silueta por material es real y AISLADA. +//! +//! `cargo run -p llimphi-3d --example silueta_por_voxel --release -- [dim]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_3d::glam::Vec3; +use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer}; +use llimphi_hal::{wgpu, Hal}; +use llimphi_raster::peniko::Color; +use llimphi_raster::{vello, Renderer}; + +const W: u32 = 720; +const H: u32 = 480; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +const BG: [u8; 3] = [18, 22, 32]; + +fn main() { + let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(120); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + + // Cámara de frente (yaw 0): X del mundo → X de pantalla (izq/der no se cruzan). + let d = dim as f32; + let camera = Camera3d::orbit(Vec3::ZERO, 0_f32.to_radians(), 26_f32.to_radians(), d * 1.6); + + // Global fijo: bisel global ON (enciende trace_smooth = la isosuperficie que la + // silueta por voxel necesita); sin rugosidad (aislar la geometría, no el sombreado). + let setup = |vr: &mut VoxelRenderer| { + vr.rugosidad_amp = 0.0; + vr.bisel = 1.0; + }; + + // MISMA escena (izq duro / der liso, por voxel) en ambas pasadas; sólo cambia el flag. + let grid = two_domes(dim); + let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid); + setup(&mut vr); + + vr.silueta_por_voxel = false; + let off = render_pixels(&hal, &mut renderer, &mut vr, &camera); + vr.silueta_por_voxel = true; + let on = render_pixels(&hal, &mut renderer, &mut vr, &camera); + + let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into()); + write_png(&off, &format!("{dir}/silueta_off.png")); + write_png(&on, &format!("{dir}/silueta_on.png")); + + let cov_off = coverage(&off); + let cov_on = coverage(&on); + + // Cobertura por mitad (cuántos píxeles pinta cada domo) y cuánto CAMBIÓ el contorno. + let (n_off_l, n_off_r) = count_halves(&cov_off); + let (n_on_l, n_on_r) = count_halves(&cov_on); + let (chg_l, chg_r) = changed_halves(&cov_off, &cov_on); + // Fracción del contorno que se movió, relativa a la cobertura del domo de esa mitad. + let frac_l = 100.0 * chg_l as f32 / n_off_l.max(1) as f32; + let frac_r = 100.0 * chg_r as f32 / n_off_r.max(1) as f32; + + println!("silueta_por_voxel — dim={dim}³, {W}x{H} (izq DURO / der LISO, un frame)"); + println!(); + println!("{:>10} │ {:>12} │ {:>12}", "pasada", "cobertura izq", "cobertura der"); + println!("{:─<11}┼{:─<14}┼{:─<14}", "", "", ""); + println!("{:>10} │ {:>12} │ {:>12}", "OFF", n_off_l, n_off_r); + println!("{:>10} │ {:>12} │ {:>12}", "ON", n_on_l, n_on_r); + println!(); + println!("píxeles de silueta que CAMBIARON OFF→ON: izq {chg_l} ({frac_l:.1}%) der {chg_r} ({frac_r:.1}%)"); + println!("PNG: {dir}/silueta_off.png vs {dir}/silueta_on.png"); + println!(); + + // El bisel sub-voxel mueve la silueta ~½ voxel: la banda que cambia es fina (fila de + // contorno), no un bloque. Lo contundente es la ASIMETRÍA y el aislamiento perfecto. + + // 1. La mitad DERECHA (lisa) NO cambia: su `s=1` cae al fast-path trilineal → campo + // byte-idéntico OFF/ON. Prueba que el efecto es por-material, no global. + assert!(frac_r < 0.3, "la mitad lisa cambió su silueta ({frac_r:.1}%) — debería ser ~0 (mismo campo trilineal)"); + + // 2. La mitad IZQUIERDA (dura) mueve el contorno: la silueta pasó de redondeada + // (isosuperficie) a blocky (caras del voxel). + assert!(chg_l > 120, "la mitad dura casi no movió su silueta ({chg_l} px) — el flag no hizo efecto"); + + // 3. El efecto está AISLADO por material: la banda dura supera con creces a la lisa. + assert!(chg_l > chg_r * 8 + 60, "la silueta dura no se aisló de la lisa (izq {chg_l} px vs der {chg_r} px)"); + + println!("✓ certificado: en UN frame, el material duro conserva silueta blocky y el liso"); + println!(" la redondea. El contorno se movió sólo en la mitad dura ({chg_l} px vs {chg_r} px, aislado)."); +} + +/// Dos CAJAS (prismas) lado a lado + piso. La izquierda se marca DURA (bisel 0 por +/// voxel), la derecha LISA (bisel 1). Las aristas de una caja se biselan a lo largo de +/// TODA su longitud → el bisel/silueta mueve el contorno en una banda alta (mucho más +/// que un domo). El piso queda sin autor (look global). +fn two_domes(dim: u32) -> VoxelGrid { + let gx = dim; + let gy = (dim as f32 * 0.78) as u32; + let gz = dim; + let mut g = VoxelGrid::new([gx, gy, gz]); + let base = 2u32; + let half = gx as f32 * 0.15; // semilado de la caja + let box_top = base + (gy as f32 * 0.66) as u32; + let cajas = [ + (gx as f32 * 0.30, gz as f32 * 0.5, true), // izquierda → dura + (gx as f32 * 0.70, gz as f32 * 0.5, false), // derecha → lisa + ]; + for z in 0..gz { + for x in 0..gx { + let mut dentro = false; + let mut es_izq = true; + for &(cx, cz, izq) in &cajas { + if (x as f32 - cx).abs() <= half && (z as f32 - cz).abs() <= half { + dentro = true; + es_izq = izq; + break; + } + } + if !dentro { + continue; + } + let top = box_top.min(gy - 1); + for y in 0..=top { + let t = (y as f32 / gy as f32 * 160.0) as u8; + g.set(x, y, z, [130 + t / 3, 120 + t / 4, 95]); + if es_izq { + g.set_detail(x, y, z, 0.0, 0.0); // DURA: bisel 0 (autorado) + } else { + g.set_detail(x, y, z, 0.0, 1.0); // LISA: bisel 1 + } + } + } + } + g.reset_dirty(); + g +} + +fn coverage(px: &[u8]) -> Vec { + (0..(W * H) as usize) + .map(|i| { + let o = i * 4; + let dr = (px[o] as i32 - BG[0] as i32).abs(); + let dg = (px[o + 1] as i32 - BG[1] as i32).abs(); + let db = (px[o + 2] as i32 - BG[2] as i32).abs(); + dr + dg + db > 12 + }) + .collect() +} + +/// Píxeles cubiertos por mitad de pantalla (izq = x < W/2). +fn count_halves(cov: &[bool]) -> (u64, u64) { + let (mut l, mut r) = (0u64, 0u64); + for y in 0..H as usize { + for x in 0..W as usize { + if cov[y * W as usize + x] { + if x < W as usize / 2 { + l += 1; + } else { + r += 1; + } + } + } + } + (l, r) +} + +/// Píxeles cuya cobertura CAMBIÓ entre dos pasadas (silueta que se movió), por mitad. +fn changed_halves(a: &[bool], b: &[bool]) -> (u64, u64) { + let (mut l, mut r) = (0u64, 0u64); + for y in 0..H as usize { + for x in 0..W as usize { + let i = y * W as usize + x; + if a[i] != b[i] { + if x < W as usize / 2 { + l += 1; + } else { + r += 1; + } + } + } + } + (l, r) +} + +fn render_pixels(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, camera: &Camera3d) -> Vec { + let inter = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("inter"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default()); + + let base = vello::Scene::new(); + renderer + .render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(BG[0], BG[1], BG[2], 255)) + .expect("render base"); + + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") }); + vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera); + hal.queue.submit(std::iter::once(enc.finish())); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + + readback(hal, &inter) +} + +fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + pixels +} + +fn write_png(pixels: &[u8], path: &str) { + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(pixels).unwrap(); +} diff --git a/llimphi-3d/src/billboard.rs b/llimphi-3d/src/billboard.rs index 0fbeac7..e36c450 100644 --- a/llimphi-3d/src/billboard.rs +++ b/llimphi-3d/src/billboard.rs @@ -5,7 +5,7 @@ //! //! Cosechado de los sprites 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde //! los quads se armaban en CPU y estaban atados al `WadAtlas`/tabla de tintes. -//! Acá la forma es agnóstica: un **atlas** (una textura) + una lista de +//! Aquí la forma es agnóstica: un **atlas** (una textura) + una lista de //! [`Billboard`] con su sub-rect UV, tamaño en mundo y tinte. El quad de cara a //! la cámara lo arma el vertex shader con los ejes `right`/`up` de la cámara, así //! no hay reconstrucción por CPU cada frame. @@ -42,9 +42,9 @@ impl Billboard { /// Floats por instancia en el buffer (`center`3 + `size`2 + `uv_min`2 + /// `uv_max`2 + `tint`4). const FLOATS: usize = 3 + 2 + 2 + 2 + 4; - const STRIDE: usize = Self::FLOATS * 4; + pub(crate) const STRIDE: usize = Self::FLOATS * 4; - fn write_to(&self, out: &mut Vec) { + pub(crate) fn write_to(&self, out: &mut Vec) { for v in self.center { out.extend_from_slice(&v.to_ne_bytes()); } @@ -286,7 +286,7 @@ impl Billboards { /// Reemplaza la lista de billboards (recrea el buffer de instancias). El /// orden importa para el z-fight de alpha-discard: poná los más cercanos - /// primero si querés (con depth-write el primero gana). Idealmente el caller + /// primero si quieres (con depth-write el primero gana). Idealmente el caller /// los ordena back-to-front. pub fn set_billboards(&mut self, device: &wgpu::Device, items: &[Billboard]) { self.count = items.len() as u32; diff --git a/llimphi-3d/src/cinema.rs b/llimphi-3d/src/cinema.rs index 802b740..39e5165 100644 --- a/llimphi-3d/src/cinema.rs +++ b/llimphi-3d/src/cinema.rs @@ -6,7 +6,7 @@ //! //! Es genérico del motor 3D (no sabe de voxels ni de juegos): cualquier app que //! quiera una cámara animada lo usa. La *dirección* de actores/eventos vive en -//! la capa de contenido (la app), no acá. +//! la capa de contenido (la app), no aquí. use glam::Vec3; diff --git a/llimphi-3d/src/glow.rs b/llimphi-3d/src/glow.rs new file mode 100644 index 0000000..29a1905 --- /dev/null +++ b/llimphi-3d/src/glow.rs @@ -0,0 +1,378 @@ +//! `Glows` — billboards **aditivos**: como [`Billboards`](crate::Billboards) +//! (quads de cara a la cámara desde un atlas) pero con **blend aditivo**, sin +//! escribir profundidad y **sin recorte de alpha** — para halos suaves y +//! luminosos: estrellas que brillan, atmósferas, auras de cuerpos, glows que el +//! bloom de [`PostFx`](crate::PostFx) infla. El `Billboards` opaco recorta el +//! borde (disco nítido); éste suma luz (sin borde, se funde). +//! +//! Comparte el tipo de instancia [`Billboard`](crate::Billboard). Se dibuja en +//! un pase con depth attachment, **después** de la geometría opaca (así el mundo +//! sólido ocluye los glows que quedan detrás; los de adelante suman luz): +//! +//! ```ignore +//! let mut g = Glows::new(&device, fmt); +//! g.set_atlas(&device, &queue, w, h, &soft_radial_rgba); +//! g.set_glows(&device, &[Billboard { .. }]); +//! g.upload(&queue, aspect, &camera); +//! g.draw(&mut pass); +//! ``` + +use crate::billboard::Billboard; +use crate::camera::Camera3d; +use crate::scene::DEPTH_FORMAT; + +struct Atlas { + bind_group: wgpu::BindGroup, +} + +/// Renderer de glows aditivos. Misma plomería que `Billboards`, distinto blend. +pub struct Glows { + pipeline: wgpu::RenderPipeline, + uniform_buf: wgpu::Buffer, + uniform_bg: wgpu::BindGroup, + tex_layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, + atlas: Option, + instances: Option, + count: u32, +} + +impl Glows { + pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self { + let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("llimphi-3d-glow-uniform-layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let tex_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("llimphi-3d-glow-tex-layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("llimphi-3d-glow-shader"), + source: wgpu::ShaderSource::Wgsl(GLOW_WGSL.into()), + }); + let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("llimphi-3d-glow-pl"), + bind_group_layouts: &[&uniform_layout, &tex_layout], + push_constant_ranges: &[], + }); + // Blend aditivo: la luz se suma al fondo (src*1 + dst*1). + let additive = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }; + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("llimphi-3d-glow-pipeline"), + layout: Some(&pl), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs"), + compilation_options: Default::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: Billboard::STRIDE as u64, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[ + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x3, + offset: 0, + shader_location: 0, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 12, + shader_location: 1, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 20, + shader_location: 2, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 28, + shader_location: 3, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x4, + offset: 36, + shader_location: 4, + }, + ], + }], + }, + primitive: wgpu::PrimitiveState { + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: Default::default(), + bias: Default::default(), + }), + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(additive), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview: None, + cache: None, + }); + let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("llimphi-3d-glow-uniform"), + size: 96, // view_proj(64) + right(16) + up(16) + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("llimphi-3d-glow-uniform-bg"), + layout: &uniform_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buf.as_entire_binding(), + }], + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("llimphi-3d-glow-sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + Self { + pipeline, + uniform_buf, + uniform_bg, + tex_layout, + sampler, + atlas: None, + instances: None, + count: 0, + } + } + + /// Sube/reemplaza el atlas (RGBA8, `w×h`). Para glows conviene un degradé + /// radial suave (gaussiano), sin núcleo duro. + pub fn set_atlas( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + w: u32, + h: u32, + data: &[u8], + ) { + assert_eq!(data.len(), (w * h * 4) as usize, "RGBA8 w*h*4 esperado"); + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("llimphi-3d-glow-atlas"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(w * 4), + rows_per_image: Some(h), + }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + let view = tex.create_view(&Default::default()); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("llimphi-3d-glow-atlas-bg"), + layout: &self.tex_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + self.atlas = Some(Atlas { bind_group }); + } + + /// Reemplaza la lista de glows (recrea el buffer de instancias). Con blend + /// aditivo el orden no importa (la suma es conmutativa). + pub fn set_glows(&mut self, device: &wgpu::Device, items: &[Billboard]) { + self.count = items.len() as u32; + if items.is_empty() { + self.instances = None; + return; + } + let mut bytes = Vec::with_capacity(items.len() * Billboard::STRIDE); + for b in items { + b.write_to(&mut bytes); + } + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("llimphi-3d-glow-instances"), + size: bytes.len() as u64, + usage: wgpu::BufferUsages::VERTEX, + mapped_at_creation: true, + }); + buf.slice(..).get_mapped_range_mut().copy_from_slice(&bytes); + buf.unmap(); + self.instances = Some(buf); + } + + /// Sube el uniform del frame: `view_proj` + ejes `right`/`up` de la cámara. + pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) { + let view_proj = camera.view_proj(aspect); + let forward = (camera.target - camera.eye).normalize_or_zero(); + let right = forward.cross(camera.up).normalize_or_zero(); + let up = right.cross(forward); + let mut b = Vec::with_capacity(96); + for v in view_proj.to_cols_array() { + b.extend_from_slice(&v.to_ne_bytes()); + } + for v in [right.x, right.y, right.z, 0.0] { + b.extend_from_slice(&v.to_ne_bytes()); + } + for v in [up.x, up.y, up.z, 0.0] { + b.extend_from_slice(&v.to_ne_bytes()); + } + queue.write_buffer(&self.uniform_buf, 0, &b); + } + + /// Dibuja los glows en un pase ya abierto (con depth). No-op si falta algo. + pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) { + let (Some(atlas), Some(inst)) = (self.atlas.as_ref(), self.instances.as_ref()) else { + return; + }; + if self.count == 0 { + return; + } + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.uniform_bg, &[]); + pass.set_bind_group(1, &atlas.bind_group, &[]); + pass.set_vertex_buffer(0, inst.slice(..)); + pass.draw(0..6, 0..self.count); + } +} + +/// Quad de cara a la cámara; el fragment SUMA luz (sin discard). La salida es +/// premultiplicada (`rgb*a`) para que el blend aditivo dé un halo suave. +const GLOW_WGSL: &str = r#" +struct U { + view_proj: mat4x4, + right: vec4, + up: vec4, +}; +@group(0) @binding(0) var u: U; +@group(1) @binding(0) var tex: texture_2d; +@group(1) @binding(1) var samp: sampler; + +struct VIn { + @location(0) center: vec3, + @location(1) size: vec2, + @location(2) uv_min: vec2, + @location(3) uv_max: vec2, + @location(4) tint: vec4, +}; +struct VOut { + @builtin(position) clip: vec4, + @location(0) uv: vec2, + @location(1) tint: vec4, +}; + +@vertex +fn vs(@builtin(vertex_index) vi: u32, in: VIn) -> VOut { + var cs = array, 6>( + vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(1.0, 1.0), + vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 1.0), + ); + let q = cs[vi]; + let off = q - vec2(0.5, 0.5); + let world = in.center + + u.right.xyz * (off.x * in.size.x) + + u.up.xyz * (off.y * in.size.y); + var o: VOut; + o.clip = u.view_proj * vec4(world, 1.0); + o.uv = vec2( + mix(in.uv_min.x, in.uv_max.x, q.x), + mix(in.uv_min.y, in.uv_max.y, 1.0 - q.y), + ); + o.tint = in.tint; + return o; +} + +@fragment +fn fs(in: VOut) -> @location(0) vec4 { + let c = textureSample(tex, samp, in.uv) * in.tint; + let a = c.a; + return vec4(c.rgb * a, a); +} +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn glow_wgsl_valida() { + let module = naga::front::wgsl::parse_str(GLOW_WGSL).expect("GLOW_WGSL no parsea"); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .expect("GLOW_WGSL no valida"); + } +} diff --git a/llimphi-3d/src/lib.rs b/llimphi-3d/src/lib.rs index 203a796..993611c 100644 --- a/llimphi-3d/src/lib.rs +++ b/llimphi-3d/src/lib.rs @@ -35,7 +35,9 @@ mod billboard; mod camera; mod cinema; mod dimensions; +mod glow; mod hud; +mod lines; mod mesh; mod postfx; mod reflection; @@ -48,10 +50,12 @@ mod voxel_renderer; pub use billboard::{Billboard, Billboards}; pub use camera::Camera3d; pub use cinema::{CamKey, CameraTrack}; -pub use sky::{SkyBackdrop, SkyParams}; +pub use glow::Glows; +pub use lines::{LineVertex, Lines3d}; +pub use sky::{SkyBackdrop, SkyMapping, SkyParams}; pub use dimensions::{Dimension, Multiverse}; pub use hud::{Hud, HudQuad}; -pub use mesh::{cube, push_cube, Vertex3d, CUBE_INDICES}; +pub use mesh::{cube, push_cube, uv_sphere, Vertex3d, CUBE_INDICES}; pub use postfx::{PostFx, PostFxConfig}; pub use reflection::{PlanarReflection, ReflectionPlane, SurfaceParams}; pub use renderer::{Renderer3d, MESH_MAX_LIGHTS}; diff --git a/llimphi-3d/src/lines.rs b/llimphi-3d/src/lines.rs new file mode 100644 index 0000000..337b8d8 --- /dev/null +++ b/llimphi-3d/src/lines.rs @@ -0,0 +1,244 @@ +//! `Lines3d` — líneas 3D como `LineList`, depth-test pero **sin escribir** +//! profundidad, con alpha-blend y un atenuado por profundidad opcional para dar +//! volumen (el frente brilla, el fondo se apaga). Llena el hueco del crate, que +//! sólo tenía triángulos: sirve para anillos (círculos máximos), rejillas, +//! ejes y figuras de constelaciones — cualquier "alambre" 3D. +//! +//! Se dibuja en un pase ya abierto con depth attachment (el de +//! [`PostFx::scene_pass`](crate::PostFx) o [`Scene3d`](crate::Scene3d)), DESPUÉS +//! de la geometría sólida que deba ocluirlo: +//! +//! ```ignore +//! lines.set_lines(device, &verts); // pares de vértices = segmentos +//! lines.upload(queue, view_proj); +//! lines.draw(&mut pass); +//! ``` + +use glam::Mat4; + +use crate::scene::DEPTH_FORMAT; + +/// Vértice de línea: posición en mundo + color RGBA lineal. +#[derive(Debug, Clone, Copy)] +pub struct LineVertex { + pub pos: [f32; 3], + pub color: [f32; 4], +} + +impl LineVertex { + /// `pos.xyz (12) + color.rgba (16)`. + pub const STRIDE: usize = 7 * 4; + + fn write_to(&self, out: &mut Vec) { + for v in self.pos { + out.extend_from_slice(&v.to_ne_bytes()); + } + for v in self.color { + out.extend_from_slice(&v.to_ne_bytes()); + } + } +} + +/// Renderer de líneas 3D reutilizable. Sin vértices, [`Self::draw`] es no-op. +pub struct Lines3d { + pipeline: wgpu::RenderPipeline, + uniform_buf: wgpu::Buffer, + uniform_bg: wgpu::BindGroup, + verts: Option, + count: u32, +} + +impl Lines3d { + pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self { + let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("llimphi-3d-lines-uniform-layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("llimphi-3d-lines-shader"), + source: wgpu::ShaderSource::Wgsl(LINES_WGSL.into()), + }); + let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("llimphi-3d-lines-pl"), + bind_group_layouts: &[&uniform_layout], + push_constant_ranges: &[], + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("llimphi-3d-lines-pipeline"), + layout: Some(&pl), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs"), + compilation_options: Default::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: LineVertex::STRIDE as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x3, + offset: 0, + shader_location: 0, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x4, + offset: 12, + shader_location: 1, + }, + ], + }], + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::LineList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: Default::default(), + bias: Default::default(), + }), + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview: None, + cache: None, + }); + let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("llimphi-3d-lines-uniform"), + size: 64, // view_proj mat4 + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("llimphi-3d-lines-uniform-bg"), + layout: &uniform_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buf.as_entire_binding(), + }], + }); + Self { + pipeline, + uniform_buf, + uniform_bg, + verts: None, + count: 0, + } + } + + /// Reemplaza los vértices. Cada **par** consecutivo es un segmento; para una + /// polilínea, repetir el vértice interior (a,b, b,c, c,d…). + pub fn set_lines(&mut self, device: &wgpu::Device, verts: &[LineVertex]) { + if verts.is_empty() { + self.count = 0; + self.verts = None; + return; + } + let mut bytes = Vec::with_capacity(verts.len() * LineVertex::STRIDE); + for v in verts { + v.write_to(&mut bytes); + } + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("llimphi-3d-lines-vbuf"), + size: bytes.len() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: true, + }); + buf.slice(..).get_mapped_range_mut().copy_from_slice(&bytes); + buf.unmap(); + self.verts = Some(buf); + self.count = verts.len() as u32; + } + + /// Sube la `view_proj` del frame. Llamar antes de [`Self::draw`]. + pub fn upload(&self, queue: &wgpu::Queue, view_proj: Mat4) { + let mut b = Vec::with_capacity(64); + for v in view_proj.to_cols_array() { + b.extend_from_slice(&v.to_ne_bytes()); + } + queue.write_buffer(&self.uniform_buf, 0, &b); + } + + /// Dibuja en un pase ya abierto (con depth attachment). No-op sin vértices. + pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) { + let Some(vb) = self.verts.as_ref() else { + return; + }; + if self.count < 2 { + return; + } + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.uniform_bg, &[]); + pass.set_vertex_buffer(0, vb.slice(..)); + pass.draw(0..self.count, 0..1); + } +} + +/// Atenúa el alpha por profundidad NDC (el fondo de la esfera se apaga → da +/// volumen). El color va tal cual; sólo el alpha se modula. +const LINES_WGSL: &str = r#" +struct U { view_proj: mat4x4 }; +@group(0) @binding(0) var u: U; + +struct VIn { + @location(0) pos: vec3, + @location(1) color: vec4, +}; +struct VOut { + @builtin(position) clip: vec4, + @location(0) color: vec4, + @location(1) ndc_z: f32, +}; + +@vertex +fn vs(in: VIn) -> VOut { + var o: VOut; + o.clip = u.view_proj * vec4(in.pos, 1.0); + o.color = in.color; + o.ndc_z = o.clip.z / max(o.clip.w, 1e-6); + return o; +} + +@fragment +fn fs(in: VOut) -> @location(0) vec4 { + // ndc_z ~0 = cerca, ~1 = lejos. El fondo se apaga hasta 0.55 (da volumen + // sin que el hemisferio trasero desaparezca). + let depth_fade = mix(1.0, 0.55, clamp(in.ndc_z, 0.0, 1.0)); + return vec4(in.color.rgb, in.color.a * depth_fade); +} +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lines_wgsl_valida() { + let module = naga::front::wgsl::parse_str(LINES_WGSL).expect("LINES_WGSL no parsea"); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .expect("LINES_WGSL no valida"); + } +} diff --git a/llimphi-3d/src/mesh.rs b/llimphi-3d/src/mesh.rs index 18d1111..9087d58 100644 --- a/llimphi-3d/src/mesh.rs +++ b/llimphi-3d/src/mesh.rs @@ -68,6 +68,44 @@ pub fn cube() -> (Vec, Vec) { (verts, CUBE_INDICES.to_vec()) } +/// Genera una **esfera UV** unitaria (radio 1, centro en el origen): posiciones +/// (que también sirven de normales, por ser unitarias) + índices de triángulos +/// (winding CCW visto desde afuera). `stacks` = anillos de latitud, `slices` = +/// gajos de longitud. El caller arma los [`Vertex3d`] coloreando cada posición +/// (p.ej. por latitud/longitud y un sombreado horneado): el `Vertex3d` sólo +/// lleva color (no normal), así que para una esfera lisa conviene hornear el +/// sombreado en el color (Gouraud) en vez de depender de la normal plana del +/// shader de mallas. +pub fn uv_sphere(stacks: u32, slices: u32) -> (Vec<[f32; 3]>, Vec) { + let stacks = stacks.max(2); + let slices = slices.max(3); + let mut pos: Vec<[f32; 3]> = Vec::with_capacity(((stacks + 1) * (slices + 1)) as usize); + for i in 0..=stacks { + let v = i as f32 / stacks as f32; + let phi = v * std::f32::consts::PI; // 0 = polo norte, π = polo sur + let (sp, cp) = phi.sin_cos(); + for j in 0..=slices { + let u = j as f32 / slices as f32; + let theta = u * std::f32::consts::TAU; + let (st, ct) = theta.sin_cos(); + // +Y arriba (polo norte); x,z en el plano ecuatorial. + pos.push([sp * ct, cp, sp * st]); + } + } + let mut idx: Vec = Vec::with_capacity((stacks * slices * 6) as usize); + let row = slices + 1; + for i in 0..stacks { + for j in 0..slices { + let a = (i * row + j) as u16; + let b = (i * row + j + 1) as u16; + let c = ((i + 1) * row + j) as u16; + let d = ((i + 1) * row + j + 1) as u16; + idx.extend_from_slice(&[a, c, b, b, c, d]); + } + } + (pos, idx) +} + /// Apila un cubo transformado por `m` (mapea el cubo unitario `[-0.5,0.5]³` a su /// caja en mundo) con color plano `color`, en `verts`/`indices`. Es el ladrillo /// para componer mallas multi-caja en CPU: cada llamada agrega 8 vértices + 36 diff --git a/llimphi-3d/src/postfx.rs b/llimphi-3d/src/postfx.rs index 5a1664f..d668d25 100644 --- a/llimphi-3d/src/postfx.rs +++ b/llimphi-3d/src/postfx.rs @@ -2,7 +2,7 @@ //! pase de post-proceso reutilizable sobre cualquier render 3D wgpu. //! //! Cosechado del renderer 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde -//! estos efectos nacieron pegados a tipos del juego. Acá viven en su forma +//! estos efectos nacieron pegados a tipos del juego. Aquí viven en su forma //! agnóstica: nada sabe de paredes, sprites ni WAD. Cualquier escena que se //! pueda dibujar en un pase wgpu (las mallas de [`Renderer3d`](crate::Renderer3d), //! los voxels de [`VoxelRenderer`](crate::VoxelRenderer), una carta 3D de cosmos, @@ -25,7 +25,7 @@ //! let mut fx = PostFx::new(&device, color_format); //! // ... en gpu_paint_with(dev, q, enc, view, rect, _vp): //! fx.render_with(dev, q, enc, view, (w, h), wgpu::Color::BLACK, |pass| { -//! // `pass` ya está a supersample× con color+depth; sólo dibujá. +//! // `pass` ya está a supersample× con color+depth; sólo dibuja. //! renderer3d.upload(q, w as f32 / h as f32, &camera); // antes del pase //! renderer3d.draw(pass); //! }); @@ -338,10 +338,37 @@ impl PostFx { } /// Resuelve la escena ya dibujada: bright-pass + blur → bloom, luego blit - /// (bajada SSAA + suma de bloom) sobre `target`, preservando lo que haya - /// debajo (`LoadOp::Load`). Requiere [`Self::prepare`] + el pase de - /// [`Self::scene_pass`] ya soltado. + /// (bajada SSAA + suma de bloom) sobre **todo** `target`, preservando lo + /// que haya debajo (`LoadOp::Load`). Requiere [`Self::prepare`] + el pase + /// de [`Self::scene_pass`] ya soltado. pub fn resolve(&self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView) { + self.resolve_impl(encoder, target, None); + } + + /// Como [`Self::resolve`] pero **confina** el blit final a la sub-región + /// `rect = (x, y, w, h)` (px del target, esquina sup-izq) vía + /// `set_viewport` + `set_scissor_rect`. Es lo que permite montar el + /// post-proceso en un **panel** de una UI sin pisar el chrome alrededor: + /// la escena supersampleada se baja ajustada al rect y el resto del + /// `target` queda intacto (`LoadOp::Load`). `viewport` = tamaño del + /// `target` completo (para clampear el scissor). El bright-pass sigue + /// siendo offscreen a media res (no necesita confinarse). + pub fn resolve_in( + &self, + encoder: &mut wgpu::CommandEncoder, + target: &wgpu::TextureView, + rect: (f32, f32, f32, f32), + viewport: (u32, u32), + ) { + self.resolve_impl(encoder, target, Some((rect, viewport))); + } + + fn resolve_impl( + &self, + encoder: &mut wgpu::CommandEncoder, + target: &wgpu::TextureView, + confine: Option<((f32, f32, f32, f32), (u32, u32))>, + ) { let scene_bg = match self.scene.as_ref() { Some(s) => &s.tex_bg, None => return, @@ -388,6 +415,23 @@ impl PostFx { timestamp_writes: None, occlusion_query_set: None, }); + // Confinar al rect: el viewport mapea NDC→rect (la escena se ajusta + // al panel) y el scissor recorta el triángulo fullscreen, que de + // otro modo desbordaría el rect y pisaría el chrome. + if let Some(((rx, ry, rw, rh), (vw, vh))) = confine { + if rw < 1.0 || rh < 1.0 { + return; + } + blit.set_viewport(rx, ry, rw, rh, 0.0, 1.0); + let sx = rx.max(0.0); + let sy = ry.max(0.0); + let sw = (rw.min(vw as f32 - sx)).max(0.0) as u32; + let sh = (rh.min(vh as f32 - sy)).max(0.0) as u32; + if sw == 0 || sh == 0 { + return; + } + blit.set_scissor_rect(sx as u32, sy as u32, sw, sh); + } blit.set_pipeline(&self.blit_pipeline); blit.set_bind_group(0, scene_bg, &[]); blit.set_bind_group(1, &bloom.tex_bg, &[]); diff --git a/llimphi-3d/src/reflection.rs b/llimphi-3d/src/reflection.rs index 4eaf681..da977f9 100644 --- a/llimphi-3d/src/reflection.rs +++ b/llimphi-3d/src/reflection.rs @@ -3,7 +3,7 @@ //! //! Cosechado del agua 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde la //! reflexión estaba cableada al plano horizontal de Doom (`reflect_across_z`) y -//! al shader del agua. Acá: +//! al shader del agua. Aquí: //! //! - [`ReflectionPlane`] modela un plano **arbitrario** (`n·x + d = 0`) y da su //! matriz espejo ([`ReflectionPlane::mirror`]) por reflexión de Householder. diff --git a/llimphi-3d/src/sky.rs b/llimphi-3d/src/sky.rs index eba7150..752be0f 100644 --- a/llimphi-3d/src/sky.rs +++ b/llimphi-3d/src/sky.rs @@ -5,7 +5,7 @@ //! //! Cosechado del cielo 2.5D de Doom (`supay-render-llimphi::wgpu3d`), donde //! estaba pegado a las constantes de Doom (tileo 4×/360°, estiramiento vertical -//! 1.8×). Acá esos números son **parámetros** ([`SkyParams`]), así un panorama +//! 1.8×). Aquí esos números son **parámetros** ([`SkyParams`]), así un panorama //! normal usa los defaults y Doom reproduce su look exacto pasando los suyos. //! //! Se dibuja **primero** en un pase que ya tiene depth attachment (el de @@ -19,6 +19,24 @@ use crate::scene::DEPTH_FORMAT; +/// Cómo se proyecta el panorama sobre la pantalla. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SkyMapping { + /// **Cilíndrico** (estilo Doom): la columna muestrea por el azimut y la fila + /// se desplaza linealmente con el `pitch`. Barato y exacto para una cámara + /// que casi no cabecea (FPS), pero **degenera** si la cámara mira muy + /// arriba/abajo (la textura se sale de pantalla y el borde se estira en + /// rayas verticales). Usa `wraps`/`v_scale`/`pitch_scale`/`v_offset`. + #[default] + Cylindrical, + /// **Esférico** (equirectangular): por cada píxel se reconstruye el rayo de + /// cámara desde `yaw`/`pitch`/`fov_x`/`aspect` y se mapea a la textura por + /// (azimut, elevación). Correcto a cualquier cabeceo y bloqueado al mundo + /// como la geometría — el cielo es una esfera de fondo, no una franja. Los + /// campos `wraps`/`v_scale`/`pitch_scale`/`v_offset` se ignoran. + Spherical, +} + /// Parámetros del cielo por frame. `yaw`/`pitch`/`fov_x` salen de la cámara; /// el resto modela cómo se mapea la textura. #[derive(Clone, Copy, Debug)] @@ -41,6 +59,8 @@ pub struct SkyParams { pub pitch_scale: f32, /// Offset vertical fijo añadido a la coordenada de textura. pub v_offset: f32, + /// Proyección del panorama. Ver [`SkyMapping`]. + pub mapping: SkyMapping, } impl Default for SkyParams { @@ -54,6 +74,7 @@ impl Default for SkyParams { v_scale: 1.0, pitch_scale: 1.0, v_offset: 0.0, + mapping: SkyMapping::Cylindrical, } } } @@ -159,7 +180,7 @@ impl SkyBackdrop { let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("llimphi-3d-sky-uniform"), - size: 32, // 8 × f32 + size: 48, // 9 × f32 redondeado a múltiplo de 16 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -255,7 +276,11 @@ impl SkyBackdrop { /// Sube los parámetros del frame. Llamar antes de [`Self::draw`]. pub fn upload(&self, queue: &wgpu::Queue, p: &SkyParams) { - let mut b = Vec::with_capacity(32); + let mode = match p.mapping { + SkyMapping::Cylindrical => 0.0, + SkyMapping::Spherical => 1.0, + }; + let mut b = Vec::with_capacity(48); for v in [ p.yaw, p.pitch, @@ -265,6 +290,10 @@ impl SkyBackdrop { p.v_scale, p.pitch_scale, p.v_offset, + mode, + 0.0, // pad a múltiplo de 16 + 0.0, + 0.0, ] { b.extend_from_slice(&v.to_ne_bytes()); } @@ -289,6 +318,7 @@ const SKY_WGSL: &str = r#" struct SkyU { yaw: f32, pitch: f32, fov_x: f32, aspect: f32, wraps: f32, v_scale: f32, pitch_scale: f32, v_offset: f32, + mode: f32, _pad0: f32, _pad1: f32, _pad2: f32, }; @group(0) @binding(0) var s: SkyU; @group(1) @binding(0) var sky: texture_2d; @@ -317,7 +347,34 @@ fn vs(@builtin(vertex_index) vi: u32) -> SOut { @fragment fn fs(in: SOut) -> @location(0) vec4 { - // Azimut de la columna: centro = yaw, bordes ± fov/2. + if (s.mode > 0.5) { + // ---- Esférico (equirectangular): reconstruir el rayo de cámara ---- + // Base de cámara igual a Camera3d::orbit(target=0): el ojo está en + // (cp·sy, sp, cp·cy)·dist y mira al origen, up=+Y. + let sy = sin(s.yaw); let cy = cos(s.yaw); + let sp = sin(s.pitch); let cp = cos(s.pitch); + let zaxis = normalize(vec3(cp * sy, sp, cp * cy)); // ojo→origen invertido + let xaxis = normalize(cross(vec3(0.0, 1.0, 0.0), zaxis)); + let yaxis = cross(zaxis, xaxis); + let tan_x = tan(s.fov_x * 0.5); + let tan_y = tan_x / s.aspect; + let ndc_x = in.scr.x * 2.0 - 1.0; + let ndc_y = 1.0 - in.scr.y * 2.0; // arriba = +1 + // La cámara mira hacia -zaxis (look_at_rh). + let dir = normalize(ndc_x * tan_x * xaxis + ndc_y * tan_y * yaxis - zaxis); + let az = atan2(dir.x, dir.z); // -PI..PI + let el = asin(clamp(dir.y, -1.0, 1.0)); // -PI/2..PI/2 + let u = az / (2.0 * PI) + 0.5; + let v = 0.5 - el / PI; // arriba(+el) → v=0 + let col = textureSample(sky, samp, vec2(u, v)); + // El equirectangular tiene una singularidad en los polos (cenit/nadir): + // todas las columnas colapsan a una fila y la textura se abre en abanico + // ("starburst"). Atenuamos el muestreo a oscuro cerca del polo para que + // se disuelva en cielo profundo en vez de reventar en rayas radiales. + let pole = smoothstep(0.78, 0.995, abs(dir.y)); + return col * (1.0 - pole); + } + // ---- Cilíndrico (Doom): azimut por columna, fila por pitch lineal ---- let colang = s.yaw - (in.scr.x - 0.5) * s.fov_x; let su = fract(colang / (2.0 * PI) * s.wraps); let sv = clamp(in.scr.y * s.v_scale - s.pitch * s.pitch_scale + s.v_offset, 0.0, 1.0); diff --git a/llimphi-3d/src/voxel.rs b/llimphi-3d/src/voxel.rs index 029dc0d..3c00ca2 100644 --- a/llimphi-3d/src/voxel.rs +++ b/llimphi-3d/src/voxel.rs @@ -20,6 +20,25 @@ pub type DirtyBox = [u32; 6]; pub struct VoxelGrid { dim: [u32; 3], data: Vec<[u8; 4]>, + /// **Detalle sub-voxel por voxel** (Rg8): `[rugosidad, bisel]` codificados con + /// **offset +1** para distinguir "sin autor" (`[0,0]`) de "autor puso 0" + /// (`[1,1]`). `rug`/`bisel` autorados en `[0,1]` (rug re-escalado a amplitud + /// `0..1.5` en el shader). Todo `0` = el voxel usa el **look global** del + /// ray-march (`VoxelRenderer::{rugosidad_amp, bisel}`) → retrocompat byte a + /// byte cuando nadie lo toca. Lo llena [`set_detail`](Self::set_detail); el + /// renderer lo sube a un atlas paralelo (`dpool`) que espeja los slots del + /// pool de color. Es una propiedad de **construcción** del material, no un + /// canal editado en vivo (`sync`/`scroll_to` no lo re-suben). + detail: Vec<[u8; 2]>, + /// **Pincel de detalle** activo (bytes ya codificados con offset +1, o `None`). + /// Cuando está puesto, cada [`set`](Self::set)/[`set_rgba`](Self::set_rgba) + /// estampa también ESTE detalle en el voxel — así los estampadores profundos + /// (`place_objeto`, `stamp_bicho`…) heredan el detalle del material/ser sin + /// tener que threadearlo por cada `put`. Se prende con + /// [`set_detail_brush`](Self::set_detail_brush) y se apaga con + /// [`clear_detail_brush`](Self::clear_detail_brush). `None` = comportamiento + /// clásico (el voxel queda "sin autor" salvo `set_detail` explícito). + detail_brush: Option<[u8; 2]>, /// AABB de voxels mutados desde el último `take_dirty`. `None` = sin cambios. dirty: Option, } @@ -31,6 +50,8 @@ impl VoxelGrid { Self { dim, data: vec![[0, 0, 0, 0]; n], + detail: vec![[0, 0]; n], + detail_brush: None, dirty: None, } } @@ -72,15 +93,104 @@ impl VoxelGrid { self.dirty = None; } + /// Marca **todo** el grid como sucio (caja = grid entero) → el próximo + /// [`VoxelRenderer::sync`](crate::VoxelRenderer::sync) re-sube la ventana completa. + /// Útil al **regenerar** el grid por streaming (la ventana cambió de origen). + pub fn mark_all_dirty(&mut self) { + self.dirty = Some([0, 0, 0, self.dim[0] - 1, self.dim[1] - 1, self.dim[2] - 1]); + } + /// Marca un voxel sólido con color `rgb` (alpha = 255). Fuera de rango: no-op. pub fn set(&mut self, x: u32, y: u32, z: u32, rgb: [u8; 3]) { if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] { let i = self.idx(x, y, z); self.data[i] = [rgb[0], rgb[1], rgb[2], 255]; + if let Some(d) = self.detail_brush { + self.detail[i] = d; + } self.mark_dirty(x, y, z); } } + /// Marca un voxel con color `rgb` y **alpha** explícito. El alpha codifica + /// ocupación **y** brillo: `>127` = sólido; el shader lee el brillo como + /// `(1 - a/255)·2` (matte `255` → 0 brillo, `130` → lustre pleno). Pasar + /// alpha `≤127` lo dejaría "vacío" para el shader — usa `[130, 255]` para + /// voxels lustrosos. Fuera de rango: no-op. + pub fn set_rgba(&mut self, x: u32, y: u32, z: u32, rgb: [u8; 3], a: u8) { + if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] { + let i = self.idx(x, y, z); + self.data[i] = [rgb[0], rgb[1], rgb[2], a]; + if let Some(d) = self.detail_brush { + self.detail[i] = d; + } + self.mark_dirty(x, y, z); + } + } + + /// Convierte un factor de **brillo** `[0,1]` al alpha de voxel correspondiente + /// (`0` → `255` matte, `1` → `130` lustre pleno), manteniéndolo siempre sólido. + #[inline] + pub fn shine_to_alpha(brillo: f32) -> u8 { + 255 - (brillo.clamp(0.0, 1.0) * 125.0) as u8 + } + + /// Amplitud de rugosidad `[0,1.5]` que el shader mapea desde el byte de detalle + /// (espeja `(byte-1)/254·1.5`). Expuesto para que el caller razone en la misma + /// escala que `VoxelRenderer::rugosidad_amp`. + pub const DETALLE_RUG_MAX: f32 = 1.5; + + /// Codifica un factor `[0,1]` al byte de detalle con **offset +1** (`0` autorado + /// → `1`, distinguible del `0` "sin autor"). `norm` ya normalizado a `[0,1]`. + #[inline] + fn detail_byte(norm: f32) -> u8 { + 1 + (norm.clamp(0.0, 1.0) * 254.0).round() as u8 + } + + /// **Detalle sub-voxel de este voxel** (capa 3 del plan): `rugosidad` en + /// amplitud `[0, DETALLE_RUG_MAX]` y `bisel` en `[0,1]`, autorados por voxel. + /// Sobrescribe el look **global** del ray-march sólo para este voxel (dos + /// materiales en la misma escena pueden lucir distinto a la vez — lo que el + /// escalar global no podía). No cambia la ocupación; pon el voxel sólido con + /// `set`/`set_rgba` aparte. Fuera de rango: no-op. No marca dirty: el detalle + /// es propiedad de construcción (el renderer lo hornea al armar/rearmar). + pub fn set_detail(&mut self, x: u32, y: u32, z: u32, rugosidad: f32, bisel: f32) { + if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] { + let i = self.idx(x, y, z); + self.detail[i] = [ + Self::detail_byte(rugosidad / Self::DETALLE_RUG_MAX), + Self::detail_byte(bisel), + ]; + } + } + + /// **Prende el pincel de detalle**: a partir de aquí cada `set`/`set_rgba` + /// estampa también este `(rugosidad, bisel)` en el voxel escrito (misma escala + /// que [`set_detail`](Self::set_detail)). Pensado para que los estampadores + /// profundos (flora en `place_objeto`, seres en `stamp_bicho`, muebles…) hereden + /// el detalle del material/ser sin threadearlo por cada `put`. Acuérdate de + /// apagarlo con [`clear_detail_brush`](Self::clear_detail_brush) al terminar el + /// objeto, o teñirás de más. No toca voxels ya escritos. + pub fn set_detail_brush(&mut self, rugosidad: f32, bisel: f32) { + self.detail_brush = Some([ + Self::detail_byte(rugosidad / Self::DETALLE_RUG_MAX), + Self::detail_byte(bisel), + ]); + } + + /// Apaga el pincel de detalle (vuelve al comportamiento clásico: los `set` + /// posteriores no estampan detalle salvo `set_detail` explícito). + pub fn clear_detail_brush(&mut self) { + self.detail_brush = None; + } + + /// Detalle codificado `[rug, bisel]` (bytes con offset +1; `[0,0]` = sin autor) + /// del voxel `(x,y,z)`, o `None` fuera de rango. + pub fn detail_at(&self, x: u32, y: u32, z: u32) -> Option<[u8; 2]> { + (x < self.dim[0] && y < self.dim[1] && z < self.dim[2]) + .then(|| self.detail[self.idx(x, y, z)]) + } + /// Vacía **todos** los voxels y marca el grid entero como dirty (la próxima /// `VoxelRenderer::sync` re-sube todo). Para regenerar el contenido de una /// ventana de *streaming* in-place sin reconstruir el renderer. @@ -88,6 +198,9 @@ impl VoxelGrid { for px in &mut self.data { *px = [0, 0, 0, 0]; } + for d in &mut self.detail { + *d = [0, 0]; + } self.dirty = Some([0, 0, 0, self.dim[0] - 1, self.dim[1] - 1, self.dim[2] - 1]); } @@ -96,6 +209,7 @@ impl VoxelGrid { if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] { let i = self.idx(x, y, z); self.data[i] = [0, 0, 0, 0]; + self.detail[i] = [0, 0]; self.mark_dirty(x, y, z); } } @@ -197,6 +311,28 @@ impl VoxelGrid { out } + /// Extrae el **detalle sub-voxel** de un brick `(cx,cy,cz)` de lado `brick` + /// como Rg8 plano (`brick³` voxels × 2 bytes, x contiguo), padeando con `[0,0]` + /// (sin autor → look global) los voxels fuera del grid. Espeja `extract_brick` + /// para el atlas paralelo `dpool` del renderer. + pub fn extract_brick_detail(&self, brick: u32, cx: u32, cy: u32, cz: u32) -> Vec { + let b = brick; + let mut out = vec![0u8; (b * b * b * 2) as usize]; + for lz in 0..b { + for ly in 0..b { + for lx in 0..b { + let (x, y, z) = (cx * b + lx, cy * b + ly, cz * b + lz); + if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] { + let d = self.detail[self.idx(x, y, z)]; + let o = ((lx + ly * b + lz * b * b) * 2) as usize; + out[o..o + 2].copy_from_slice(&d); + } + } + } + } + out + } + /// Extrae una sub-caja RGBA contigua `[origin, origin+ext)` para subirla con /// `queue.write_texture` (M3: upload incremental de la región fina mutada). pub fn extract_fine(&self, origin: [u32; 3], ext: [u32; 3]) -> Vec { @@ -294,3 +430,72 @@ impl VoxelGrid { g } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detalle_sin_autor_es_cero() { + // Un grid recién nacido no tiene detalle → todo `[0,0]` (look global). + let g = VoxelGrid::new([4, 4, 4]); + assert_eq!(g.detail_at(1, 1, 1), Some([0, 0])); + assert_eq!(g.detail_at(9, 0, 0), None, "fuera de rango"); + } + + #[test] + fn detalle_offset_distingue_cero_autorado() { + let mut g = VoxelGrid::new([4, 4, 4]); + // Autor pone 0 en ambos → bytes `[1,1]`, DISTINTOS del `[0,0]` sin autor. + g.set_detail(0, 0, 0, 0.0, 0.0); + assert_eq!(g.detail_at(0, 0, 0), Some([1, 1]), "0 autorado ≠ sin autor"); + // Máximos → 255 (1 + 254). + g.set_detail(1, 0, 0, VoxelGrid::DETALLE_RUG_MAX, 1.0); + assert_eq!(g.detail_at(1, 0, 0), Some([255, 255])); + // Un valor intermedio queda por encima del piso. + g.set_detail(2, 0, 0, VoxelGrid::DETALLE_RUG_MAX * 0.5, 0.5); + let d = g.detail_at(2, 0, 0).unwrap(); + assert!(d[0] > 120 && d[0] < 135, "rug medio ≈ 128: {}", d[0]); + assert!(d[1] > 120 && d[1] < 135, "bisel medio ≈ 128: {}", d[1]); + } + + #[test] + fn pincel_de_detalle_tine_los_set_posteriores() { + let mut g = VoxelGrid::new([4, 4, 4]); + // Sin pincel: `set` deja el voxel "sin autor". + g.set(0, 0, 0, [10, 20, 30]); + assert_eq!(g.detail_at(0, 0, 0), Some([0, 0]), "sin pincel = sin autor"); + // Con pincel: `set` y `set_rgba` heredan el detalle activo. + g.set_detail_brush(VoxelGrid::DETALLE_RUG_MAX, 1.0); + g.set(1, 0, 0, [10, 20, 30]); + g.set_rgba(2, 0, 0, [10, 20, 30], 130); + assert_eq!(g.detail_at(1, 0, 0), Some([255, 255]), "set hereda el pincel"); + assert_eq!(g.detail_at(2, 0, 0), Some([255, 255]), "set_rgba hereda el pincel"); + // Apagado: vuelve a "sin autor". + g.clear_detail_brush(); + g.set(3, 0, 0, [10, 20, 30]); + assert_eq!(g.detail_at(3, 0, 0), Some([0, 0]), "pincel apagado = sin autor"); + } + + #[test] + fn extract_brick_detail_ubica_y_padea() { + let mut g = VoxelGrid::new([8, 8, 8]); + g.set_detail(1, 2, 3, VoxelGrid::DETALLE_RUG_MAX, 1.0); + let d = g.extract_brick_detail(8, 0, 0, 0); + assert_eq!(d.len(), 8 * 8 * 8 * 2); + // Índice del voxel (1,2,3) en el brick, ×2 bytes. + let o = (1 + 2 * 8 + 3 * 8 * 8) * 2; + assert_eq!(&d[o..o + 2], &[255, 255], "detalle en su lugar"); + // El resto padeado en 0 (sin autor). + assert_eq!(&d[0..2], &[0, 0]); + } + + #[test] + fn clear_borra_detalle() { + let mut g = VoxelGrid::new([4, 4, 4]); + g.set(0, 0, 0, [200, 100, 50]); + g.set_detail(0, 0, 0, 1.0, 0.8); + g.clear(0, 0, 0); + assert_eq!(g.detail_at(0, 0, 0), Some([0, 0]), "clear también zera el detalle"); + } +} diff --git a/llimphi-3d/src/voxel_renderer.rs b/llimphi-3d/src/voxel_renderer.rs index 1489598..a00271b 100644 --- a/llimphi-3d/src/voxel_renderer.rs +++ b/llimphi-3d/src/voxel_renderer.rs @@ -111,6 +111,14 @@ impl Default for Atmosphere { /// Renderer de voxels por ray-march de dos niveles sobre un brick pool sparse. pub struct VoxelRenderer { pool: wgpu::Texture, + /// Atlas **paralelo** al `pool` (mismos slots/dimensiones): por voxel guarda el + /// **detalle sub-voxel** autorado (`Rg8Uint` = `[rugosidad, bisel]` con offset + /// +1; `[0,0]` = sin autor → look global). Se hornea al armar/rearmar el pool y + /// crece con él (`grow_layers`); `sync`/`scroll_to` NO lo re-suben (el detalle es + /// propiedad de construcción del material, no un canal editado en vivo). Su + /// isosuperficie/indirección son las mismas del pool → el shader lo direcciona + /// igual. Todo `[0,0]` (caso común) = retrocompat byte a byte. + dpool: wgpu::Texture, indir: wgpu::Texture, bind_group: wgpu::BindGroup, /// Layout del bind group, guardado para re-armar el bind group cuando el pool @@ -148,6 +156,47 @@ pub struct VoxelRenderer { /// posición, acotado a la distancia a la luz). `true` por defecto. Apagarlo /// recupera el MVP plano (más barato) — útil para comparar off/on. pub point_shadows: bool, + /// **Rugosidad sub-voxel** (bump mapping procedural). Perturba la normal de + /// sombreado del voxel golpeado con el gradiente tangencial de un ruido de + /// valor 3D → la superficie deja de leerse rectangular a corta distancia + /// (roca/tierra granulada) sin tocar geometría ni memoria. `0.0` = off + /// (comportamiento clásico, cara plana axial). La perturbación se atenúa + /// sola en voxels lustrosos (vidrio/metal, `alpha` bajo) → sólo lo mate se + /// rugosiza. Rango útil ≈ `0.0..1.5`. + pub rugosidad_amp: f32, + /// Frecuencia del ruido de rugosidad (ciclos por voxel). Más alto = grano + /// más fino/apretado. Default `1.6`. Sólo importa si `rugosidad_amp > 0`. + pub rugosidad_freq: f32, + /// **Bisel / suavizado sub-voxel** (pendientes diagonales). Refina el hit del + /// ray-march contra la **isosuperficie trilineal de la ocupación** (derivada de + /// la forma, sin storage extra): el terreno escalonado se lee como rampa + /// diagonal y las esquinas convexas quedan biseladas, sin dejar de ser "bloque + /// tallado". `0.0` = off (voxel duro clásico, byte-idéntico); `1.0` = superficie + /// suave plena; intermedio = bisel parcial. Las zonas planas quedan planas (la + /// isosuperficie sólo se aparta de la cara en bordes/esquinas). Cuesta ~varias + /// muestras del campo por píxel golpeado → sólo se paga con `bisel > 0`. + pub bisel: f32, + /// Modo diagnóstico: pinta la **normal** de sombreado como color (`n·0.5+0.5`) + /// en vez del sombreado. Lo usa la certificación (`example bisel_demo`) para + /// medir qué fracción de la superficie dejó de ser axial. `false` en producción. + pub debug_normals: bool, + /// **Silueta sub-voxel POR MATERIAL** (`false` = off, retrocompat byte-idéntico). + /// Con `bisel` global prendido, `trace_smooth` traza la isosuperficie GLOBAL: la + /// silueta sale igual para todos (el `bisel` por voxel sólo cambiaba la NORMAL de + /// sombreado, no el contorno). Con este flag, el CAMPO mismo mezcla escalón↔trilineal + /// según el `bisel` POR VOXEL del material sólido cercano → un material duro (bisel + /// bajo) conserva la silueta axial/blocky mientras otro liso (bisel alto) la redondea, + /// en el MISMO frame. Cuesta: samplea el detalle de las 8 esquinas por evaluación del + /// campo → sólo pagalo cuando de veras quieres siluetas distintas por material. Sólo + /// tiene efecto con `bisel > 0` (que enciende `trace_smooth`). + pub silueta_por_voxel: bool, + /// **Cantidad de oclusión ambiental** `[0,1]` (`1.0` = clásica, retrocompat + /// byte-idéntico; `0.0` = sin AO). La AO se computa **axialmente** (`axis_of` de la + /// normal indexa voxels enteros); sobre una superficie REDONDEADA (bisel) el eje + /// dominante salta a lo largo de la esfera y la AO pinta **anillos concéntricos** — + /// correcto para bloques, artefacto para un ser orgánico. Bajala (≈0,2–0,4) al rendir + /// seres/blobs voxel suaves para que el cuerpo lea liso. Viaja en `n_lights.w`. + pub ao_amount: f32, } impl VoxelRenderer { @@ -194,6 +243,22 @@ impl VoxelRenderer { }); let pool_view = pool.create_view(&wgpu::TextureViewDescriptor::default()); + // Atlas de detalle sub-voxel, espejo del pool (mismos slots). Rg8Uint = dos + // bytes por voxel: `[rugosidad, bisel]` (con offset +1; `[0,0]` = sin autor). + let dpool = device.create_texture(&wgpu::TextureDescriptor { + label: Some("llimphi-3d-voxel-dpool"), + size: extent([ax * BRICK, ay * BRICK, az * BRICK]), + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D3, + format: wgpu::TextureFormat::Rg8Uint, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let dpool_view = dpool.create_view(&wgpu::TextureViewDescriptor::default()); + let indir = device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-3d-voxel-indir"), size: extent(cdim), @@ -236,6 +301,17 @@ impl VoxelRenderer { }, uniform_entry(2), uniform_entry(3), + // Atlas de detalle sub-voxel (Rg8Uint, direccionado como el pool). + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Uint, + view_dimension: wgpu::TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }, ], }); @@ -275,6 +351,10 @@ impl VoxelRenderer { binding: 3, resource: ubuf_ent.as_entire_binding(), }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&dpool_view), + }, ], }); @@ -328,6 +408,7 @@ impl VoxelRenderer { let mut r = Self { pool, + dpool, indir, bind_group, bgl, @@ -346,6 +427,12 @@ impl VoxelRenderer { entities: Vec::new(), lights: Vec::new(), point_shadows: true, + rugosidad_amp: 0.0, + rugosidad_freq: 1.6, + bisel: 0.0, + debug_normals: false, + silueta_por_voxel: false, + ao_amount: 1.0, }; // Poblar el pool: cada brick ocupado toma un slot incremental. @@ -359,6 +446,7 @@ impl VoxelRenderer { let idx = r.cell_idx(cx, cy, cz); r.slots[idx] = slot + 1; r.upload_brick(queue, slot, grid, cx, cy, cz); + r.upload_brick_detail(queue, slot, grid, cx, cy, cz); } } } @@ -422,6 +510,22 @@ impl VoxelRenderer { ); } + /// Sube el brick de **detalle sub-voxel** al `dpool` (espeja `upload_brick`, + /// mismo slot/origen, 2 bytes/voxel). Se llama junto a `upload_brick` al armar + /// el pool y al hornear bricks nuevos. + fn upload_brick_detail(&self, queue: &wgpu::Queue, slot: u32, grid: &VoxelGrid, cx: u32, cy: u32, cz: u32) { + let data = grid.extract_brick_detail(BRICK, cx, cy, cz); + let o = self.slot_origin(slot); + write_3d( + queue, + &self.dpool, + [o[0] * BRICK, o[1] * BRICK, o[2] * BRICK], + [BRICK, BRICK, BRICK], + 2, + &data, + ); + } + fn upload_indirection_full(&self, queue: &wgpu::Queue) { let mut bytes = Vec::with_capacity(self.slots.len() * 4); for &s in &self.slots { @@ -631,30 +735,49 @@ impl VoxelRenderer { | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); + // El dpool crece en paralelo (mismos slots): la franja nueva queda en `[0,0]` + // (sin autor → look global), que es lo que corresponde al contenido streameado. + let new_dpool = device.create_texture(&wgpu::TextureDescriptor { + label: Some("llimphi-3d-voxel-dpool"), + size: extent([ax * BRICK, ay * BRICK, new_az * BRICK]), + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D3, + format: wgpu::TextureFormat::Rg8Uint, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); - // Copia el atlas viejo (mismas dimensiones x/y, az capas) al nuevo. + // Copia ambos atlas viejos (mismas dimensiones x/y, az capas) al nuevo. let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("llimphi-3d-voxel-pool-grow"), }); - enc.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: &self.pool, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: &new_pool, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - extent([ax * BRICK, ay * BRICK, az * BRICK]), - ); + let copy = |enc: &mut wgpu::CommandEncoder, src: &wgpu::Texture, dst: &wgpu::Texture| { + enc.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: src, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: dst, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + extent([ax * BRICK, ay * BRICK, az * BRICK]), + ); + }; + copy(&mut enc, &self.pool, &new_pool); + copy(&mut enc, &self.dpool, &new_dpool); queue.submit(std::iter::once(enc.finish())); - // Re-armar el bind group con la view del pool nuevo (indir/ubufs intactos). + // Re-armar el bind group con las views nuevas (indir/ubufs intactos). let pool_view = new_pool.create_view(&wgpu::TextureViewDescriptor::default()); + let dpool_view = new_dpool.create_view(&wgpu::TextureViewDescriptor::default()); let indir_view = self.indir.create_view(&wgpu::TextureViewDescriptor::default()); self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("llimphi-3d-voxel-bg"), @@ -664,12 +787,14 @@ impl VoxelRenderer { wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&indir_view) }, wgpu::BindGroupEntry { binding: 2, resource: self.ubuf.as_entire_binding() }, wgpu::BindGroupEntry { binding: 3, resource: self.ubuf_ent.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&dpool_view) }, ], }); let old_cap = ax * ay * az; let new_cap = ax * ay * new_az; self.pool = new_pool; + self.dpool = new_dpool; self.atlas[2] = new_az; self.free.extend((old_cap..new_cap).rev()); } @@ -695,10 +820,22 @@ impl VoxelRenderer { for v in [s[0], s[1], s[2], self.atmosphere.god_rays.max(0.0)] { u.extend_from_slice(&v.to_ne_bytes()); } - for v in [self.cdim[0] as f32, self.cdim[1] as f32, self.cdim[2] as f32, 0.0] { + // cdim.w piggyback = amplitud de rugosidad sub-voxel (0 = off); atlas.w = su + // frecuencia. Reusan lanes que estaban en 0 → retrocompatible. + for v in [ + self.cdim[0] as f32, + self.cdim[1] as f32, + self.cdim[2] as f32, + self.rugosidad_amp.max(0.0), + ] { u.extend_from_slice(&v.to_ne_bytes()); } - for v in [self.atlas[0] as f32, self.atlas[1] as f32, self.atlas[2] as f32, 0.0] { + for v in [ + self.atlas[0] as f32, + self.atlas[1] as f32, + self.atlas[2] as f32, + self.rugosidad_freq.max(0.0), + ] { u.extend_from_slice(&v.to_ne_bytes()); } let a = &self.atmosphere; @@ -710,11 +847,12 @@ impl VoxelRenderer { ] { u.extend_from_slice(&v.to_ne_bytes()); } + // sky_horizon.w piggyback = bisel/suavizado sub-voxel (0 = off, retrocompat). for v in [ a.sky_horizon[0] as f32 / 255.0, a.sky_horizon[1] as f32 / 255.0, a.sky_horizon[2] as f32 / 255.0, - 0.0, + self.bisel.clamp(0.0, 1.0), ] { u.extend_from_slice(&v.to_ne_bytes()); } @@ -726,18 +864,23 @@ impl VoxelRenderer { // Se sube YA REDUCIDO a `[0, cdim)` (floormod) para que el `%` del shader // nunca opere sobre un negativo — el `%` de WGSL sobre enteros con signo es // ambiguo entre plataformas y rompía el wrap con orígenes negativos. + // scroll.w piggyback = flag de diagnóstico de normales (0 = sombreado normal). for v in [ floormod(self.brick_origin[0], self.cdim[0] as i32) as f32, floormod(self.brick_origin[1], self.cdim[1] as i32) as f32, floormod(self.brick_origin[2], self.cdim[2] as i32) as f32, - 0.0, + if self.debug_normals { 1.0 } else { 0.0 }, ] { u.extend_from_slice(&v.to_ne_bytes()); } // Luces puntuales: count (vec4) + MAX_LIGHTS × [pos+range, color]. let nl = self.lights.len().min(MAX_LIGHTS); let shadow_flag = if self.point_shadows { 1.0 } else { 0.0 }; - for v in [nl as f32, shadow_flag, 0.0, 0.0] { + // n_lights.z piggyback = flag de silueta sub-voxel por material (0 = off, + // retrocompat: el campo queda trilineal puro). + let silueta_flag = if self.silueta_por_voxel { 1.0 } else { 0.0 }; + // n_lights.w piggyback = cantidad de AO [0,1] (1 = clásico byte-idéntico). + for v in [nl as f32, shadow_flag, silueta_flag, self.ao_amount.clamp(0.0, 1.0)] { u.extend_from_slice(&v.to_ne_bytes()); } for i in 0..MAX_LIGHTS { @@ -915,18 +1058,26 @@ struct U { sky_horizon: vec4,// 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 + n_lights: vec4, // x = luces puntuales, y = sombras on/off, z = silueta sub-voxel por material, w = cantidad de AO [0,1] 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), +// Disco de muestreo para la penumbra: 16 taps en dos anillos (Poisson-ish) → +// borde de sombra suave sin bandeo. Más taps = penumbra más limpia (a más costo). +const SOFT_TAPS: i32 = 16; +const SOFT_DISK = array, 16>( + // anillo interno (r≈0.4) + vec2( 0.40, 0.00), vec2( 0.28, 0.28), + vec2( 0.00, 0.40), vec2(-0.28, 0.28), + vec2(-0.40, 0.00), vec2(-0.28, -0.28), + vec2( 0.00, -0.40), vec2( 0.28, -0.28), + // anillo externo (r≈0.85, rotado 22.5°) + vec2( 0.785, 0.325), vec2( 0.325, 0.785), + vec2(-0.325, 0.785), vec2(-0.785, 0.325), + vec2(-0.785, -0.325), vec2(-0.325, -0.785), + vec2( 0.325, -0.785), vec2( 0.785, -0.325), ); struct Entity { pos: vec4, @@ -941,6 +1092,9 @@ struct EntU { @group(0) @binding(1) var indir: texture_3d; @group(0) @binding(2) var u: U; @group(0) @binding(3) var ent: EntU; +// Detalle sub-voxel por voxel (Rg8Uint = [rugosidad, bisel] con offset +1; [0,0] = +// sin autor → look global). Direccionado como el pool (mismos slots/indirección). +@group(0) @binding(4) var dpool: texture_3d; struct VOut { @builtin(position) clip: vec4, @@ -997,6 +1151,34 @@ fn voxel_at(voxel: vec3) -> vec4 { return textureLoad(pool, acell * bu + local, 0); } +// Detalle sub-voxel crudo del voxel `voxel` = `[rug_byte, bisel_byte]` (offset +1; +// `[0,0]` = sin autor). Direccionamiento idéntico a `voxel_at`, sobre el `dpool`. +fn detail_raw(voxel: vec3) -> vec2 { + let vi = vec3(voxel); + if (any(vi < vec3(0)) || any(vec3(vi) >= u.grid_dim.xyz)) { return vec2(0u); } + let bu = i32(u.grid_dim.w); + let cc = vi / bu; + let s = slot_at(cc); + if (s == 0u) { return vec2(0u); } + 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(dpool, acell * bu + local, 0).xy; +} + +// Detalle DECODIFICADO = `(has, rug_amp, bisel)`. `has` = 1 si el voxel tiene detalle +// autorado (sobrescribe el look global), 0 = usar el global. `rug_amp` en `[0,1.5]`, +// `bisel` en `[0,1]` (espeja el offset +1 de CPU: `(byte-1)/254`). +fn pv_detail(voxel: vec3) -> vec3 { + let d = detail_raw(voxel); + if ((d.x | d.y) == 0u) { return vec3(0.0, 0.0, 0.0); } + let rug = (f32(d.x) - 1.0) / 254.0 * 1.5; + let bis = (f32(d.y) - 1.0) / 254.0; + return vec3(1.0, max(rug, 0.0), clamp(bis, 0.0, 1.0)); +} + struct Hit { hit: bool, vox: vec3, @@ -1006,7 +1188,10 @@ struct Hit { // DDA de dos niveles sobre el brick pool: marcha la grilla gruesa (indirección), // baja a la fina sólo en bricks con slot. -fn trace(ro: vec3, rd_in: vec3, dim: vec3, B: f32) -> Hit { +// DDA por el grid. `pass_glass` = los rayos de SOMBRA atraviesan el vidrio (voxels +// muy lustrosos, alpha < GLASS_A) para que las ventanas dejen entrar la luz; los +// rayos primarios (pass_glass=false) sí lo dibujan. +fn trace(ro: vec3, rd_in: vec3, dim: vec3, B: f32, pass_glass: bool) -> Hit { var h: Hit; h.hit = false; @@ -1050,7 +1235,10 @@ fn trace(ro: vec3, rd_in: vec3, dim: vec3, B: f32) -> Hit { 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) { + // Vidrio (muy lustroso): transparente a los rayos de sombra → la + // ventana no proyecta sombra y deja pasar la luz del día. + let es_vidrio = c.a < 0.62; + if (c.a > 0.5 && !(pass_glass && es_vidrio)) { h.hit = true; h.vox = voxel; h.normal = fnorm; @@ -1187,6 +1375,275 @@ fn frag_depth(p: vec3, dim: vec3) -> f32 { return clip.z / clip.w; } +// --- Rugosidad sub-voxel (bump procedural) --------------------------------- +// Hash 3D determinista → [0,1) (estilo iq). Sin RNG por-píxel: estable en el reel. +fn hash13(p3: vec3) -> f32 { + var p = fract(p3 * 0.1031); + p = p + dot(p, p.zyx + vec3(31.32)); + return fract((p.x + p.y) * p.z); +} +// Ruido de valor 3D (trilineal sobre hashes de lattice, con suavizado smoothstep). +fn vnoise(x: vec3) -> f32 { + let i = floor(x); + let f = fract(x); + let w = f * f * (3.0 - 2.0 * f); + let n000 = hash13(i + vec3(0.0, 0.0, 0.0)); + let n100 = hash13(i + vec3(1.0, 0.0, 0.0)); + let n010 = hash13(i + vec3(0.0, 1.0, 0.0)); + let n110 = hash13(i + vec3(1.0, 1.0, 0.0)); + let n001 = hash13(i + vec3(0.0, 0.0, 1.0)); + let n101 = hash13(i + vec3(1.0, 0.0, 1.0)); + let n011 = hash13(i + vec3(0.0, 1.0, 1.0)); + let n111 = hash13(i + vec3(1.0, 1.0, 1.0)); + let x00 = mix(n000, n100, w.x); + let x10 = mix(n010, n110, w.x); + let x01 = mix(n001, n101, w.x); + let x11 = mix(n011, n111, w.x); + return mix(mix(x00, x10, w.y), mix(x01, x11, w.y), w.z); +} +// Inclina la normal geométrica `geo_n` por el gradiente TANGENCIAL de una altura +// de ruido en `p` (espacio de voxel). `amp` = fuerza, `freq` = grano. Proyecta el +// gradiente al plano tangente → la normal se queda en el hemisferio exterior (sin +// auto-sombra) para amplitudes moderadas. Diferencias hacia-adelante (4 taps). +fn perturb_normal(geo_n: vec3, p: vec3, freq: f32, amp: f32) -> vec3 { + let sp = p * freq; + let e = 0.5; + let h0 = vnoise(sp); + let grad = vec3( + vnoise(sp + vec3(e, 0.0, 0.0)) - h0, + vnoise(sp + vec3(0.0, e, 0.0)) - h0, + vnoise(sp + vec3(0.0, 0.0, e)) - h0, + ); + let tang = grad - geo_n * dot(grad, geo_n); // sólo la componente tangente + return normalize(geo_n - tang * amp); +} + +// --- Bisel / suavizado sub-voxel (pendientes diagonales) ------------------- +// Ocupación de la celda que contiene `v` (1 sólido / 0 vacío). Fuera de grilla → 0. +fn occ_f(v: vec3) -> f32 { + return select(0.0, 1.0, voxel_at(v).a > 0.5); +} +// Campo escalar TRILINEAL = ocupación sobre los 8 centros de celda vecinos. Su +// isosuperficie (`field = 0.5`) es una superficie suave: plana donde la forma es +// plana, diagonal donde escalona, redondeada en las esquinas → el "voxel suave". +fn field_trilinear(p: vec3) -> f32 { + let g = p - vec3(0.5); + let i = floor(g); + let f = fract(g); + let c000 = occ_f(i + vec3(0.0, 0.0, 0.0)); + let c100 = occ_f(i + vec3(1.0, 0.0, 0.0)); + let c010 = occ_f(i + vec3(0.0, 1.0, 0.0)); + let c110 = occ_f(i + vec3(1.0, 1.0, 0.0)); + let c001 = occ_f(i + vec3(0.0, 0.0, 1.0)); + let c101 = occ_f(i + vec3(1.0, 0.0, 1.0)); + let c011 = occ_f(i + vec3(0.0, 1.0, 1.0)); + let c111 = occ_f(i + vec3(1.0, 1.0, 1.0)); + let x00 = mix(c000, c100, f.x); + let x10 = mix(c010, c110, f.x); + let x01 = mix(c001, c101, f.x); + let x11 = mix(c011, c111, f.x); + return mix(mix(x00, x10, f.y), mix(x01, x11, f.y), f.z); +} + +// **Suavidad local del material** en `p` para la silueta POR VOXEL: media, ponderada +// por peso trilineal Y ocupación, del `bisel` autorado de las 8 celdas vecinas SÓLIDAS +// (celda sin autor = 1.0 = suave). Un material duro (bisel bajo) tira su vecindario a +// 0 → el campo se vuelve escalón (silueta axial); uno liso lo deja trilineal (silueta +// redondeada). El ratio num/den ignora la magnitud del peso → aun en la celda vacía +// diagonal de una esquina convexa, si la única esquina sólida es dura, la suavidad ahí +// es baja y la esquina se conserva afilada. Sólo se llama con el flag prendido. +fn smoothness_local(p: vec3) -> f32 { + let g = p - vec3(0.5); + let i = floor(g); + let f = fract(g); + var num = 0.0; + var den = 0.0; + for (var dx = 0; dx < 2; dx = dx + 1) { + for (var dy = 0; dy < 2; dy = dy + 1) { + for (var dz = 0; dz < 2; dz = dz + 1) { + let corner = i + vec3(f32(dx), f32(dy), f32(dz)); + if (occ_f(corner) > 0.5) { + let wx = select(1.0 - f.x, f.x, dx == 1); + let wy = select(1.0 - f.y, f.y, dy == 1); + let wz = select(1.0 - f.z, f.z, dz == 1); + let w = wx * wy * wz; + let pv = pv_detail(corner); + let s = select(1.0, pv.z, pv.x > 0.5); // sin autor = suave + num = num + w * s; + den = den + w; + } + } + } + } + if (den < 1e-5) { return 1.0; } + return num / den; +} + +// Campo escalar del ray-march suave. Por defecto = trilineal (voxel suave). Con la +// silueta POR VOXEL prendida (`n_lights.z`), mezcla escalón↔trilineal según la suavidad +// del material sólido cercano: `mix(occ_step, trilineal, s)` — `s=1` deja el cruce en la +// isosuperficie (redondeado), `s=0` lo empuja a la cara del voxel (silueta dura axial), +// porque el escalón `occ_f` salta 0→1 justo ahí. Así dos materiales comparten un frame +// con siluetas distintas. Fast path byte-idéntico cuando el flag está off o todo suave. +fn field(p: vec3) -> f32 { + let suave = field_trilinear(p); + if (u.n_lights.z < 0.5) { return suave; } + let s = smoothness_local(p); + if (s >= 0.999) { return suave; } + let hard = occ_f(p); // escalón 0/1: su iso 0.5 cae en la cara del voxel (axial) + return mix(hard, suave, s); +} + +// Normal exterior de la isosuperficie en `p` = opuesta del gradiente del campo +// (que apunta hacia el sólido). Diferencias hacia-adelante. Gradiente nulo (plano +// saturado) → arriba por defecto. **Usa el campo TRILINEAL** (no el mixto): con la +// silueta por voxel, el campo duro es un escalón `occ_f` cuyo gradiente por diferencias +// es degenerado (salta 0/1) y haría titilar `axis_of` en las caras planas. La geometría +// (silueta) sale del campo mixto; el NORMAL del trilineal → `axis_of(sn)` elige la cara +// axial limpia para el material duro, y `sn` queda redondeado para el liso. +fn smooth_normal(p: vec3) -> vec3 { + // Gradiente de campo DIFUMINADO por diferencias centrales ANCHAS. El trilineal + // binario con una diferencia hacia-adelante corta (e=0.5) sólo toma direcciones + // cuantizadas por el patrón de ocupación 2×2×2 local → la normal salta en + // escalones a lo largo de una superficie curva grande → **anillos de cebolla** + // en el sombreado (el artefacto que hacía "monstruo" a un ser redondo). Promediar + // el gradiente sobre un radio mayor (diferencias centrales a dos radios, 0.9 y 1.8) + // integra suficiente vecindario para que la normal varíe continua → esfera lisa. + // Sólo se llama desde `trace_smooth` (bisel>0) → el terreno duro (bisel=0, `trace`) + // queda byte-idéntico. Radios < que el semieje de un miembro fino (≈6 vox) para no + // cancelar el gradiente cruzando la pieza. + let e0 = 1.0; + let e1 = 2.2; + let gx = + (field_trilinear(p + vec3(e0, 0.0, 0.0)) - field_trilinear(p - vec3(e0, 0.0, 0.0))) + + (field_trilinear(p + vec3(e1, 0.0, 0.0)) - field_trilinear(p - vec3(e1, 0.0, 0.0))); + let gy = + (field_trilinear(p + vec3(0.0, e0, 0.0)) - field_trilinear(p - vec3(0.0, e0, 0.0))) + + (field_trilinear(p + vec3(0.0, e1, 0.0)) - field_trilinear(p - vec3(0.0, e1, 0.0))); + let gz = + (field_trilinear(p + vec3(0.0, 0.0, e0)) - field_trilinear(p - vec3(0.0, 0.0, e0))) + + (field_trilinear(p + vec3(0.0, 0.0, e1)) - field_trilinear(p - vec3(0.0, 0.0, e1))); + let g = vec3(gx, gy, gz); + let gl = length(g); + if (gl < 1e-5) { return vec3(0.0, 1.0, 0.0); } + return -g / gl; +} + +// Eje dominante de una normal (para AO/facetado): la cara axial más cercana. +fn axis_of(n: vec3) -> vec3 { + let a = abs(n); + if (a.x >= a.y && a.x >= a.z) { return vec3(sign(n.x), 0.0, 0.0); } + if (a.y >= a.z) { return vec3(0.0, sign(n.y), 0.0); } + return vec3(0.0, 0.0, sign(n.z)); +} + +// **Capa 2b — traza la ISOSUPERFICIE suave del campo** (no el voxel duro): el hit, +// la silueta Y la normal salen del cruce `field = iso`. Mantiene el DDA GRUESO para +// saltar bricks vacíos (aceleración), pero dentro de un brick con contenido marcha el +// campo a sub-pasos y biseca el primer cruce de fuera(, rd_in: vec3, dim: vec3, B: f32, bisel: 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 iso = 0.5; + 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; + let dt = 0.5; + var f_prev = field(ro + safe_rd * t_enter) - iso; + let max_coarse = i32(cdim.x + cdim.y + cdim.z) + 3; + for (var ci = 0; ci < max_coarse; ci = ci + 1) { + if (slot_at(vec3(cc)) != 0u) { + let t_exit = min(min(t_max_c.x, t_max_c.y), t_max_c.z); + var t = t_cell; + // Cota de sub-pasos: un brick en diagonal mide hasta B·√3 de recorrido; + // a dt=0.5 hacen falta ~3.5·B pasos para cruzarlo (menos → pinholes en + // rayos diagonales, que se saltan el cruce en la parte lejana del brick). + let max_sub = i32(B) * 4 + 4; + for (var si = 0; si < max_sub; si = si + 1) { + let t_next = min(t + dt, t_exit); + let f_next = field(ro + safe_rd * t_next) - iso; + if (f_prev < 0.0 && f_next >= 0.0) { + // Bisección del cruce en [t, t_next]. + var a = t; + var b = t_next; + var fa = field(ro + safe_rd * a) - iso; + for (var k = 0; k < 6; k = k + 1) { + let tm = 0.5 * (a + b); + let fm = field(ro + safe_rd * tm) - iso; + if (fa * fm <= 0.0) { b = tm; } else { a = tm; fa = fm; } + } + let th = 0.5 * (a + b); + let ph = ro + safe_rd * th; + let sn = smooth_normal(ph); + // Color/brillo: la celda SÓLIDA más cercana entre las 8 esquinas del + // punto (en la isosuperficie hay ≥1 sólida). Muestrear la celda por + // proximidad al punto de hit y no por `floor(ph - n·k)` evita las + // motas negras (una celda con ocupación 0 aunque el campo ahí sea >½). + let bc = floor(ph - vec3(0.5)); + var best_c = clamp(floor(ph - sn * 0.5), vec3(0.0), dim - 1.0); + var best_d = 1e9; + for (var dx = 0; dx < 2; dx = dx + 1) { + for (var dy = 0; dy < 2; dy = dy + 1) { + for (var dz = 0; dz < 2; dz = dz + 1) { + let cell = bc + vec3(f32(dx), f32(dy), f32(dz)); + if (voxel_at(cell).a > 0.5) { + let d = distance(cell + vec3(0.5), ph); + if (d < best_d) { best_d = d; best_c = cell; } + } + } + } + } + // Bisel por voxel: si la celda golpeada trae detalle autorado, su + // bisel manda la mezcla facetado↔suave de la normal (dos materiales + // en la misma escena mezclan distinto); si no, el bisel global. La + // geometría/silueta ya salió de la isosuperficie global (barato). + let vox = clamp(best_c, vec3(0.0), dim - 1.0); + let pv = pv_detail(vox); + let bmix = select(bisel, pv.z, pv.x > 0.5); + h.hit = true; + h.t = th; + h.vox = vox; + h.normal = normalize(mix(axis_of(sn), sn, bmix)); + return h; + } + f_prev = f_next; + t = t_next; + if (t >= t_exit) { break; } + } + } + if (t_max_c.x < t_max_c.y && t_max_c.x < t_max_c.z) { + cc.x = cc.x + step.x; + t_cell = t_max_c.x; + t_max_c.x = t_max_c.x + t_delta_c.x; + } else if (t_max_c.y < t_max_c.z) { + cc.y = cc.y + step.y; + t_cell = t_max_c.y; + t_max_c.y = t_max_c.y + t_delta_c.y; + } else { + cc.z = cc.z + step.z; + t_cell = t_max_c.z; + t_max_c.z = t_max_c.z + t_delta_c.z; + } + if (any(cc < vec3(0.0)) || any(cc >= cdim)) { return h; } + } + return h; +} + struct FOut { @location(0) color: vec4, @builtin(frag_depth) depth: f32, @@ -1219,7 +1676,7 @@ fn god_rays(ro: vec3, rd: vec3, max_t: f32, dim: vec3, B: f32) -> let tt = (f32(i) + jitter) * dt; if (tt >= max_t) { break; } let sp = ro + rd * tt; - let sh = trace(sp, u.sun_dir.xyz, dim, B); + let sh = trace(sp, u.sun_dir.xyz, dim, B, true); acc = acc + select(1.0, 0.0, sh.hit); // 1 si el sol llega, 0 si está en sombra } acc = acc / f32(steps); @@ -1238,7 +1695,16 @@ fn fs(in: VOut) -> FOut { let B = u.grid_dim.w; let ro = ro_world + dim * 0.5; - let h = trace(ro, rd, dim, B); + // Bisel/suavizado sub-voxel: con `bisel > 0` la primaria TRAZA la isosuperficie + // suave del campo (silueta + normal diagonales, no sólo sombreado); `bisel = 0` = + // hit duro clásico byte-idéntico. Las sombras siempre usan el trace duro (barato). + let bisel = u.sky_horizon.w; + var h: Hit; + if (bisel > 0.0) { + h = trace_smooth(ro, rd, dim, B, bisel); + } else { + h = trace(ro, rd, dim, B, false); + } let t_vox = select(1e30, h.t, h.hit); let eh = trace_entities(ro, rd, t_vox); @@ -1249,6 +1715,9 @@ fn fs(in: VOut) -> FOut { var p: vec3; var ao: f32; var t_hit: f32; + // Brillo/reflectividad por voxel: viaja en el alpha (matte = 1.0 → 0 brillo; + // valores más bajos = más lustre). Sólo lo llevan los minerales lustrosos. + var shine_a: f32 = 1.0; if (eh.hit) { albedo = eh.color; normal = eh.normal; @@ -1256,11 +1725,27 @@ fn fs(in: VOut) -> FOut { ao = 1.0; t_hit = eh.t; } else if (h.hit) { - albedo = voxel_at(h.vox).rgb; + let vtex = voxel_at(h.vox); + albedo = vtex.rgb; + shine_a = vtex.a; + // `h.normal` ya es la normal (suave si bisel>0, axial si no). La AO se calcula + // con el eje dominante (indexa voxels enteros); para el hit duro es idéntica. normal = h.normal; p = ro + rd * h.t; - ao = compute_ao(h.vox, h.normal, p, dim); + ao = compute_ao(h.vox, axis_of(h.normal), p, dim); t_hit = h.t; + // Rugosidad sub-voxel: inclina la normal de SOMBREADO (ya biselada si aplica) + // por bump procedural. Sólo lo mate (roca/tierra) se rugosiza; el vidrio/metal + // lustroso (alpha bajo) queda liso. Si el voxel golpeado trae detalle autorado, + // su rugosidad manda; si no, la amplitud global (`u.cdim.w`). + let pvd = pv_detail(h.vox); + let rug_amp = select(u.cdim.w, pvd.y, pvd.x > 0.5); + if (rug_amp > 0.0) { + let matte = clamp((shine_a - 0.62) / 0.38, 0.0, 1.0); + if (matte > 0.0) { + normal = perturb_normal(normal, p, max(u.atlas.w, 1e-3), rug_amp * matte); + } + } } else { // Sin impacto: con niebla activa pintamos cielo propio (a profundidad // lejana, así una malla por delante igual se dibuja); sin niebla, @@ -1280,8 +1765,12 @@ fn fs(in: VOut) -> FOut { let ldir = u.sun_dir.xyz; let diff = max(dot(normal, ldir), 0.0); - let so = p + normal * 0.5 + ldir * 0.01; - let sh_v = trace(so, ldir, dim, B); + // Bias del origen de sombra: con bisel, la superficie suave está ~1 voxel DENTRO + // de la cáscara dura (que es la que traza la sombra) → hay que salir más para no + // nacer enterrado y auto-sombrearse (motas negras). Sin bisel, el 0.5 clásico. + let so_bias = select(0.5, 1.75, bisel > 0.0); + let so = p + normal * so_bias + ldir * 0.01; + let sh_v = trace(so, ldir, dim, B, true); let sh_e = trace_entities(so, ldir, 1e30); let shadow = select(1.0, 0.25, sh_v.hit || sh_e.hit); @@ -1289,7 +1778,10 @@ fn fs(in: VOut) -> FOut { // sale de su elevación (cálido al ras del horizonte → blanco en lo alto) y el // ambiente del color del cielo (rebote frío del cenit). El mood se controla // moviendo `sun_dir` y la paleta de cielo, que ya viajan en el uniform. - let ao_term = 0.35 + 0.65 * ao; + // AO escalada por `n_lights.w` (1 = clásica `0.35 + 0.65*ao` byte-idéntica; 0 = sin AO). + // La AO axial pinta anillos sobre superficies redondeadas (bisel) → bajarla para seres + // orgánicos, subirla para bloques. + let ao_term = 1.0 - u.n_lights.w * 0.65 * (1.0 - ao); let sun_h = clamp(u.sun_dir.y, 0.0, 1.0); let sun_col = mix(vec3(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 @@ -1321,7 +1813,7 @@ fn fs(in: VOut) -> FOut { let lrad = lc.w; // radio de área (penumbra); 0 = sombra dura. if (lrad <= 0.0) { // Sombra dura: un solo shadow ray hacia el centro de la luz. - let hv = trace(lso, ldir2, dim, B); + let hv = trace(lso, ldir2, dim, B, true); let blocked_v = hv.hit && hv.t < d - bias; let he = trace_entities(lso, ldir2, d - bias); vis = select(1.0, 0.0, blocked_v || he.hit); @@ -1341,7 +1833,7 @@ fn fs(in: VOut) -> FOut { let to2 = lp2 - p; let d2 = length(to2); let dir2 = to2 / max(d2, 1e-3); - let hv = trace(lso, dir2, dim, B); + let hv = trace(lso, dir2, dim, B, true); let bv = hv.hit && hv.t < d2 - bias; let he = trace_entities(lso, dir2, d2 - bias); if (bv || he.hit) { occ = occ + 1.0; } @@ -1354,6 +1846,25 @@ fn fs(in: VOut) -> FOut { var color = albedo * light * ao_term; + // Brillo especular + glaseado por voxel (algo que la luz plana de Minecraft no + // hace): reflejo dependiente de la vista sobre los minerales lustrosos. `shine` + // sale del alpha del voxel (matte 1.0 → 0). Nuestro ray-march ya tiene la + // normal de cara y el rayo de cámara, así que el destello es real por-píxel. + let shine = clamp((1.0 - shine_a) * 2.04, 0.0, 1.0); + if (shine > 0.0) { + let vdir = -rd; // hacia la cámara + let hvec = normalize(u.sun_dir.xyz + vdir); // half-vector Blinn-Phong + let ndh = max(dot(normal, hvec), 0.0); + // Lóbulo moderado: las normales del voxel son axiales, un exponente alto + // no engancharía ninguna cara visible. La dureza sube un poco el foco. + let power = mix(2.0, 9.0, shine); + let spec = pow(ndh, power) * shine * shadow * 3.0; + // Glaseado (fresnel): lustre vidrioso a ángulo rasante, tinte frío del cielo. + let fres = pow(1.0 - max(dot(normal, vdir), 0.0), 4.0) * shine * 0.45; + let gloss = sun_col * spec + mix(sun_col, u.sky_zenith.xyz, 0.6) * fres; + color = color + gloss; + } + // Niebla / perspectiva aérea: lo lejano desvanece hacia el cielo en esa // dirección, lo que hace legible el borde de un mundo grande. if (fog_density > 0.0) { @@ -1364,9 +1875,38 @@ fn fs(in: VOut) -> FOut { color = color + vec3(1.0, 0.95, 0.82) * gr; } + // Diagnóstico: pinta la normal de sombreado como color (n·0.5+0.5). Lo usa la + // certificación para medir qué fracción de la superficie dejó de ser axial. + if (u.scroll.w > 0.5) { + var dbg: FOut; + dbg.color = vec4(normal * 0.5 + 0.5, 1.0); + dbg.depth = frag_depth(p, dim); + return dbg; + } + var out: FOut; out.color = vec4(color, 1.0); out.depth = frag_depth(p, dim); return out; } "#; + +#[cfg(test)] +mod tests { + use super::*; + + /// El WGSL del ray-march parsea y valida sin GPU (naga). Ataja errores que de + /// otro modo sólo saldrían al crear el pipeline — incluye las funciones de + /// rugosidad sub-voxel (`hash13`/`vnoise`/`perturb_normal`). + #[test] + fn wgsl_valida() { + let module = naga::front::wgsl::parse_str(WGSL) + .unwrap_or_else(|e| panic!("WGSL del voxel-renderer no parsea: {e:?}")); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .unwrap_or_else(|e| panic!("WGSL del voxel-renderer no valida: {e:?}")); + } +} diff --git a/llimphi-anim-studio/Cargo.toml b/llimphi-anim-studio/Cargo.toml new file mode 100644 index 0000000..c6988d0 --- /dev/null +++ b/llimphi-anim-studio/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "llimphi-anim-studio" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Studio de máquinas de animación estilo Rive: una interfaz Llimphi para autorar el grafo de estados de llimphi-anim (estados, transiciones, condiciones, inputs) sobre un nodegraph, con preview en vivo dirigido por inputs y persistencia RON. Frontend intercambiable sobre el runtime agnóstico — el StateMachine no sabe quién lo edita." + +[[bin]] +name = "llimphi-anim-studio" +path = "src/main.rs" + +[dependencies] +# El runtime estilo Rive — el core agnóstico que esta UI autora. +llimphi-anim = { workspace = true } +# Render de la malla deformada (solid/wireframe + fit/bounds) para el preview del rig. +llimphi-mesh = { workspace = true } +# Carga de imágenes de disco (PNG/JPEG/WEBP → ImageBrush) para texturizar la malla. +llimphi-image = { workspace = true } +# Bucle Elm + ventana + re-exports (raster/layout/text). +llimphi-ui = { workspace = true } +# Paletas semánticas de color. +llimphi-theme = { workspace = true } +# El lienzo de nodos: estados = nodos, transiciones = cables (drag pin→pin). +llimphi-widget-nodegraph = { workspace = true } +# Botones y sliders del inspector + controles de inputs en vivo. +llimphi-widget-button = { workspace = true } +llimphi-widget-slider = { workspace = true } +# Campo de texto para nombrar estados / inputs. +llimphi-widget-text-input = { workspace = true } +# Portapapeles del sistema para copiar/cortar/pegar en los campos. +llimphi-clipboard = { workspace = true } +# Persistencia del documento (grafo) en texto editable a mano. +serde = { workspace = true, features = ["derive"] } +ron = { workspace = true } diff --git a/llimphi-anim-studio/LEEME.md b/llimphi-anim-studio/LEEME.md new file mode 100644 index 0000000..bee6437 --- /dev/null +++ b/llimphi-anim-studio/LEEME.md @@ -0,0 +1,24 @@ +# llimphi-anim-studio + +*Read this in English: [README.md](README.md).* + +Biblioteca del studio de animación «rive»: los documentos **serializables** +que el editor autora —el grafo de estados (`doc::Doc`) y el rig esqueletal +(`rig::RigDoc`)— y el `Project` que los junta en el `.ron` que se guarda +y se lee. + +Antes estos tipos vivían dentro del binario del editor; exponerlos como +biblioteca permite que **otros consumidores** carguen el mismo formato sin +duplicar los structs. El primero es `mirada-fondo`, que reproduce un proyecto +como **fondo** (splash/greeter/wallpaper): bakea el rig deformado a frames y +los blitea. El editor (`main.rs`) es ahora un frontend más sobre esta lib. + +## Uso + +```sh +cargo run --release -p llimphi-anim-studio +``` + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-anim-studio/README.md b/llimphi-anim-studio/README.md new file mode 100644 index 0000000..6da183d --- /dev/null +++ b/llimphi-anim-studio/README.md @@ -0,0 +1,22 @@ +# llimphi-anim-studio + +The library of the "rive" animation studio: the **serializable** documents the +editor authors — the state graph (`doc::Doc`) and the skeletal rig +(`rig::RigDoc`) — plus the `Project` that joins them into the `.ron` that is +saved and read. + +These types used to live inside the editor's binary; exposing them as a library +lets **other consumers** load the same format without duplicating the structs. +The first is `mirada-fondo`, which plays a project as a **background** +(splash/greeter/wallpaper): it bakes the deformed rig to frames and blits them. +The editor (`main.rs`) is now just one more frontend over this library. + +## Use + +```sh +cargo run --release -p llimphi-anim-studio +``` + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-anim-studio/src/doc.rs b/llimphi-anim-studio/src/doc.rs new file mode 100644 index 0000000..6162d57 --- /dev/null +++ b/llimphi-anim-studio/src/doc.rs @@ -0,0 +1,410 @@ +//! El **documento** del studio: la representación editable del grafo de estados, +//! agnóstica del render. Es la fuente de verdad que la UI manipula y que se +//! **compila** a un [`llimphi_anim::StateMachine`] ejecutable cada vez que cambia, +//! para alimentar el preview en vivo. +//! +//! ## Por qué un modelo propio +//! +//! `llimphi_anim::StateMachine` es un *builder* de una sola dirección: se +//! construye con `add_state`/`transition`, se congela en un `Arc` al hacer +//! `instance()`, y **no expone introspección** (sus campos son privados). Un +//! editor necesita leer, reordenar y reescribir el grafo, así que el studio +//! mantiene este `Doc` editable (con posiciones de canvas, defaults de inputs, +//! etc.) y lo proyecta al runtime con [`Doc::compile`]. La relación es la misma +//! que `Project → render` en voxel-studio: el documento es rico, el runtime es +//! la proyección ejecutable. +//! +//! El `ClipId` que consume el runtime es simplemente el **índice del estado**: +//! como el studio no carga assets reales (Lottie/rig) en la Fase 1, el preview +//! pinta cada estado con un color/movimiento sintético derivado de su índice. El +//! día que se cableen clips reales, sólo cambia el consumidor del `RenderFrame`. + +use llimphi_anim::{Cmp, Condition, StateMachine}; +use serde::{Deserialize, Serialize}; + +/// Operador de comparación numérica — espejo serializable de [`llimphi_anim::Cmp`] +/// (que no deriva serde). Se convierte a él al compilar. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CmpOp { + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +impl CmpOp { + pub const ALL: [CmpOp; 6] = [ + CmpOp::Eq, + CmpOp::Ne, + CmpOp::Lt, + CmpOp::Le, + CmpOp::Gt, + CmpOp::Ge, + ]; + pub fn symbol(self) -> &'static str { + match self { + CmpOp::Eq => "==", + CmpOp::Ne => "≠", + CmpOp::Lt => "<", + CmpOp::Le => "≤", + CmpOp::Gt => ">", + CmpOp::Ge => "≥", + } + } + fn to_anim(self) -> Cmp { + match self { + CmpOp::Eq => Cmp::Eq, + CmpOp::Ne => Cmp::Ne, + CmpOp::Lt => Cmp::Lt, + CmpOp::Le => Cmp::Le, + CmpOp::Gt => Cmp::Gt, + CmpOp::Ge => Cmp::Ge, + } + } +} + +/// Tipo de un input de la máquina (espejo del trío Rive bool/number/trigger). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum InputKind { + Bool, + Number, + Trigger, +} + +impl InputKind { + pub fn label(self) -> &'static str { + match self { + InputKind::Bool => "bool", + InputKind::Number => "número", + InputKind::Trigger => "trigger", + } + } +} + +/// Una guarda editable de una transición. Se compila a [`llimphi_anim::Condition`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum CondDef { + /// `bool input == value`. + Bool { input: String, value: bool }, + /// `number input value`. + Number { input: String, op: CmpOp, value: f64 }, + /// el trigger `input` fue disparado este frame. + Trigger { input: String }, + /// el clip del estado de origen terminó (sólo estados no-loop). + ClipDone, +} + +impl CondDef { + fn to_anim(&self) -> Condition { + match self { + CondDef::Bool { input, value } => Condition::bool(input.clone(), *value), + CondDef::Number { input, op, value } => { + Condition::number(input.clone(), op.to_anim(), *value) + } + CondDef::Trigger { input } => Condition::trigger(input.clone()), + CondDef::ClipDone => Condition::clip_done(), + } + } +} + +/// Un estado del grafo: un clip (por índice) con su velocidad y loop, más su +/// posición en el lienzo y la duración nominal del clip (para `ClipDone`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StateDef { + pub name: String, + /// Velocidad de reproducción del clip (escala el tiempo). + pub speed: f64, + /// ¿El clip hace loop? Si no, su `ClipDone` puede disparar transiciones. + pub looping: bool, + /// Duración nominal del clip en segundos (para `ClipDone` y el preview). + pub clip_len: f64, + /// Posición del nodo en el lienzo (pixels relativos al canvas). + pub x: f32, + pub y: f32, +} + +impl StateDef { + pub fn new(name: impl Into, x: f32, y: f32) -> Self { + StateDef { + name: name.into(), + speed: 1.0, + looping: true, + clip_len: 2.0, + x, + y, + } + } +} + +/// Una transición editable. `from = None` ⇒ transición *any-state*. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TransDef { + /// Estado de origen (índice en `states`), o `None` para any-state. + pub from: Option, + /// Estado destino (índice en `states`). + pub to: usize, + /// Guardas AND. Vacío ⇒ la transición **nunca** dispara (el runtime la ignora). + pub conditions: Vec, + /// Duración del crossfade (segundos). `0.0` = salto instantáneo. + pub duration_secs: f64, +} + +/// Un input declarado, con su default. Los defaults siembran los controles en +/// vivo del panel de preview. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InputDef { + pub name: String, + pub kind: InputKind, + pub bool_default: bool, + pub num_default: f64, +} + +impl InputDef { + pub fn new(name: impl Into, kind: InputKind) -> Self { + InputDef { + name: name.into(), + kind, + bool_default: false, + num_default: 0.0, + } + } +} + +/// El documento entero: estados + transiciones + inputs + estado de entrada. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Doc { + pub states: Vec, + pub transitions: Vec, + pub inputs: Vec, + /// Índice del estado de entrada. + pub entry: usize, +} + +impl Default for Doc { + fn default() -> Self { + Doc { + states: Vec::new(), + transitions: Vec::new(), + inputs: Vec::new(), + entry: 0, + } + } +} + +impl Doc { + /// Proyecta el documento a un [`StateMachine`] ejecutable. Índice de estado = + /// `ClipId`. Transiciones con índices fuera de rango se descartan (defensa + /// ante un documento inconsistente recién editado). + pub fn compile(&self) -> StateMachine { + let mut sm = StateMachine::new(); + let n = self.states.len(); + for (i, s) in self.states.iter().enumerate() { + sm.add_state(s.name.clone(), i as u32, s.speed, s.looping); + sm.set_clip_duration(i as u32, s.clip_len); + } + if n > 0 { + sm.set_entry(self.entry.min(n - 1)); + } + for t in &self.transitions { + if t.to >= n { + continue; + } + let conds: Vec = t.conditions.iter().map(CondDef::to_anim).collect(); + match t.from { + Some(f) if f < n => sm.transition(f, t.to, conds, t.duration_secs), + None => sm.transition_any(t.to, conds, t.duration_secs), + Some(_) => {} // origen fuera de rango → descartar + } + } + sm + } + + /// Documento de arranque: el clásico `idle ⇄ walk` por un bool `moving`, más + /// un `jump` any-state por trigger. Da algo vivo que tocar al abrir. + pub fn starter() -> Self { + let mut doc = Doc::default(); + doc.states.push(StateDef::new("idle", 60.0, 80.0)); + let mut walk = StateDef::new("walk", 320.0, 80.0); + walk.speed = 1.0; + doc.states.push(walk); + let mut jump = StateDef::new("jump", 190.0, 240.0); + jump.looping = false; + jump.clip_len = 0.6; + doc.states.push(jump); + doc.entry = 0; + + doc.inputs.push(InputDef::new("moving", InputKind::Bool)); + doc.inputs.push(InputDef::new("jump", InputKind::Trigger)); + + doc.transitions.push(TransDef { + from: Some(0), + to: 1, + conditions: vec![CondDef::Bool { + input: "moving".into(), + value: true, + }], + duration_secs: 0.2, + }); + doc.transitions.push(TransDef { + from: Some(1), + to: 0, + conditions: vec![CondDef::Bool { + input: "moving".into(), + value: false, + }], + duration_secs: 0.2, + }); + // any-state: ⚡jump → jump (instantáneo) + doc.transitions.push(TransDef { + from: None, + to: 2, + conditions: vec![CondDef::Trigger { + input: "jump".into(), + }], + duration_secs: 0.0, + }); + // jump termina → idle + doc.transitions.push(TransDef { + from: Some(2), + to: 0, + conditions: vec![CondDef::ClipDone], + duration_secs: 0.0, + }); + doc + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compila_y_arranca_en_entry() { + let doc = Doc::starter(); + let inst = doc.compile().instance(); + assert_eq!(inst.current_state(), "idle"); + } + + #[test] + fn bool_dispara_idle_a_walk() { + let mut inst = Doc::starter().compile().instance(); + inst.set_bool("moving", true); + inst.advance(0.3); // > duración de blend (0.2) + assert_eq!(inst.current_state(), "walk"); + inst.set_bool("moving", false); + inst.advance(0.3); + assert_eq!(inst.current_state(), "idle"); + } + + #[test] + fn trigger_anystate_salta_a_jump_y_vuelve() { + let mut inst = Doc::starter().compile().instance(); + inst.fire("jump"); + inst.advance(0.016); + assert_eq!(inst.current_state(), "jump"); + // El clip de 0.6 s termina (no loop) → vuelve a idle por ClipDone. + inst.advance(0.7); + assert_eq!(inst.current_state(), "idle"); + } + + #[test] + fn transicion_con_indice_fuera_de_rango_se_descarta() { + let mut doc = Doc::default(); + doc.states.push(StateDef::new("a", 0.0, 0.0)); + // destino inexistente → no debe panickear al compilar + doc.transitions.push(TransDef { + from: Some(0), + to: 99, + conditions: vec![CondDef::ClipDone], + duration_secs: 0.0, + }); + let inst = doc.compile().instance(); + assert_eq!(inst.current_state(), "a"); + } + + #[test] + fn condicion_numerica_compila() { + let mut doc = Doc::default(); + doc.states.push(StateDef::new("slow", 0.0, 0.0)); + doc.states.push(StateDef::new("fast", 0.0, 0.0)); + doc.inputs.push(InputDef::new("speed", InputKind::Number)); + doc.transitions.push(TransDef { + from: Some(0), + to: 1, + conditions: vec![CondDef::Number { + input: "speed".into(), + op: CmpOp::Gt, + value: 5.0, + }], + duration_secs: 0.0, + }); + let mut inst = doc.compile().instance(); + inst.set_number("speed", 3.0); + inst.advance(0.016); + assert_eq!(inst.current_state(), "slow"); + inst.set_number("speed", 9.0); + inst.advance(0.016); + assert_eq!(inst.current_state(), "fast"); + } + + #[test] + fn round_trip_ron() { + let doc = Doc::starter(); + let ron = ron::ser::to_string_pretty(&doc, ron::ser::PrettyConfig::default()) + .expect("serializa"); + let back: Doc = ron::from_str(&ron).expect("deserializa"); + assert_eq!(doc, back); + } + + /// Un grafo con forma de **botón reactivo** (idle/hover/press, hovered+pressed) + /// authored en el studio sobrevive el round-trip RON y, al compilar, corre el + /// ciclo completo dirigido por inputs. Certifica el lazo autor→export→consumo + /// que enchufa `llimphi-widget-rive-button::from_state_machine`. + fn boton_doc() -> Doc { + let mut doc = Doc::default(); + doc.states.push(StateDef::new("idle", 60.0, 80.0)); + doc.states.push(StateDef::new("hover", 260.0, 80.0)); + let mut press = StateDef::new("press", 460.0, 80.0); + press.looping = false; + press.clip_len = 0.4; + doc.states.push(press); + doc.entry = 0; + + doc.inputs.push(InputDef::new("hovered", InputKind::Bool)); + doc.inputs.push(InputDef::new("pressed", InputKind::Trigger)); + + let b = |input: &str, value: bool| CondDef::Bool { input: input.into(), value }; + doc.transitions.push(TransDef { from: Some(0), to: 1, conditions: vec![b("hovered", true)], duration_secs: 0.18 }); + doc.transitions.push(TransDef { from: Some(1), to: 0, conditions: vec![b("hovered", false)], duration_secs: 0.18 }); + doc.transitions.push(TransDef { from: None, to: 2, conditions: vec![CondDef::Trigger { input: "pressed".into() }], duration_secs: 0.06 }); + doc.transitions.push(TransDef { from: Some(2), to: 1, conditions: vec![CondDef::ClipDone, b("hovered", true)], duration_secs: 0.18 }); + doc.transitions.push(TransDef { from: Some(2), to: 0, conditions: vec![CondDef::ClipDone, b("hovered", false)], duration_secs: 0.18 }); + doc + } + + #[test] + fn boton_authorado_round_trip_corre_el_ciclo() { + let doc = boton_doc(); + // Export → import (lo que hace Project::save/load del studio). + let ron = ron::ser::to_string_pretty(&doc, ron::ser::PrettyConfig::default()).expect("serializa"); + let back: Doc = ron::from_str(&ron).expect("deserializa"); + assert_eq!(doc, back); + + // Consumo: la máquina compilada del doc reimportado corre el ciclo. + let mut inst = back.compile().instance(); + assert_eq!(inst.current_state(), "idle"); + inst.set_bool("hovered", true); + inst.advance(0.5); + assert_eq!(inst.current_state(), "hover"); + inst.fire("pressed"); + inst.advance(0.2); + assert_eq!(inst.current_state(), "press"); + inst.advance(0.6); // clip de 0.4 s termina → ClipDone, sigue hovered → hover + assert_eq!(inst.current_state(), "hover"); + inst.set_bool("hovered", false); + inst.advance(0.5); + assert_eq!(inst.current_state(), "idle"); + } +} diff --git a/llimphi-anim-studio/src/lib.rs b/llimphi-anim-studio/src/lib.rs new file mode 100644 index 0000000..d01a46a --- /dev/null +++ b/llimphi-anim-studio/src/lib.rs @@ -0,0 +1,45 @@ +//! Biblioteca del studio de animación «rive»: los documentos **serializables** +//! que el editor autora —el grafo de estados ([`doc::Doc`]) y el rig esqueletal +//! ([`rig::RigDoc`])— y el [`Project`] que los junta en el `.ron` que se guarda +//! y se lee. +//! +//! Antes estos tipos vivían dentro del binario del editor; exponerlos como +//! biblioteca permite que **otros consumidores** carguen el mismo formato sin +//! duplicar los structs. El primero es `mirada-fondo`, que reproduce un proyecto +//! como **fondo** (splash/greeter/wallpaper): bakea el rig deformado a frames y +//! los blitea. El editor (`main.rs`) es ahora un frontend más sobre esta lib. + +#![forbid(unsafe_code)] + +pub mod doc; +pub mod rig; + +use serde::{Deserialize, Serialize}; + +/// El proyecto persistido: ambas superficies juntas en un solo `.ron` (el grafo +/// de estados + el rig esqueletal). Es el formato que escribe el editor y el que +/// carga `mirada-fondo` para reproducir un «rive» como fondo. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Project { + pub doc: doc::Doc, + #[serde(default = "rig::RigDoc::starter")] + pub rig: rig::RigDoc, +} + +impl Default for Project { + fn default() -> Self { + Project { + doc: doc::Doc::starter(), + rig: rig::RigDoc::starter(), + } + } +} + +impl Project { + /// Carga y parsea un proyecto `.ron` de disco. + pub fn load(path: impl AsRef) -> Result { + let p = path.as_ref(); + let s = std::fs::read_to_string(p).map_err(|e| format!("no se pudo leer {p:?}: {e}"))?; + ron::from_str(&s).map_err(|e| format!("RON inválido en {p:?}: {e}")) + } +} diff --git a/llimphi-anim-studio/src/main.rs b/llimphi-anim-studio/src/main.rs new file mode 100644 index 0000000..10d483f --- /dev/null +++ b/llimphi-anim-studio/src/main.rs @@ -0,0 +1,1935 @@ +//! # llimphi-anim-studio — el autor de máquinas de animación, con interfaz +//! +//! Editor visual del grafo de estados estilo Rive de [`llimphi_anim`]: los +//! **estados** son nodos en un lienzo (`llimphi-widget-nodegraph`), las +//! **transiciones** son cables que se trazan arrastrando pin→pin, y un panel de +//! **inputs en vivo** (toggles/sliders/triggers) maneja una `Instance` real cuyo +//! estado actual **se ilumina en el grafo** y se pinta en un preview sintético. +//! El documento ([`doc::Doc`]) es la fuente de verdad; el `StateMachine` es su +//! proyección ejecutable, recompilada en cada edición. +//! +//! ```bash +//! cargo run -p llimphi-anim-studio --release # ventana interactiva +//! ``` +//! +//! Persistencia en `anim-studio.ron` (texto editable a mano). + +use std::collections::HashMap; +use std::time::Duration; + +use llimphi_anim::{Instance, RenderFrame}; +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::prelude::{ + length, percent, AlignItems, Dimension, FlexDirection, Size, Style, +}; +use llimphi_ui::llimphi_raster::kurbo::Rect as KRect; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::vello; +use llimphi_ui::{ + App, DragPhase, Handle, KeyEvent, View, +}; +use llimphi_widget_button::{button_view, ButtonPalette}; +use llimphi_widget_nodegraph::{ + nodegraph_view_styled, NodeId, NodeSpec, NodegraphMetrics, NodegraphPalette, NodeTint, Wire, +}; +use llimphi_widget_slider::{slider_view, SliderPalette}; +use llimphi_clipboard::SystemClipboard; +use llimphi_widget_text_input::{ + text_input_view_full, TextInputEvent, TextInputPalette, TextInputState, +}; + +use llimphi_anim_studio::{doc, rig, Project}; +use doc::{CmpOp, CondDef, Doc, InputDef, InputKind, StateDef, TransDef}; +use rig::{BoneDef, MeshMode, RigDoc}; + +/// Dónde se guarda/carga el grafo (relativo al cwd). +const PROJECT_PATH: &str = "anim-studio.ron"; +/// Paso de simulación del preview (~30 fps). +const DT: f64 = 1.0 / 30.0; + +// ============================================================================= +// Selección +// ============================================================================= + +/// Qué elemento está bajo edición en el inspector. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Sel { + None, + State(usize), + Trans(usize), +} + +/// Las dos superficies del studio: el grafo de estados (F1) y el rig +/// esqueletal (F2). Comparten ventana y persistencia; se conmutan en la barra +/// superior. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + /// Editor de la máquina de estados. + Estados, + /// Editor del rig esqueletal (cadena de huesos + malla + IK). + Rig, +} + +// `Project` (doc + rig) vive ahora en la lib (`llimphi_anim_studio::Project`), +// para que `mirada-fondo` cargue el mismo formato sin duplicar los tipos. + +// ============================================================================= +// Modelo +// ============================================================================= + +struct Model { + doc: Doc, + theme: Theme, + mode: Mode, + sel: Sel, + + /// El rig esqueletal (modo Rig). + rig: RigDoc, + /// Hueso seleccionado en el modo Rig. + rig_sel: Option, + /// Textura cargada para deformar (no se serializa; se recarga del path). + texture: Option, + /// Campo del path de la textura. + tex_input: TextInputState, + tex_focused: bool, + /// Tamaño en px del lienzo del rig en el último press (para invertir + /// pantalla→modelo al arrastrar el objetivo IK). + rig_canvas_wh: (f32, f32), + + /// Instancia ejecutable (recompilada del `doc` en cada edición estructural). + instance: Instance, + /// Índice del estado actualmente activo (para iluminar el nodo en el grafo). + current_idx: Option, + /// Valores en vivo de los inputs (siembran los controles y se aplican cada tick). + live_bools: HashMap, + live_numbers: HashMap, + /// ¿Corre la simulación del preview? + playing: bool, + + /// Campo de nombre del estado seleccionado. + name_input: TextInputState, + name_focused: bool, + /// Campo de nombre para crear un input nuevo. + new_input: TextInputState, + new_input_focused: bool, + /// Portapapeles del sistema para copiar/cortar/pegar en los campos. + clipboard: SystemClipboard, + + status: String, +} + +impl Model { + /// Recompila el `doc` a una `Instance` fresca, reaplicando los inputs en vivo. + fn rebuild(&mut self) { + self.instance = self.doc.compile().instance(); + self.seed_live_inputs(); + self.apply_live_inputs(); + self.current_idx = self.find_current(); + } + + /// Asegura que cada input declarado tenga una entrada en los mapas en vivo + /// (con su default), y descarta los que ya no existen. + fn seed_live_inputs(&mut self) { + let mut bools = HashMap::new(); + let mut numbers = HashMap::new(); + for i in &self.doc.inputs { + match i.kind { + InputKind::Bool => { + let v = self.live_bools.get(&i.name).copied().unwrap_or(i.bool_default); + bools.insert(i.name.clone(), v); + } + InputKind::Number => { + let v = self.live_numbers.get(&i.name).copied().unwrap_or(i.num_default); + numbers.insert(i.name.clone(), v); + } + InputKind::Trigger => {} + } + } + self.live_bools = bools; + self.live_numbers = numbers; + } + + fn apply_live_inputs(&mut self) { + for (k, v) in &self.live_bools { + self.instance.set_bool(k.clone(), *v); + } + for (k, v) in &self.live_numbers { + self.instance.set_number(k.clone(), *v); + } + } + + fn find_current(&self) -> Option { + let name = self.instance.current_state(); + self.doc.states.iter().position(|s| s.name == name) + } + + fn selected_state(&self) -> Option { + match self.sel { + Sel::State(i) if i < self.doc.states.len() => Some(i), + _ => None, + } + } + fn selected_trans(&self) -> Option { + match self.sel { + Sel::Trans(i) if i < self.doc.transitions.len() => Some(i), + _ => None, + } + } + + /// Sincroniza el campo de nombre con el estado seleccionado. + fn sync_name_input(&mut self) { + if let Some(i) = self.selected_state() { + self.name_input.set_text(self.doc.states[i].name.clone()); + } + } +} + +// ============================================================================= +// Mensajes +// ============================================================================= + +#[derive(Clone)] +enum Msg { + Tick, + SetMode(Mode), + // --- rig (modo Rig) --- + RigAddBone, + RigDelBone, + RigSelectBone(usize), + RigSetAngle(usize, f64), + RigSetLen(usize, f64), + RigSetThickness(f64), + RigSetCols(f64), + RigSetMeshMode(MeshMode), + RigSetGridRes(f64), + RigSetAspect(f64), + /// Evento de mouse del campo de textura (click/arrastre). El `Press` + /// además lo enfoca. Ver [`text_input_view_full`]. + RigTexCampo(TextInputEvent), + RigTexKey(KeyEvent), + RigLoadTexture, + RigClearTexture, + RigToggleIk, + RigToggleFlip, + RigSetTargetX(f64), + RigSetTargetY(f64), + RigResetPose, + /// Click en el lienzo: coloca el objetivo IK ahí. `(local_x, local_y, w, h)`. + RigCanvasClick(f32, f32, f32, f32), + /// Arrastre en el lienzo: mueve el objetivo IK por delta de pantalla. + RigCanvasDrag(f32, f32), + // --- grafo --- + DragNode(NodeId, DragPhase, f32, f32), + Connect(NodeId, NodeId), + SelectState(usize), + SelectTrans(usize), + // --- CRUD estados --- + AddState, + DeleteSelected, + SetEntry, + /// Evento de mouse del campo de nombre de estado (click/arrastre). El + /// `Press` además lo enfoca. Ver [`text_input_view_full`]. + RenameCampo(TextInputEvent), + RenameKey(KeyEvent), + ToggleLoop, + SetSpeed(f64), + SetClipLen(f64), + // --- transiciones --- + SetTransDur(f64), + ToggleAnyState, + AddCondFor(String), + AddCondClipDone, + DeleteCond(usize), + ToggleCondBool(usize), + CycleCondOp(usize), + SetCondNum(usize, f64), + // --- inputs --- + /// Evento de mouse del campo de nombre de input nuevo (click/arrastre). El + /// `Press` además lo enfoca. Ver [`text_input_view_full`]. + NewInputCampo(TextInputEvent), + NewInputKey(KeyEvent), + AddInput(InputKind), + DeleteInput(usize), + // --- controles en vivo --- + SetLiveBool(String, bool), + SetLiveNumber(String, f64), + FireTrigger(String), + TogglePlay, + Restart, + // --- persistencia --- + Save, + Load, +} + +// ============================================================================= +// App +// ============================================================================= + +struct Studio; + +impl App for Studio { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi-anim-studio — autor de máquinas de animación" + } + fn initial_size() -> (u32, u32) { + (1240, 760) + } + + fn init(handle: &Handle) -> Model { + handle.spawn_periodic(Duration::from_millis(33), || Msg::Tick); + let doc = Doc::starter(); + let mut model = Model { + instance: doc.compile().instance(), + doc, + theme: Theme::dark(), + mode: Mode::Estados, + sel: Sel::None, + rig: RigDoc::starter(), + rig_sel: Some(1), + texture: None, + tex_input: TextInputState::new(), + tex_focused: false, + rig_canvas_wh: (1.0, 1.0), + current_idx: Some(0), + live_bools: HashMap::new(), + live_numbers: HashMap::new(), + playing: true, + name_input: TextInputState::new(), + name_focused: false, + new_input: TextInputState::new(), + new_input_focused: false, + clipboard: SystemClipboard::new(), + status: "listo — arrastra pin→pin para conectar; toca los inputs en vivo".into(), + }; + model.rebuild(); + model + } + + fn on_key(model: &Model, ev: &KeyEvent) -> Option { + if model.name_focused { + return Some(Msg::RenameKey(ev.clone())); + } + if model.new_input_focused { + return Some(Msg::NewInputKey(ev.clone())); + } + if model.tex_focused { + return Some(Msg::RigTexKey(ev.clone())); + } + None + } + + fn update(mut model: Model, msg: Msg, _handle: &Handle) -> Model { + match msg { + Msg::Tick => { + if model.playing { + model.apply_live_inputs(); + model.instance.advance(DT); + model.current_idx = model.find_current(); + } + } + Msg::SetMode(m) => model.mode = m, + + // ---------------- rig ---------------- + Msg::RigAddBone => { + model.rig.bones.push(BoneDef::new(100.0)); + model.rig_sel = Some(model.rig.bones.len() - 1); + } + Msg::RigDelBone => { + if let Some(i) = model.rig_sel { + if i < model.rig.bones.len() && model.rig.bones.len() > 1 { + model.rig.bones.remove(i); + model.rig_sel = Some(i.min(model.rig.bones.len() - 1)); + } + } + } + Msg::RigSelectBone(i) => model.rig_sel = Some(i), + Msg::RigSetAngle(i, v) => { + if let Some(b) = model.rig.bones.get_mut(i) { + b.angle = v.clamp(-3.1, 3.1); + } + } + Msg::RigSetLen(i, v) => { + if let Some(b) = model.rig.bones.get_mut(i) { + b.len = v.clamp(10.0, 400.0); + } + } + Msg::RigSetThickness(v) => model.rig.thickness = v.clamp(2.0, 120.0), + Msg::RigSetCols(v) => model.rig.cols = (v as usize).clamp(2, 64), + Msg::RigSetMeshMode(m) => model.rig.mesh_mode = m, + Msg::RigSetGridRes(v) => model.rig.grid_res = (v as usize).clamp(2, 40), + Msg::RigSetAspect(v) => model.rig.mesh_aspect = (v as f64).clamp(0.1, 3.0), + Msg::RigTexCampo(ev) => { + // El press/click enfoca este campo (y desenfoca los demás); el + // arrastre extiende la selección. + if matches!(ev, TextInputEvent::Press(_)) { + model.tex_focused = true; + model.name_focused = false; + model.new_input_focused = false; + } + model.tex_input.handle(ev, &mut model.clipboard); + } + Msg::RigTexKey(ev) => { + // `handle` cubre escribir, copiar/cortar/pegar y acentos. + model.tex_input.handle(TextInputEvent::Key(ev), &mut model.clipboard); + } + Msg::RigLoadTexture => { + let path = model.tex_input.text(); + let path = path.trim().to_string(); + if path.is_empty() { + model.status = "escribe el path de una imagen".into(); + } else { + match load_texture(&path) { + Ok((img, aspect)) => { + model.rig.mesh_aspect = aspect; + model.rig.mesh_mode = MeshMode::Grid; // textura ⇒ rejilla + model.rig.texture_path = Some(path.clone()); + model.texture = Some(img); + model.status = format!("textura cargada: {path}"); + } + Err(e) => model.status = format!("no se pudo cargar: {e}"), + } + } + } + Msg::RigClearTexture => { + model.texture = None; + model.rig.texture_path = None; + model.status = "textura quitada".into(); + } + Msg::RigToggleIk => model.rig.ik_enabled = !model.rig.ik_enabled, + Msg::RigToggleFlip => model.rig.ik_flip = !model.rig.ik_flip, + Msg::RigSetTargetX(v) => model.rig.ik_target.0 = v, + Msg::RigSetTargetY(v) => model.rig.ik_target.1 = v, + Msg::RigResetPose => { + for b in &mut model.rig.bones { + b.angle = 0.0; + } + } + Msg::RigCanvasClick(lx, ly, rw, rh) => { + if model.rig.bones.len() >= 2 { + model.rig_canvas_wh = (rw, rh); + model.rig.ik_enabled = true; // colocar objetivo ⇒ quieres IK + let b = rig_view_bounds(&model.rig); + let (mx, my) = canvas_local_to_model(lx, ly, rw, rh, b); + model.rig.ik_target = (mx, my); + } + } + Msg::RigCanvasDrag(dx, dy) => { + if model.rig.ik_enabled && model.rig.bones.len() >= 2 { + let (rw, rh) = model.rig_canvas_wh; + let b = rig_view_bounds(&model.rig); + let s = canvas_scale(rw, rh, b); + if s > 0.0 { + model.rig.ik_target.0 += dx as f64 / s; + model.rig.ik_target.1 += dy as f64 / s; + } + } + } + + // ---------------- grafo ---------------- + Msg::DragNode(id, phase, dx, dy) => { + if matches!(phase, DragPhase::Move | DragPhase::End) { + if let Some(s) = model.doc.states.get_mut(id as usize) { + s.x = (s.x + dx).max(0.0); + s.y = (s.y + dy).max(0.0); + } + } + } + Msg::Connect(from, to) => { + let (from, to) = (from as usize, to as usize); + if from != to && from < model.doc.states.len() && to < model.doc.states.len() { + model.doc.transitions.push(TransDef { + from: Some(from), + to, + conditions: Vec::new(), + duration_secs: 0.2, + }); + let idx = model.doc.transitions.len() - 1; + model.sel = Sel::Trans(idx); + model.status = + "transición creada — agregale una condición o nunca dispara".into(); + model.rebuild(); + } + } + Msg::SelectState(i) => { + model.sel = Sel::State(i); + model.name_focused = false; + model.sync_name_input(); + } + Msg::SelectTrans(i) => { + model.sel = Sel::Trans(i); + model.name_focused = false; + } + + // ---------------- CRUD estados ---------------- + Msg::AddState => { + let n = model.doc.states.len(); + let x = 80.0 + (n as f32 % 4.0) * 180.0; + let y = 80.0 + (n as f32 / 4.0).floor() * 150.0; + model + .doc + .states + .push(StateDef::new(format!("estado{n}"), x, y)); + model.sel = Sel::State(n); + model.sync_name_input(); + model.rebuild(); + } + Msg::DeleteSelected => match model.sel { + Sel::State(i) => { + remove_state(&mut model.doc, i); + model.sel = Sel::None; + model.rebuild(); + } + Sel::Trans(i) => { + if i < model.doc.transitions.len() { + model.doc.transitions.remove(i); + } + model.sel = Sel::None; + model.rebuild(); + } + Sel::None => {} + }, + Msg::SetEntry => { + if let Some(i) = model.selected_state() { + model.doc.entry = i; + model.rebuild(); + model.status = format!("entry = {}", model.doc.states[i].name); + } + } + Msg::RenameCampo(ev) => { + if matches!(ev, TextInputEvent::Press(_)) { + model.name_focused = true; + model.new_input_focused = false; + } + model.name_input.handle(ev, &mut model.clipboard); + } + Msg::RenameKey(ev) => { + if model.name_input.handle(TextInputEvent::Key(ev), &mut model.clipboard) { + if let Some(i) = model.selected_state() { + let new = model.name_input.text(); + // Renombrar el estado y propagar a sus referencias no hace + // falta: las transiciones referencian por índice, no nombre. + model.doc.states[i].name = new; + model.rebuild(); + } + } + } + Msg::ToggleLoop => { + if let Some(i) = model.selected_state() { + model.doc.states[i].looping = !model.doc.states[i].looping; + model.rebuild(); + } + } + Msg::SetSpeed(v) => { + if let Some(i) = model.selected_state() { + model.doc.states[i].speed = v.clamp(0.0, 4.0); + model.rebuild(); + } + } + Msg::SetClipLen(v) => { + if let Some(i) = model.selected_state() { + model.doc.states[i].clip_len = v.clamp(0.1, 10.0); + model.rebuild(); + } + } + + // ---------------- transiciones ---------------- + Msg::SetTransDur(v) => { + if let Some(i) = model.selected_trans() { + model.doc.transitions[i].duration_secs = v.clamp(0.0, 2.0); + model.rebuild(); + } + } + Msg::ToggleAnyState => { + if let Some(i) = model.selected_trans() { + let t = &mut model.doc.transitions[i]; + t.from = match t.from { + Some(_) => None, + None => Some(model.doc.entry), + }; + model.rebuild(); + } + } + Msg::AddCondFor(name) => { + if let Some(i) = model.selected_trans() { + let kind = model + .doc + .inputs + .iter() + .find(|x| x.name == name) + .map(|x| x.kind); + let cond = match kind { + Some(InputKind::Bool) => CondDef::Bool { input: name, value: true }, + Some(InputKind::Number) => CondDef::Number { + input: name, + op: CmpOp::Gt, + value: 0.0, + }, + Some(InputKind::Trigger) => CondDef::Trigger { input: name }, + None => return model, + }; + model.doc.transitions[i].conditions.push(cond); + model.rebuild(); + } + } + Msg::AddCondClipDone => { + if let Some(i) = model.selected_trans() { + model.doc.transitions[i].conditions.push(CondDef::ClipDone); + model.rebuild(); + } + } + Msg::DeleteCond(ci) => { + if let Some(i) = model.selected_trans() { + if ci < model.doc.transitions[i].conditions.len() { + model.doc.transitions[i].conditions.remove(ci); + model.rebuild(); + } + } + } + Msg::ToggleCondBool(ci) => { + if let Some(i) = model.selected_trans() { + if let Some(CondDef::Bool { value, .. }) = + model.doc.transitions[i].conditions.get_mut(ci) + { + *value = !*value; + model.rebuild(); + } + } + } + Msg::CycleCondOp(ci) => { + if let Some(i) = model.selected_trans() { + if let Some(CondDef::Number { op, .. }) = + model.doc.transitions[i].conditions.get_mut(ci) + { + let cur = CmpOp::ALL.iter().position(|o| o == op).unwrap_or(0); + *op = CmpOp::ALL[(cur + 1) % CmpOp::ALL.len()]; + model.rebuild(); + } + } + } + Msg::SetCondNum(ci, v) => { + if let Some(i) = model.selected_trans() { + if let Some(CondDef::Number { value, .. }) = + model.doc.transitions[i].conditions.get_mut(ci) + { + *value = v; + model.rebuild(); + } + } + } + + // ---------------- inputs ---------------- + Msg::NewInputCampo(ev) => { + if matches!(ev, TextInputEvent::Press(_)) { + model.new_input_focused = true; + model.name_focused = false; + } + model.new_input.handle(ev, &mut model.clipboard); + } + Msg::NewInputKey(ev) => { + model.new_input.handle(TextInputEvent::Key(ev), &mut model.clipboard); + } + Msg::AddInput(kind) => { + let name = model.new_input.text(); + let name = name.trim(); + if !name.is_empty() && !model.doc.inputs.iter().any(|i| i.name == name) { + model.doc.inputs.push(InputDef::new(name, kind)); + model.new_input.clear(); + model.rebuild(); + } else { + model.status = "nombre de input vacío o repetido".into(); + } + } + Msg::DeleteInput(i) => { + if i < model.doc.inputs.len() { + model.doc.inputs.remove(i); + model.rebuild(); + } + } + + // ---------------- controles en vivo ---------------- + Msg::SetLiveBool(name, v) => { + model.live_bools.insert(name, v); + model.apply_live_inputs(); + } + Msg::SetLiveNumber(name, v) => { + model.live_numbers.insert(name, v); + model.apply_live_inputs(); + } + Msg::FireTrigger(name) => { + model.instance.fire(name); + } + Msg::TogglePlay => model.playing = !model.playing, + Msg::Restart => { + model.rebuild(); + model.status = "reiniciado al estado de entrada".into(); + } + + // ---------------- persistencia ---------------- + Msg::Save => { + let project = Project { + doc: model.doc.clone(), + rig: model.rig.clone(), + }; + let ron = ron::ser::to_string_pretty(&project, ron::ser::PrettyConfig::default()); + model.status = match ron { + Ok(s) => match std::fs::write(PROJECT_PATH, s) { + Ok(_) => format!("guardado en {PROJECT_PATH}"), + Err(e) => format!("error al escribir: {e}"), + }, + Err(e) => format!("error al serializar: {e}"), + }; + } + Msg::Load => match std::fs::read_to_string(PROJECT_PATH) { + Ok(s) => match ron::from_str::(&s) { + Ok(p) => { + model.doc = p.doc; + model.rig = p.rig; + model.sel = Sel::None; + model.rig_sel = model.rig.bones.len().checked_sub(1); + // Recargar la textura referenciada por path, si la hay. + model.texture = model + .rig + .texture_path + .as_ref() + .and_then(|p| load_texture(p).ok().map(|(img, _)| img)); + if let Some(p) = &model.rig.texture_path { + model.tex_input.set_text(p.clone()); + } + model.rebuild(); + model.status = format!("cargado de {PROJECT_PATH}"); + } + Err(e) => model.status = format!("RON inválido: {e}"), + }, + Err(e) => model.status = format!("no se pudo leer: {e}"), + }, + } + model + } + + fn view(model: &Model) -> View { + let body = match model.mode { + Mode::Estados => View::new(row_full()) + .children(vec![left_panel(model), graph_panel(model), right_panel(model)]), + Mode::Rig => View::new(row_full()).children(vec![ + rig_left_panel(model), + rig_canvas_panel(model), + rig_right_panel(model), + ]), + }; + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(model.theme.bg_app) + .children(vec![top_bar(model), body]) + } +} + +/// Estilo de una fila que ocupa todo el ancho y el alto restante. +fn row_full() -> Style { + Style { + flex_direction: FlexDirection::Row, + flex_grow: 1.0, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + ..Default::default() + } +} + +/// Barra superior: conmutador de modo Estados / Rig. +fn top_bar(model: &Model) -> View { + let theme = &model.theme; + let tab = |label: &str, active: bool, msg: Msg| -> View { + let (bg, fg) = if active { + (theme.accent, Color::from_rgba8(20, 20, 24, 255)) + } else { + (theme.bg_panel_alt, theme.fg_muted) + }; + View::new(Style { + size: Size { + width: length(120.0_f32), + height: length(28.0_f32), + }, + align_items: Some(AlignItems::Center), + padding: pad(12.0, 0.0), + ..Default::default() + }) + .fill(bg) + .radius(5.0) + .text(label.to_string(), 13.0, fg) + .on_click(msg) + }; + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(44.0_f32), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + padding: pad(12.0, 0.0), + gap: gap(8.0), + ..Default::default() + }) + .fill(theme.bg_panel) + .children(vec![ + tab("◆ Estados", model.mode == Mode::Estados, Msg::SetMode(Mode::Estados)), + tab("⦿ Rig", model.mode == Mode::Rig, Msg::SetMode(Mode::Rig)), + ]) +} + +// ============================================================================= +// Panel izquierdo — listas + CRUD +// ============================================================================= + +fn left_panel(model: &Model) -> View { + let theme = &model.theme; + let btn = ButtonPalette::from_theme(theme); + let mut rows: Vec> = Vec::new(); + + rows.push(section_title("ESTADOS", theme)); + for (i, s) in model.doc.states.iter().enumerate() { + let is_sel = model.sel == Sel::State(i); + let is_entry = model.doc.entry == i; + let label = if is_entry { + format!("▶ {}", s.name) + } else { + s.name.clone() + }; + rows.push(selectable_row(&label, is_sel, Msg::SelectState(i), theme)); + } + rows.push(spacer(6.0)); + rows.push(button_view("+ estado", &btn, Msg::AddState)); + + rows.push(spacer(14.0)); + rows.push(section_title("TRANSICIONES", theme)); + for (i, t) in model.doc.transitions.iter().enumerate() { + let is_sel = model.sel == Sel::Trans(i); + let from = match t.from { + Some(f) => model.doc.states.get(f).map(|s| s.name.as_str()).unwrap_or("?"), + None => "∗", + }; + let to = model + .doc + .states + .get(t.to) + .map(|s| s.name.as_str()) + .unwrap_or("?"); + let mark = if t.conditions.is_empty() { " ⚠" } else { "" }; + let label = format!("{from} → {to}{mark}"); + rows.push(selectable_row(&label, is_sel, Msg::SelectTrans(i), theme)); + } + + rows.push(spacer(14.0)); + rows.push(section_title("INPUTS", theme)); + for (i, inp) in model.doc.inputs.iter().enumerate() { + rows.push(input_row(i, inp, theme)); + } + rows.push(spacer(6.0)); + rows.push(text_input_view_full( + &model.new_input, + "nombre del input…", + model.new_input_focused, + &TextInputPalette::from_theme(theme), + Msg::NewInputCampo, + )); + rows.push(spacer(4.0)); + rows.push( + row(vec![ + button_view("+bool", &btn, Msg::AddInput(InputKind::Bool)), + button_view("+núm", &btn, Msg::AddInput(InputKind::Number)), + button_view("+trig", &btn, Msg::AddInput(InputKind::Trigger)), + ]), + ); + + panel_column(rows, 250.0, theme.bg_panel) +} + +fn input_row(i: usize, inp: &InputDef, theme: &Theme) -> View { + let btn = ButtonPalette::from_theme(theme); + row(vec![ + View::new(Style { + flex_grow: 1.0, + size: Size { + width: Dimension::auto(), + height: length(24.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text(format!("{} · {}", inp.name, inp.kind.label()), 12.0, theme.fg_text), + View::new(Style { + size: Size { + width: length(30.0_f32), + height: length(24.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![button_view("✕", &btn, Msg::DeleteInput(i))]), + ]) +} + +// ============================================================================= +// Panel central — el lienzo de nodos +// ============================================================================= + +fn graph_panel(model: &Model) -> View { + let theme = &model.theme; + let palette = NodegraphPalette::from_theme(theme); + let metrics = NodegraphMetrics::default(); + + let nodes: Vec = model + .doc + .states + .iter() + .enumerate() + .map(|(i, s)| NodeSpec { + id: i as NodeId, + label: s.name.clone(), + x: s.x, + y: s.y, + inputs: vec!["in".into()], + outputs: vec!["out".into()], + }) + .collect(); + + // Sólo las transiciones con origen concreto se dibujan como cable; las + // any-state no tienen nodo de origen (viven sólo en la lista). + let wires: Vec = model + .doc + .transitions + .iter() + .filter_map(|t| { + t.from.map(|f| Wire { + from_node: f as NodeId, + from_output: 0, + to_node: t.to as NodeId, + to_input: 0, + }) + }) + .collect(); + + let current = model.current_idx; + let selected = model.selected_state(); + let accent = theme.accent; + let sel_bg = theme.bg_selected; + + let tint = move |id: NodeId| -> Option { + let i = id as usize; + if current == Some(i) { + // Estado activo: título encendido en accent (se ve "prendido" en vivo). + Some(NodeTint { + bg_title: Some(accent), + fg_title: Some(Color::from_rgba8(20, 20, 24, 255)), + ..Default::default() + }) + } else if selected == Some(i) { + Some(NodeTint { + bg_node: Some(sel_bg), + ..Default::default() + }) + } else { + None + } + }; + + let graph = nodegraph_view_styled( + &nodes, + &wires, + &palette, + &metrics, + |id, phase, dx, dy| Some(Msg::DragNode(id, phase, dx, dy)), + |from_node, _from_out, to_node, _to_in| Some(Msg::Connect(from_node, to_node)), + Some(|id: NodeId| Some(Msg::SelectState(id as usize))), + Some(&tint as &dyn Fn(NodeId) -> Option), + None, + ); + + View::new(Style { + flex_grow: 1.0, + flex_direction: FlexDirection::Column, + size: Size { + width: Dimension::auto(), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![graph]) +} + +// ============================================================================= +// Panel derecho — preview en vivo + inspector +// ============================================================================= + +fn right_panel(model: &Model) -> View { + let theme = &model.theme; + let btn = ButtonPalette::from_theme(theme); + let mut rows: Vec> = Vec::new(); + + // --- Preview --- + rows.push(section_title("PREVIEW", theme)); + rows.push(preview_canvas(model)); + let cur = model + .current_idx + .and_then(|i| model.doc.states.get(i)) + .map(|s| s.name.as_str()) + .unwrap_or("—"); + let trans = if model.instance.is_transitioning() { + " (mezclando…)" + } else { + "" + }; + rows.push( + View::new(auto_h(22.0)).text(format!("estado: {cur}{trans}"), 13.0, theme.accent), + ); + rows.push(spacer(4.0)); + rows.push(row(vec![ + button_view(if model.playing { "⏸ pausa" } else { "▶ play" }, &btn, Msg::TogglePlay), + button_view("⟲ reiniciar", &btn, Msg::Restart), + ])); + + // --- Controles en vivo --- + rows.push(spacer(12.0)); + rows.push(section_title("INPUTS EN VIVO", theme)); + if model.doc.inputs.is_empty() { + rows.push(muted("declará inputs en el panel izquierdo", theme)); + } + let sp = SliderPalette::from_theme(theme); + for inp in &model.doc.inputs { + match inp.kind { + InputKind::Bool => { + let on = model.live_bools.get(&inp.name).copied().unwrap_or(false); + let name = inp.name.clone(); + let lbl = format!("{}: {}", inp.name, if on { "true" } else { "false" }); + rows.push(button_view(lbl, &btn, Msg::SetLiveBool(name, !on))); + } + InputKind::Number => { + let v = model.live_numbers.get(&inp.name).copied().unwrap_or(0.0) as f32; + let name = inp.name.clone(); + rows.push(slider_view( + inp.name.clone(), + v, + 0.0, + 10.0, + &sp, + move |_p, nv| Some(Msg::SetLiveNumber(name.clone(), nv as f64)), + )); + } + InputKind::Trigger => { + let name = inp.name.clone(); + rows.push(button_view(format!("⚡ {}", inp.name), &btn, Msg::FireTrigger(name))); + } + } + rows.push(spacer(4.0)); + } + + // --- Inspector --- + rows.push(spacer(10.0)); + rows.extend(inspector(model)); + + // --- Persistencia + estado --- + rows.push(spacer(12.0)); + rows.push(row(vec![ + button_view("guardar", &btn, Msg::Save), + button_view("cargar", &btn, Msg::Load), + ])); + rows.push(spacer(8.0)); + rows.push(View::new(auto_h(0.0)).text(model.status.clone(), 11.0, theme.fg_placeholder).max_lines(3)); + + panel_column(rows, 320.0, theme.bg_panel) +} + +/// El inspector del elemento seleccionado (estado o transición). +fn inspector(model: &Model) -> Vec> { + let theme = &model.theme; + let btn = ButtonPalette::from_theme(theme); + let sp = SliderPalette::from_theme(theme); + let mut rows: Vec> = Vec::new(); + + match model.sel { + Sel::State(i) if i < model.doc.states.len() => { + let s = &model.doc.states[i]; + rows.push(section_title("ESTADO", theme)); + rows.push(text_input_view_full( + &model.name_input, + "nombre…", + model.name_focused, + &TextInputPalette::from_theme(theme), + Msg::RenameCampo, + )); + rows.push(spacer(6.0)); + rows.push(button_view( + if s.looping { "loop: sí" } else { "loop: no" }, + &btn, + Msg::ToggleLoop, + )); + rows.push(spacer(4.0)); + let speed = s.speed as f32; + rows.push(slider_view( + format!("velocidad {:.2}", s.speed), + speed, + 0.0, + 4.0, + &sp, + move |_p, nv| Some(Msg::SetSpeed(nv as f64)), + )); + if !s.looping { + let len = s.clip_len as f32; + rows.push(slider_view( + format!("duración {:.2}s", s.clip_len), + len, + 0.1, + 10.0, + &sp, + move |_p, nv| Some(Msg::SetClipLen(nv as f64)), + )); + } + rows.push(spacer(6.0)); + rows.push(row(vec![ + button_view("entry", &btn, Msg::SetEntry), + button_view("borrar", &btn, Msg::DeleteSelected), + ])); + } + Sel::Trans(i) if i < model.doc.transitions.len() => { + let t = &model.doc.transitions[i]; + rows.push(section_title("TRANSICIÓN", theme)); + let from = match t.from { + Some(f) => model.doc.states.get(f).map(|s| s.name.clone()).unwrap_or_default(), + None => "∗ (any-state)".into(), + }; + let to = model.doc.states.get(t.to).map(|s| s.name.clone()).unwrap_or_default(); + rows.push(muted(&format!("{from} → {to}"), theme)); + rows.push(spacer(4.0)); + let dur = t.duration_secs as f32; + rows.push(slider_view( + format!("blend {:.2}s", t.duration_secs), + dur, + 0.0, + 2.0, + &sp, + move |_p, nv| Some(Msg::SetTransDur(nv as f64)), + )); + rows.push(spacer(4.0)); + rows.push(button_view( + if t.from.is_some() { "→ volver any-state" } else { "← darle origen (entry)" }, + &btn, + Msg::ToggleAnyState, + )); + + // Condiciones (AND). + rows.push(spacer(8.0)); + rows.push(muted("CONDICIONES (AND)", theme)); + if t.conditions.is_empty() { + rows.push(muted("⚠ sin condición → nunca dispara", theme)); + } + for (ci, c) in t.conditions.iter().enumerate() { + rows.push(cond_row(ci, c, theme)); + } + // Agregar condición: un botón por input + clip-terminó. + rows.push(spacer(6.0)); + rows.push(muted("agregar:", theme)); + let mut add_btns: Vec> = Vec::new(); + for inp in &model.doc.inputs { + let name = inp.name.clone(); + add_btns.push(button_view(format!("+{}", inp.name), &btn, Msg::AddCondFor(name))); + } + if !add_btns.is_empty() { + rows.push(wrap_row(add_btns)); + } + rows.push(spacer(4.0)); + rows.push(button_view("+ clip terminó", &btn, Msg::AddCondClipDone)); + rows.push(spacer(6.0)); + rows.push(button_view("borrar transición", &btn, Msg::DeleteSelected)); + } + _ => { + rows.push(muted("selecciona un estado o transición", theme)); + } + } + rows +} + +/// Una fila de condición editable. +fn cond_row(ci: usize, c: &CondDef, theme: &Theme) -> View { + let btn = ButtonPalette::from_theme(theme); + let sp = SliderPalette::from_theme(theme); + let mut items: Vec> = Vec::new(); + match c { + CondDef::Bool { input, value } => { + items.push(grow_text(format!("{input} =="), theme)); + items.push(fixed_btn( + if *value { "true" } else { "false" }, + Msg::ToggleCondBool(ci), + &btn, + 64.0, + )); + } + CondDef::Number { input, op, value } => { + items.push(grow_text(format!("{input} {} {value:.1}", op.symbol()), theme)); + items.push(fixed_btn(op.symbol(), Msg::CycleCondOp(ci), &btn, 40.0)); + } + CondDef::Trigger { input } => { + items.push(grow_text(format!("⚡ {input}"), theme)); + } + CondDef::ClipDone => { + items.push(grow_text("clip terminó".to_string(), theme)); + } + } + items.push(fixed_btn("✕", Msg::DeleteCond(ci), &btn, 30.0)); + + let mut col = vec![row(items)]; + // Slider para el valor numérico (debajo). + if let CondDef::Number { value, .. } = c { + let v = *value as f32; + col.push(slider_view( + "valor", + v, + 0.0, + 10.0, + &sp, + move |_p, nv| Some(Msg::SetCondNum(ci, nv as f64)), + )); + } + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + ..Default::default() + }) + .children(col) +} + +// ============================================================================= +// Preview sintético +// ============================================================================= + +/// Lienzo que pinta lo que emite la `Instance`: un disco por clip cuyo color = +/// el color del estado y cuyo movimiento (bob + orbe) avanza con `time_secs`. +/// Durante una transición pinta el clip entrante encima con alpha = mezcla — el +/// crossfade del runtime se ve literalmente. +fn preview_canvas(model: &Model) -> View { + let rf: RenderFrame = model.instance.render_frame(); + let n = model.doc.states.len().max(1); + let colors: Vec = (0..n).map(state_color).collect(); + let bg = self_color(model.theme.bg_app, model.theme.bg_panel_alt); + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(220.0_f32), + }, + ..Default::default() + }) + .fill(bg) + .radius(8.0) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle, Point}; + use llimphi_ui::llimphi_raster::peniko::Fill; + + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.5) as f64; + let unit = (rect.w.min(rect.h)) as f64; + let r = unit * 0.20; + + let draw = |scene: &mut vello::Scene, sample_clip: usize, time: f64, alpha: f32| { + let color = colors + .get(sample_clip) + .copied() + .unwrap_or(Color::from_rgba8(180, 180, 190, 255)) + .multiply_alpha(alpha); + // Bob vertical con el tiempo del clip. + let bob = (time * 2.4).sin() * unit * 0.16; + let center = Point::new(cx, cy + bob); + scene.fill(Fill::NonZero, Affine::IDENTITY, &color, None, &Circle::new(center, r)); + // Orbe que marca el avance del tiempo (gira con time). + let a = time * 2.0; + let orb = Point::new(cx + a.cos() * r * 1.6, cy + bob + a.sin() * r * 1.6); + let orb_c = Color::from_rgba8(255, 255, 255, 230).multiply_alpha(alpha); + scene.fill(Fill::NonZero, Affine::IDENTITY, &orb_c, None, &Circle::new(orb, unit * 0.03)); + }; + + // Primario a alpha pleno; entrante encima con su mezcla. + draw(scene, rf.primary.clip as usize, rf.primary.time_secs, 1.0); + if let Some((incoming, mix)) = rf.blend { + draw(scene, incoming.clip as usize, incoming.time_secs, mix); + } + }) +} + +/// Color estable por índice de estado (paleta de tintes distinguibles). +fn state_color(i: usize) -> Color { + const PAL: [(u8, u8, u8); 8] = [ + (94, 168, 255), // azul + (120, 210, 140), // verde + (255, 178, 92), // naranja + (220, 120, 220), // magenta + (240, 220, 110), // amarillo + (120, 210, 220), // cyan + (240, 130, 130), // rojo + (170, 150, 240), // violeta + ]; + let (r, g, b) = PAL[i % PAL.len()]; + Color::from_rgba8(r, g, b, 255) +} + +// ============================================================================= +// Modo Rig — paneles +// ============================================================================= + +fn rig_left_panel(model: &Model) -> View { + let theme = &model.theme; + let btn = ButtonPalette::from_theme(theme); + let mut rows: Vec> = Vec::new(); + rows.push(section_title("HUESOS (cadena)", theme)); + for (i, b) in model.rig.bones.iter().enumerate() { + let sel = model.rig_sel == Some(i); + let label = format!("hueso {i} · {:.0}", b.len); + rows.push(selectable_row(&label, sel, Msg::RigSelectBone(i), theme)); + } + rows.push(spacer(8.0)); + rows.push(row(vec![ + button_view("+ hueso", &btn, Msg::RigAddBone), + button_view("− hueso", &btn, Msg::RigDelBone), + ])); + rows.push(spacer(10.0)); + rows.push(muted( + "la cadena sale del origen hacia +x; cada hueso lleva su hijo y arrastra la malla por skinning (LBS).", + theme, + )); + panel_column(rows, 230.0, theme.bg_panel) +} + +fn rig_right_panel(model: &Model) -> View { + let theme = &model.theme; + let btn = ButtonPalette::from_theme(theme); + let sp = SliderPalette::from_theme(theme); + let mut rows: Vec> = Vec::new(); + + // --- Hueso seleccionado --- + rows.push(section_title("HUESO", theme)); + if let Some(i) = model.rig_sel.filter(|i| *i < model.rig.bones.len()) { + let b = &model.rig.bones[i]; + let ang = b.angle as f32; + rows.push(slider_view( + format!("ángulo {:.2} rad", b.angle), + ang, + -3.1, + 3.1, + &sp, + move |_p, nv| Some(Msg::RigSetAngle(i, nv as f64)), + )); + let len = b.len as f32; + rows.push(slider_view( + format!("largo {:.0}", b.len), + len, + 10.0, + 400.0, + &sp, + move |_p, nv| Some(Msg::RigSetLen(i, nv as f64)), + )); + } else { + rows.push(muted("selecciona un hueso", theme)); + } + rows.push(spacer(6.0)); + rows.push(button_view("⟲ pose neutra", &btn, Msg::RigResetPose)); + + // --- Malla --- + rows.push(spacer(12.0)); + rows.push(section_title("MALLA", theme)); + let is_grid = matches!(model.rig.mesh_mode, MeshMode::Grid); + rows.push(row(vec![ + toggle_btn("tubo", !is_grid, Msg::RigSetMeshMode(MeshMode::Tube), theme), + toggle_btn("rejilla", is_grid, Msg::RigSetMeshMode(MeshMode::Grid), theme), + ])); + rows.push(spacer(4.0)); + if is_grid { + let gr = model.rig.grid_res as f32; + rows.push(slider_view( + format!("resolución {}", model.rig.grid_res), + gr, + 2.0, + 40.0, + &sp, + move |_p, nv| Some(Msg::RigSetGridRes(nv as f64)), + )); + let asp = model.rig.mesh_aspect as f32; + rows.push(slider_view( + format!("aspecto {:.2}", model.rig.mesh_aspect), + asp, + 0.1, + 3.0, + &sp, + move |_p, nv| Some(Msg::RigSetAspect(nv as f64)), + )); + } else { + let th = model.rig.thickness as f32; + rows.push(slider_view( + format!("grosor {:.0}", model.rig.thickness), + th, + 2.0, + 120.0, + &sp, + move |_p, nv| Some(Msg::RigSetThickness(nv as f64)), + )); + let cols = model.rig.cols as f32; + rows.push(slider_view( + format!("columnas {}", model.rig.cols), + cols, + 2.0, + 64.0, + &sp, + move |_p, nv| Some(Msg::RigSetCols(nv as f64)), + )); + } + + // --- Textura (deformar una imagen real) --- + rows.push(spacer(12.0)); + rows.push(section_title("TEXTURA", theme)); + rows.push(text_input_view_full( + &model.tex_input, + "/path/a/imagen.png…", + model.tex_focused, + &TextInputPalette::from_theme(theme), + Msg::RigTexCampo, + )); + rows.push(spacer(4.0)); + rows.push(row(vec![ + button_view("cargar textura", &btn, Msg::RigLoadTexture), + button_view("quitar", &btn, Msg::RigClearTexture), + ])); + if model.texture.is_some() { + rows.push(muted("textura activa → modo rejilla la deforma", theme)); + } else { + rows.push(muted("carga un PNG/JPG: se rige a la cadena y se dobla", theme)); + } + + // --- IK --- + rows.push(spacer(12.0)); + rows.push(section_title("IK (2 huesos)", theme)); + if model.rig.bones.len() < 2 { + rows.push(muted("necesitas ≥2 huesos para el IK", theme)); + } else { + rows.push(muted("clic/arrastra en el lienzo para mover el objetivo", theme)); + rows.push(row(vec![ + button_view( + if model.rig.ik_enabled { "IK: on" } else { "IK: off" }, + &btn, + Msg::RigToggleIk, + ), + button_view( + if model.rig.ik_flip { "codo ↑" } else { "codo ↓" }, + &btn, + Msg::RigToggleFlip, + ), + ])); + if model.rig.ik_enabled { + let reach = (model.rig.total_len() + 120.0) as f32; + let tx = model.rig.ik_target.0 as f32; + rows.push(slider_view( + format!("objetivo x {:.0}", model.rig.ik_target.0), + tx, + -reach, + reach, + &sp, + move |_p, nv| Some(Msg::RigSetTargetX(nv as f64)), + )); + let ty = model.rig.ik_target.1 as f32; + rows.push(slider_view( + format!("objetivo y {:.0}", model.rig.ik_target.1), + ty, + -reach, + reach, + &sp, + move |_p, nv| Some(Msg::RigSetTargetY(nv as f64)), + )); + } + } + + // --- Persistencia --- + rows.push(spacer(14.0)); + rows.push(row(vec![ + button_view("guardar", &btn, Msg::Save), + button_view("cargar", &btn, Msg::Load), + ])); + rows.push(spacer(8.0)); + rows.push( + View::new(auto_h(0.0)) + .text(model.status.clone(), 11.0, theme.fg_placeholder) + .max_lines(3), + ); + + panel_column(rows, 300.0, theme.bg_panel) +} + +/// Panel central del modo Rig: el lienzo con la malla deformada en vivo. +fn rig_canvas_panel(model: &Model) -> View { + let theme = &model.theme; + View::new(Style { + flex_grow: 1.0, + flex_direction: FlexDirection::Column, + size: Size { + width: Dimension::auto(), + height: percent(1.0_f32), + }, + padding: pad(14.0, 14.0), + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![rig_canvas(model)]) +} + +/// Bbox de modelo que encuadra el rig (silueta + objetivo IK), inflado para +/// que las poses dobladas no se salgan. Lo comparten el render del lienzo y la +/// inversión pantalla→modelo del drag del objetivo IK. +fn rig_view_bounds(rig: &RigDoc) -> KRect { + let total = rig.total_len(); + let half = match rig.mesh_mode { + MeshMode::Tube => rig.thickness, + MeshMode::Grid => (total * rig.mesh_aspect * 0.5).max(rig.thickness), + }; + let mut x0: f64 = -20.0; + let mut y0: f64 = -half - 20.0; + let mut x1: f64 = total + 20.0; + let mut y1: f64 = half + 20.0; + if rig.ik_enabled { + x0 = x0.min(rig.ik_target.0); + y0 = y0.min(rig.ik_target.1); + x1 = x1.max(rig.ik_target.0); + y1 = y1.max(rig.ik_target.1); + } + let pad_m = (total * 0.18).max(24.0); + KRect::new(x0 - pad_m, y0 - pad_m, x1 + pad_m, y1 + pad_m) +} + +/// Invierte una posición local del lienzo (px, relativa al rect del nodo) a +/// espacio de modelo, deshaciendo el `fit_transform(bounds, rect)`. +fn canvas_local_to_model(lx: f32, ly: f32, rw: f32, rh: f32, b: KRect) -> (f64, f64) { + let (bw, bh) = (b.width(), b.height()); + if bw <= 0.0 || bh <= 0.0 || rw <= 0.0 || rh <= 0.0 { + return (0.0, 0.0); + } + let s = (rw as f64 / bw).min(rh as f64 / bh); + let mx = (lx as f64 - (rw as f64 - bw * s) * 0.5) / s + b.x0; + let my = (ly as f64 - (rh as f64 - bh * s) * 0.5) / s + b.y0; + (mx, my) +} + +/// Escala modelo→pantalla del `fit_transform` (px por unidad de modelo). +fn canvas_scale(rw: f32, rh: f32, b: KRect) -> f64 { + let (bw, bh) = (b.width(), b.height()); + if bw <= 0.0 || bh <= 0.0 || rw <= 0.0 || rh <= 0.0 { + return 0.0; + } + (rw as f64 / bw).min(rh as f64 / bh) +} + +/// El lienzo: malla deformada (relleno + wireframe) + huesos + objetivo IK. +fn rig_canvas(model: &Model) -> View { + let skel = model.rig.skeleton(); + let mesh = model.rig.mesh(); + let positions = mesh.deform(&skel); + + // Segmentos de hueso en espacio de modelo (para dibujarlos encima). + let mut bones_world: Vec<( + llimphi_ui::llimphi_raster::kurbo::Point, + llimphi_ui::llimphi_raster::kurbo::Point, + )> = Vec::new(); + for (i, b) in model.rig.bones.iter().enumerate() { + let w = skel.world(i); + let a = w * llimphi_ui::llimphi_raster::kurbo::Point::ZERO; + let e = w * llimphi_ui::llimphi_raster::kurbo::Point::new(b.len, 0.0); + bones_world.push((a, e)); + } + + // Encuadre estable, compartido con la inversión del drag del objetivo IK. + let bounds = rig_view_bounds(&model.rig); + + let target = if model.rig.ik_enabled { + Some(llimphi_ui::llimphi_raster::kurbo::Point::new( + model.rig.ik_target.0, + model.rig.ik_target.1, + )) + } else { + None + }; + + let fill = theme_with_alpha(model.theme.accent, 90); + let wire = model.theme.fg_text; + let bone_col = Color::from_rgba8(255, 196, 92, 255); // ámbar, contrasta con la malla + let bg = self_color(model.theme.bg_app, model.theme.bg_panel); + let tex = model.texture.clone(); + let use_tex = matches!(model.rig.mesh_mode, MeshMode::Grid) && tex.is_some(); + + View::new(Style { + flex_grow: 1.0, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(bg) + .radius(8.0) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle, Line, Stroke}; + use llimphi_ui::llimphi_raster::peniko::Fill; + use llimphi_mesh::{fit_transform, paint_solid, paint_textured, paint_wireframe}; + + if mesh.vertices.is_empty() { + return; + } + let xform = fit_transform(bounds, rect); + // Malla deformada: textura real (modo Grid) o relleno + wireframe. + if use_tex { + if let Some(t) = &tex { + paint_textured(scene, &mesh, &positions, xform, t); + } + // Wireframe tenue encima para leer la deformación. + paint_wireframe(scene, &mesh, &positions, xform, theme_with_alpha(wire, 55), 0.7); + } else { + paint_solid(scene, &mesh, &positions, xform, fill); + paint_wireframe(scene, &mesh, &positions, xform, wire, 1.0); + } + + // Huesos: líneas gruesas + nudos en las articulaciones. + for (a, e) in &bones_world { + let pa = xform * *a; + let pe = xform * *e; + scene.stroke( + &Stroke::new(3.0), + Affine::IDENTITY, + &bone_col, + None, + &Line::new(pa, pe), + ); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + &bone_col, + None, + &Circle::new(pa, 4.0), + ); + } + + // Objetivo IK: anillo blanco. + if let Some(t) = target { + let pt = xform * t; + let ring = Color::from_rgba8(255, 255, 255, 235); + scene.stroke( + &Stroke::new(2.0), + Affine::IDENTITY, + &ring, + None, + &Circle::new(pt, 9.0), + ); + } + }) + // Click coloca el objetivo IK; arrastrar lo mueve (el brazo lo persigue). + .on_click_at(|lx, ly, w, h| Some(Msg::RigCanvasClick(lx, ly, w, h))) + .draggable_at(|phase, dx, dy, _lx0, _ly0| match phase { + DragPhase::Move => Some(Msg::RigCanvasDrag(dx, dy)), + _ => None, + }) +} + +/// Color con alpha explícito. +fn theme_with_alpha(c: Color, a: u8) -> Color { + let r = c.to_rgba8(); + Color::from_rgba8(r.r, r.g, r.b, a) +} + +// ============================================================================= +// Helpers de layout +// ============================================================================= + +fn auto_h(h: f32) -> Style { + Style { + size: Size { + width: percent(1.0_f32), + height: if h > 0.0 { length(h) } else { Dimension::auto() }, + }, + align_items: Some(AlignItems::Center), + ..Default::default() + } +} + +fn spacer(h: f32) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(h), + }, + ..Default::default() + }) +} + +fn section_title(text: &str, theme: &Theme) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(20.0_f32), + }, + ..Default::default() + }) + .text(text.to_string(), 11.0, theme.fg_muted) +} + +fn muted(text: &str, theme: &Theme) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + ..Default::default() + }) + .text(text.to_string(), 11.0, theme.fg_placeholder) + .max_lines(2) +} + +fn grow_text(text: String, theme: &Theme) -> View { + View::new(Style { + flex_grow: 1.0, + size: Size { + width: Dimension::auto(), + height: length(26.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text(text, 12.0, theme.fg_text) +} + +/// Botón de conmutación (segmented control): resaltado en accent si activo. +fn toggle_btn(label: &str, active: bool, msg: Msg, theme: &Theme) -> View { + let (bg, fg) = if active { + (theme.accent, Color::from_rgba8(20, 20, 24, 255)) + } else { + (theme.bg_button, theme.fg_muted) + }; + View::new(Style { + flex_grow: 1.0, + size: Size { + width: Dimension::auto(), + height: length(28.0_f32), + }, + align_items: Some(AlignItems::Center), + padding: pad(10.0, 0.0), + ..Default::default() + }) + .fill(bg) + .radius(5.0) + .text(label.to_string(), 12.0, fg) + .on_click(msg) +} + +fn fixed_btn(label: &str, msg: Msg, btn: &ButtonPalette, w: f32) -> View { + View::new(Style { + size: Size { + width: length(w), + height: length(26.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![button_view(label.to_string(), btn, msg)]) +} + +fn selectable_row(label: &str, selected: bool, msg: Msg, theme: &Theme) -> View { + let bg = if selected { + theme.bg_selected + } else { + theme.bg_panel_alt + }; + let fg = if selected { theme.fg_text } else { theme.fg_muted }; + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(26.0_f32), + }, + align_items: Some(AlignItems::Center), + padding: pad(8.0, 0.0), + ..Default::default() + }) + .fill(bg) + .radius(4.0) + .text(label.to_string(), 12.0, fg) + .on_click(msg) +} + +fn row(children: Vec>) -> View { + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + gap: gap(6.0), + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .children(children) +} + +/// Fila que envuelve (varios botones chicos). +fn wrap_row(children: Vec>) -> View { + use llimphi_ui::llimphi_layout::taffy::prelude::FlexWrap; + View::new(Style { + flex_direction: FlexDirection::Row, + flex_wrap: FlexWrap::Wrap, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + gap: gap(4.0), + ..Default::default() + }) + .children(children) +} + +fn panel_column(rows: Vec>, width: f32, bg: Color) -> View { + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: length(width), + height: percent(1.0_f32), + }, + flex_shrink: 0.0, + padding: pad(12.0, 12.0), + gap: gap(2.0), + ..Default::default() + }) + .fill(bg) + .children(rows) +} + +fn pad( + x: f32, + y: f32, +) -> llimphi_ui::llimphi_layout::taffy::prelude::Rect< + llimphi_ui::llimphi_layout::taffy::prelude::LengthPercentage, +> { + use llimphi_ui::llimphi_layout::taffy::prelude::Rect; + Rect { + left: length(x), + right: length(x), + top: length(y), + bottom: length(y), + } +} + +fn gap( + g: f32, +) -> Size { + Size { + width: length(g), + height: length(g), + } +} + +/// Mezcla simple de dos colores al 50% (para el fondo del preview). +fn self_color(a: Color, b: Color) -> Color { + let ca = a.to_rgba8(); + let cb = b.to_rgba8(); + Color::from_rgba8( + ((ca.r as u16 + cb.r as u16) / 2) as u8, + ((ca.g as u16 + cb.g as u16) / 2) as u8, + ((ca.b as u16 + cb.b as u16) / 2) as u8, + 255, + ) +} + +// ============================================================================= +// Operaciones sobre el documento +// ============================================================================= + +/// Carga una imagen de disco y devuelve `(textura, aspecto alto/ancho)`. +fn load_texture(path: &str) -> Result<(llimphi_image::Image, f64), String> { + use std::path::Path; + const MAX: u64 = 64 * 1024 * 1024; + let img = llimphi_image::load_path(Path::new(path), MAX).map_err(|e| format!("{e:?}"))?; + let w = (img.image.width.max(1)) as f64; + let h = (img.image.height.max(1)) as f64; + Ok((img, (h / w).clamp(0.1, 3.0))) +} + +/// Borra el estado `idx` y reindexa transiciones/entry consistentemente. +fn remove_state(doc: &mut Doc, idx: usize) { + if idx >= doc.states.len() { + return; + } + doc.states.remove(idx); + // Descartar transiciones que tocan el estado borrado; reindexar el resto. + doc.transitions.retain(|t| t.from != Some(idx) && t.to != idx); + for t in &mut doc.transitions { + if let Some(f) = t.from { + if f > idx { + t.from = Some(f - 1); + } + } + if t.to > idx { + t.to -= 1; + } + } + if doc.entry == idx { + doc.entry = 0; + } else if doc.entry > idx { + doc.entry -= 1; + } +} + +fn main() { + llimphi_ui::run::(); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// La inversión pantalla→modelo del drag debe deshacer exactamente el + /// `fit_transform(bounds, rect)` que usa el render — round-trip < 1e-6. + #[test] + fn canvas_local_to_model_invierte_fit_transform() { + let b = KRect::new(-30.0, -50.0, 260.0, 50.0); + let (rw, rh) = (640.0_f64, 360.0_f64); + let (bw, bh) = (b.width(), b.height()); + let s = (rw / bw).min(rh / bh); + // Forward = misma fórmula de fit_transform, en coords LOCALES al rect. + let forward = |mx: f64, my: f64| { + let lx = (rw - bw * s) * 0.5 - b.x0 * s + s * mx; + let ly = (rh - bh * s) * 0.5 - b.y0 * s + s * my; + (lx, ly) + }; + for (mx, my) in [(0.0, 0.0), (130.0, 10.0), (-20.0, 40.0), (255.0, -30.0)] { + let (lx, ly) = forward(mx, my); + let (rx, ry) = canvas_local_to_model(lx as f32, ly as f32, rw as f32, rh as f32, b); + assert!( + (rx - mx).abs() < 1e-3 && (ry - my).abs() < 1e-3, + "round-trip falló para ({mx},{my}): recuperó ({rx},{ry})" + ); + } + } + + /// El bbox de encuadre debe contener el objetivo IK cuando está activo + /// (si no, el objetivo se saldría del lienzo y el drag sería inconsistente). + #[test] + fn bounds_contiene_objetivo_ik() { + let mut rig = RigDoc::starter(); + rig.ik_enabled = true; + rig.ik_target = (240.0, -130.0); + let b = rig_view_bounds(&rig); + assert!(b.x0 <= 240.0 && b.x1 >= 240.0); + assert!(b.y0 <= -130.0 && b.y1 >= -130.0); + } +} diff --git a/llimphi-anim-studio/src/rig.rs b/llimphi-anim-studio/src/rig.rs new file mode 100644 index 0000000..4ee3b1f --- /dev/null +++ b/llimphi-anim-studio/src/rig.rs @@ -0,0 +1,444 @@ +//! El **rig esqueletal**: la representación editable de una cadena de huesos con +//! una malla-tira auto-skinneada, que se **compila** a un +//! [`llimphi_anim::skel::Skeleton`] + [`llimphi_anim::skel::Mesh`] deformables. +//! Es la Fase 2 del studio: sobre el mismo runtime estilo Rive, pero ahora el +//! "clip" es una **deformación esqueletal** en vez de una máquina de estados. +//! +//! ## Qué autora (y qué no, todavía) +//! +//! Autora una **cadena** de huesos (cada uno con su largo y su ángulo de pose), +//! una malla-tubo generada paramétricamente alrededor de la cadena (skinning +//! suave en las articulaciones, igual que el `build_arm` canónico del demo +//! `lottie_rive_demo`), y un **IK de 2 huesos** opcional sobre los dos primeros +//! huesos persiguiendo un objetivo. El *weight-paint* a mano y la malla +//! arbitraria (importada/dibujada) quedan para F2.5/F3 — aquí la malla es +//! derivada de la cadena, no editable vértice-a-vértice. +//! +//! La matemática (jerarquía, LBS, IK analítico) vive entera en `llimphi-anim`; +//! este módulo sólo **describe** el rig y lo proyecta al runtime, igual que +//! [`crate::doc::Doc`] hace con el `StateMachine`. + +use llimphi_anim::constraint::solve_two_bone_ik; +use llimphi_anim::skel::{Mesh, Pose, Skeleton, Vertex, Weight}; +use llimphi_ui::llimphi_raster::kurbo::{Point, Vec2}; +use serde::{Deserialize, Serialize}; + +/// Un hueso de la cadena. El hueso apunta a lo largo de su eje local **+x** +/// (convención de `skel`/`constraint`): su hijo se traslada `len` en +x. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BoneDef { + /// Largo del hueso (distancia al hijo, en unidades de modelo). + pub len: f64, + /// Ángulo de pose **local** en radianes (lo que editan los sliders). En el + /// bind pose la cadena está recta (todos los ángulos en 0). + pub angle: f64, +} + +impl BoneDef { + pub fn new(len: f64) -> Self { + BoneDef { len, angle: 0.0 } + } +} + +/// Cómo se genera la malla deformable alrededor de la cadena. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MeshMode { + /// Tira-tubo a lo largo de la cadena (skinning rígido+blend en joints). + /// Ideal para un miembro (brazo, cola). + Tube, + /// Rejilla rectangular que cubre toda la silueta, con cada vértice + /// auto-skinneado a los huesos por distancia. Es la malla para **deformar + /// una imagen/arte arbitrario** (sus UV mapean la textura completa). + Grid, +} + +/// El documento del rig: la cadena + parámetros de malla + IK + textura. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RigDoc { + pub bones: Vec, + /// Medio-grosor de la malla-tubo (mitad de la altura de la tira). + pub thickness: f64, + /// Columnas de la malla por unidad de largo (densidad del tubo). + pub cols: usize, + /// Modo de generación de la malla. + pub mesh_mode: MeshMode, + /// Resolución de la rejilla (celdas a lo largo de la cadena, modo Grid). + pub grid_res: usize, + /// Relación alto/ancho de la rejilla (modo Grid); se ajusta al cargar una + /// imagen para respetar su aspecto. + pub mesh_aspect: f64, + /// Path de la textura a deformar (se recarga al abrir el proyecto). Los + /// píxeles NO se serializan — sólo la referencia al archivo. + pub texture_path: Option, + /// ¿IK de 2 huesos activo sobre los huesos 0 y 1? + pub ik_enabled: bool, + /// Objetivo del IK (espacio de modelo). + pub ik_target: (f64, f64), + /// Solución de codo (flip de la rama del IK). + pub ik_flip: bool, +} + +impl Default for RigDoc { + fn default() -> Self { + RigDoc { + bones: Vec::new(), + thickness: 22.0, + cols: 16, + mesh_mode: MeshMode::Tube, + grid_res: 10, + mesh_aspect: 0.6, + texture_path: None, + ik_enabled: false, + ik_target: (200.0, 40.0), + ik_flip: false, + } + } +} + +impl RigDoc { + /// Rig de arranque: un brazo de 2 huesos, el ejemplo canónico. + pub fn starter() -> Self { + RigDoc { + bones: vec![BoneDef::new(140.0), BoneDef::new(120.0)], + ik_target: (180.0, 90.0), + ..Default::default() + } + } + + /// Largo total de la cadena (suma de los huesos). + pub fn total_len(&self) -> f64 { + self.bones.iter().map(|b| b.len).sum() + } + + /// Posición de arranque (arc-length) de cada hueso a lo largo de la cadena + /// recta: `starts[i] = Σ len[0..i]`. + fn starts(&self) -> Vec { + let mut s = Vec::with_capacity(self.bones.len()); + let mut acc = 0.0; + for b in &self.bones { + s.push(acc); + acc += b.len; + } + s + } + + /// Construye el esqueleto en **bind pose** (cadena recta) y lo congela. El + /// orden de los `BoneId` coincide con `self.bones`. + fn build_skeleton_bind(&self) -> Skeleton { + let mut s = Skeleton::new(); + let mut prev = None; + for (i, _b) in self.bones.iter().enumerate() { + // El hijo se traslada el largo del PADRE en +x; el root en el origen. + let local = if i == 0 { + Pose::identity() + } else { + Pose::translate(Vec2::new(self.bones[i - 1].len, 0.0)) + }; + prev = Some(s.add_bone(prev, local)); + } + s.bind(); + s + } + + /// Aplica los ángulos de pose actuales (y el IK, si está activo) sobre un + /// esqueleto ya en bind, y lo deja `update()`-eado listo para deformar. + fn pose_skeleton(&self, s: &mut Skeleton) { + for (i, b) in self.bones.iter().enumerate() { + let t = if i == 0 { + Vec2::ZERO + } else { + Vec2::new(self.bones[i - 1].len, 0.0) + }; + s.set_pose(i, Pose::new(t, b.angle, Vec2::new(1.0, 1.0))); + } + // IK sobre los dos primeros huesos: sobrescribe sus poses para que la + // punta del hueso 1 alcance el objetivo. Los huesos ≥2 conservan su + // ángulo de slider (relativo al hueso 1). + if self.ik_enabled && self.bones.len() >= 2 { + let tip_local = Vec2::new(self.bones[1].len, 0.0); + let target = Point::new(self.ik_target.0, self.ik_target.1); + solve_two_bone_ik(s, 0, 1, tip_local, target, self.ik_flip); + } + s.update(); + } + + /// Esqueleto posado (bind + poses + IK + update), listo para `deform`. + pub fn skeleton(&self) -> Skeleton { + let mut s = self.build_skeleton_bind(); + self.pose_skeleton(&mut s); + s + } + + /// Una copia del rig con una **deriva idle** procedural aplicada en la fase + /// `phase` ∈ `[0,1)` del loop: un vaivén suave de cada hueso (desfasado por + /// índice) y, si el IK está activo, una pequeña órbita del objetivo. Le da + /// vida al rig —como «Alley Cat»— mientras el formato del studio todavía no + /// serializa pistas de animación por keyframe (Fase 1). Lo comparten el bake + /// de fondos (`mirada-fondo`) y la reproducción en vivo del greeter, para que + /// el mismo `.ron` se vea igual en las tres superficies. + pub fn idle_at(&self, phase: f64) -> RigDoc { + const SWAY: f64 = 0.18; + const ORBIT: f64 = 24.0; + let p = phase * std::f64::consts::TAU; + let mut rig = self.clone(); + for (k, b) in rig.bones.iter_mut().enumerate() { + b.angle += SWAY * (p + k as f64 * 0.6).sin(); + } + if rig.ik_enabled { + rig.ik_target.0 += ORBIT * p.cos(); + rig.ik_target.1 += ORBIT * (p * 2.0).sin() * 0.5; + } + rig + } + + /// La malla deformable según el modo activo. + pub fn mesh(&self) -> Mesh { + match self.mesh_mode { + MeshMode::Tube => self.tube_mesh(), + MeshMode::Grid => self.grid_mesh(), + } + } + + /// Malla-tubo skinneada alrededor de la cadena recta (bind space). Skinning + /// suave en las articulaciones: lejos de un joint el vértice es rígido a su + /// hueso; dentro de la ventana de blend mezcla con el hueso vecino. + fn tube_mesh(&self) -> Mesh { + let mut m = Mesh::new(); + let n = self.bones.len(); + if n == 0 { + return m; + } + let total = self.total_len(); + let starts = self.starts(); + let cols = self.cols.max(2); + let half = self.thickness; + // Ventana de blend: una fracción del hueso más corto. + let min_len = self.bones.iter().map(|b| b.len).fold(f64::MAX, f64::min); + let blend = (min_len * 0.4).max(1.0); + + for i in 0..=cols { + let p = total * i as f64 / cols as f64; + let weights = self.weights_at(p, &starts, blend); + let u = i as f64 / cols as f64; + m.vertices.push(Vertex { + rest: Point::new(p, -half), + uv: (u, 0.0), + weights: weights.clone(), + }); + m.vertices.push(Vertex { + rest: Point::new(p, half), + uv: (u, 1.0), + weights, + }); + } + for i in 0..cols { + let (t0, t1) = ((2 * i) as u32, (2 * (i + 1)) as u32); + let (b0, b1) = ((2 * i + 1) as u32, (2 * (i + 1) + 1) as u32); + m.triangles.push([t0, t1, b1]); + m.triangles.push([t0, b1, b0]); + } + m + } + + /// Pesos de un vértice a arc-position `p`: rígido a su segmento, con blend + /// lineal hacia el hueso vecino cerca de cada joint. + fn weights_at(&self, p: f64, starts: &[f64], blend: f64) -> Vec { + let n = self.bones.len(); + // Segmento que contiene a p. + let mut k = 0; + while k + 1 < n && p >= starts[k + 1] { + k += 1; + } + // Joint de entrada (con k-1) y de salida (con k+1). + if k + 1 < n { + let d = starts[k + 1] - p; // distancia al joint siguiente + if d < blend { + let t = (d / blend).clamp(0.0, 1.0); + let wk = 0.5 + 0.5 * t; + return vec![ + Weight { bone: k, weight: wk }, + Weight { bone: k + 1, weight: 1.0 - wk }, + ]; + } + } + if k > 0 { + let d = p - starts[k]; // distancia al joint anterior + if d < blend { + let t = (d / blend).clamp(0.0, 1.0); + let wk = 0.5 + 0.5 * t; + return vec![ + Weight { bone: k, weight: wk }, + Weight { bone: k - 1, weight: 1.0 - wk }, + ]; + } + } + vec![Weight { bone: k, weight: 1.0 }] + } + + /// Malla-rejilla que cubre la silueta (`[0,total] × [-H/2,H/2]`, con + /// `H = total·aspect`), cada vértice **auto-skinneado** a los huesos por + /// distancia (inverse-distance, top-2). Sus UV mapean la textura completa + /// `0..1`, así que deforma una imagen arbitraria, no sólo un miembro. + fn grid_mesh(&self) -> Mesh { + let mut m = Mesh::new(); + let n = self.bones.len(); + if n == 0 { + return m; + } + let total = self.total_len(); + let h = (total * self.mesh_aspect).max(1.0); + let y_top = -h * 0.5; + let starts = self.starts(); + let gx = self.grid_res.max(2); + let gy = ((gx as f64 * self.mesh_aspect).round() as usize).max(2); + + for j in 0..=gy { + for i in 0..=gx { + let x = total * i as f64 / gx as f64; + let y = y_top + h * j as f64 / gy as f64; + let weights = self.skin_weights_at(Point::new(x, y), &starts); + let uv = (i as f64 / gx as f64, j as f64 / gy as f64); + m.vertices.push(Vertex { + rest: Point::new(x, y), + uv, + weights, + }); + } + } + let stride = (gx + 1) as u32; + for j in 0..gy as u32 { + for i in 0..gx as u32 { + let a = j * stride + i; + let b = a + 1; + let c = a + stride; + let d = c + 1; + m.triangles.push([a, b, d]); + m.triangles.push([a, d, c]); + } + } + m + } + + /// Pesos auto-skin de un punto: distancia a cada segmento-hueso (en bind, + /// recta sobre el eje x), inverse-distance², se queda con los 2 huesos más + /// cercanos y normaliza. Da una deformación suave de la rejilla. + fn skin_weights_at(&self, p: Point, starts: &[f64]) -> Vec { + let n = self.bones.len(); + // (bone, dist) por hueso. + let mut ds: Vec<(usize, f64)> = (0..n) + .map(|k| { + let x0 = starts[k]; + let x1 = starts[k] + self.bones[k].len; + let dx = if p.x < x0 { + x0 - p.x + } else if p.x > x1 { + p.x - x1 + } else { + 0.0 + }; + (k, dx.hypot(p.y)) + }) + .collect(); + // Top-2 más cercanos. + ds.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + ds.truncate(2); + let eps = 1e-3; + let mut raw: Vec<(usize, f64)> = + ds.iter().map(|(k, d)| (*k, 1.0 / (d * d + eps))).collect(); + let sum: f64 = raw.iter().map(|(_, w)| w).sum(); + if sum <= 0.0 { + return vec![Weight { bone: 0, weight: 1.0 }]; + } + for (_, w) in &mut raw { + *w /= sum; + } + raw.into_iter() + .map(|(bone, weight)| Weight { bone, weight }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compila_esqueleto_y_malla() { + let rig = RigDoc::starter(); + let s = rig.skeleton(); + assert_eq!(s.len(), 2); + let m = rig.mesh(); + assert!(!m.vertices.is_empty()); + assert!(!m.triangles.is_empty()); + // En bind (todos los ángulos 0, IK off) la deformación = reposo. + let pos = m.deform(&s); + assert_eq!(pos.len(), m.vertices.len()); + for (p, v) in pos.iter().zip(&m.vertices) { + assert!((p.x - v.rest.x).abs() < 1e-6 && (p.y - v.rest.y).abs() < 1e-6); + } + } + + #[test] + fn posar_un_hueso_deforma_la_malla() { + let mut rig = RigDoc::starter(); + let rest = rig.mesh().deform(&rig.skeleton()); + // Doblar el codo (hueso 1) 0.8 rad. + rig.bones[1].angle = 0.8; + let bent = rig.mesh().deform(&rig.skeleton()); + // Al menos un vértice de la punta se movió respecto al reposo. + let moved = rest + .iter() + .zip(&bent) + .any(|(a, b)| (a.x - b.x).hypot(a.y - b.y) > 1.0); + assert!(moved, "doblar el codo debería mover la malla"); + } + + #[test] + fn grid_mesh_pesos_normalizados_y_deforma() { + let mut rig = RigDoc::starter(); + rig.mesh_mode = MeshMode::Grid; + rig.grid_res = 8; + let m = rig.mesh(); + assert!(!m.vertices.is_empty() && !m.triangles.is_empty()); + // Cada vértice tiene pesos que suman ~1 (skinning bien normalizado). + for v in &m.vertices { + let s: f64 = v.weights.iter().map(|w| w.weight).sum(); + assert!((s - 1.0).abs() < 1e-6, "pesos deben sumar 1, fue {s}"); + assert!(v.weights.iter().all(|w| w.bone < rig.bones.len())); + } + // Posar el codo deforma la rejilla. + let rest = m.deform(&rig.skeleton()); + rig.bones[1].angle = 0.9; + let bent = rig.mesh().deform(&rig.skeleton()); + let moved = rest + .iter() + .zip(&bent) + .any(|(a, b)| (a.x - b.x).hypot(a.y - b.y) > 1.0); + assert!(moved, "doblar el codo debería deformar la rejilla"); + } + + #[test] + fn ik_alcanza_el_objetivo() { + let mut rig = RigDoc::starter(); + rig.ik_enabled = true; + // Objetivo dentro del alcance (l1+l2 = 260). + rig.ik_target = (150.0, 80.0); + let s = rig.skeleton(); + // Punta del hueso 1 en mundo = world(1) * (len1, 0). + let tip = s.world(1) * Point::new(rig.bones[1].len, 0.0); + let target = Point::new(rig.ik_target.0, rig.ik_target.1); + let err = (tip.x - target.x).hypot(tip.y - target.y); + assert!(err < 1.0, "el IK debería alcanzar el objetivo, err={err}"); + } + + #[test] + fn ik_clampa_objetivo_inalcanzable_sin_panickear() { + let mut rig = RigDoc::starter(); + rig.ik_enabled = true; + rig.ik_target = (10_000.0, 0.0); // mucho más lejos que el alcance + let s = rig.skeleton(); + // No panickea y el brazo queda estirado hacia el objetivo (x≈260). + let tip = s.world(1) * Point::new(rig.bones[1].len, 0.0); + assert!(tip.x > 200.0, "brazo estirado hacia el objetivo lejano"); + } +} diff --git a/llimphi-anim/Cargo.toml b/llimphi-anim/Cargo.toml new file mode 100644 index 0000000..e56bbb6 --- /dev/null +++ b/llimphi-anim/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "llimphi-anim" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-anim — máquina de estados de animación (estilo Rive) clip-agnóstica: estados, inputs (bool/number/trigger), transiciones con condiciones y blend. El núcleo no sabe de Lottie ni de vello — opera sobre ClipId + duración; el render lo cablea el consumidor (llimphi-lottie hoy; rigs nativos mañana)." + +[dependencies] +# Álgebra afín 2D (Affine/Point/Vec2) para la jerarquía de huesos y el skinning. +# Misma versión que expone vello 0.7 → los Point deformados tipan directo con el +# renderer (llimphi-lottie/mesh) sin conversiones. +kurbo = "0.13" diff --git a/llimphi-anim/LEEME.md b/llimphi-anim/LEEME.md new file mode 100644 index 0000000..e622fb0 --- /dev/null +++ b/llimphi-anim/LEEME.md @@ -0,0 +1,41 @@ +# llimphi-anim + +*Read this in English: [README.md](README.md).* + +Máquina de estados de animación, estilo Rive, **clip-agnóstica**. + +Es el escalón sobre el playback lineal: en vez de "reproduce esta animación", +modela "según estos *inputs*, en qué estado estoy y a cuál transiciono, con +qué *blend*". Es lo que vuelve una animación *interactiva* (hover, progreso, +triggers) en lugar de un loop fijo. + +## Por qué clip-agnóstico + +El núcleo **no sabe de Lottie ni de vello**. Un clip es un `ClipId` (u32) +con una duración conocida; la máquina secuencia y mezcla clips por id y +tiempo, y emite un `RenderFrame` que dice *qué* renderizar (clip primario + +un clip entrante con su mezcla 0..1 durante una transición). El consumidor +mapea `ClipId → asset` y pinta. Hoy ese consumidor es `llimphi-lottie`; +mañana puede ser un rig de huesos nativo o un tween de `llimphi-motion` — el +mismo motor de estados sirve a todos. + +## Modelo (espejo del de Rive) + +- **Inputs**: `bool`, `number`, `trigger` (los triggers se consumen en cada + `advance`). +- **Estado**: un clip con velocidad y loop, más sus transiciones salientes. +- **Transición**: destino + condiciones (AND) sobre inputs + duración de + blend. Las transiciones *any-state* se evalúan sin importar el estado + actual (típico para "fire trigger → ir a X desde donde sea"). +- **Condición**: `bool == v`, `number v`, `trigger disparado`, o + `clip terminó` (para "cuando la animación acaba, pasa a la siguiente"). + +## Bucle + +El consumidor empuja inputs (`Instance::set_bool` etc.), llama +`Instance::advance` con el `dt` del frame, y pinta `Instance::render_frame`. +Encaja directo en el bucle Elm de Llimphi vía `Handle::spawn_periodic`. + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-anim/README.md b/llimphi-anim/README.md new file mode 100644 index 0000000..fa2ba50 --- /dev/null +++ b/llimphi-anim/README.md @@ -0,0 +1,30 @@ +# llimphi-anim + +A Rive-style, **clip-agnostic** animation state machine. + +It is the step above linear playback: instead of "play this animation", it models +"given these *inputs*, which state am I in and which do I transition to, with what +*blend*". That is what turns an animation *interactive* (hover, progress, +triggers) rather than a fixed loop. + +## Why clip-agnostic + +The core **knows nothing about Lottie or vello**. A clip is a `ClipId` (u32) with +a known duration; the machine sequences and blends clips by id and time, and +emits a `RenderFrame` saying *what* to render (the primary clip plus an incoming +clip with its 0..1 blend during a transition). The consumer maps +`ClipId → asset` and paints. Today that consumer is `llimphi-lottie`; tomorrow it +could be a native bone rig or a `llimphi-motion` tween — the same state engine +serves them all. + +## Model (mirroring Rive's) + +- **Inputs**: `bool`, `number`, `trigger` (triggers are consumed on each + `advance`). +- **State**: a clip with speed and looping, plus its outgoing transitions. +- **Transition**: a target + (AND-ed) conditions over inputs + a blend duration. + *Any-state* transitions are evaluated regardless of the current state. + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-anim/src/constraint.rs b/llimphi-anim/src/constraint.rs new file mode 100644 index 0000000..3bcca25 --- /dev/null +++ b/llimphi-anim/src/constraint.rs @@ -0,0 +1,182 @@ +//! Constraints de rigging sobre la jerarquía de huesos (estilo Rive): IK de 2 +//! huesos y aim. Corren **después** de que la animación posó el esqueleto, +//! ajustando rotaciones para satisfacer un objetivo; luego el skinning deforma. +//! +//! Convención: los huesos apuntan a lo largo de su eje local **+x** (su hijo se +//! traslada en +x, el efector está en +x) — la misma que arma `skel`. Todo en +//! 2D, analítico (sin iteración), sobre `kurbo`. + +use kurbo::{Affine, Point, Vec2}; + +use crate::skel::{BoneId, Pose, Skeleton}; + +/// Ángulo de rotación de un `Affine` (asume escala ~uniforme y sin shear). +fn affine_rotation(aff: Affine) -> f64 { + let c = aff.as_coeffs(); // [a, b, c, d, e, f]; a=cosθ·sx, b=sinθ·sx + c[1].atan2(c[0]) +} + +/// Origen (punto de pivote) de un hueso en mundo. La rotación del propio hueso +/// no mueve su origen, así que sirve aunque vayamos a re-rotarlo. +fn bone_origin(skel: &Skeleton, bone: BoneId) -> Point { + skel.world(bone) * Point::ZERO +} + +fn parent_rotation(skel: &Skeleton, bone: BoneId) -> f64 { + match skel.parent(bone) { + Some(p) => affine_rotation(skel.world(p)), + None => 0.0, + } +} + +/// Resuelve **IK de 2 huesos**: ajusta las rotaciones locales de `upper` (padre) +/// y `lower` (hijo de `upper`) para que el efector — el punto `tip_local` en el +/// frame de `lower` — alcance `target` (en espacio de mundo del esqueleto). +/// `flip` elige la solución del codo (arriba/abajo). Si `target` está fuera de +/// alcance, el brazo se estira hacia él (alcance máximo). Llama `update` al final. +/// +/// Requiere que los world transforms estén actualizados antes (la animación ya +/// posó el esqueleto). Las longitudes salen del rig: `L1` = offset `upper→lower`, +/// `L2` = `|tip_local|`. +pub fn solve_two_bone_ik( + skel: &mut Skeleton, + upper: BoneId, + lower: BoneId, + tip_local: Vec2, + target: Point, + flip: bool, +) { + let base = bone_origin(skel, upper); + let l1 = skel.pose(lower).translation.hypot(); + let l2 = tip_local.hypot(); + if l1 < 1e-9 || l2 < 1e-9 { + return; + } + let s = if flip { -1.0 } else { 1.0 }; + + let d = target - base; + let dist = d.hypot().clamp((l1 - l2).abs() + 1e-6, l1 + l2 - 1e-6); + let alpha = d.y.atan2(d.x); + let cos_beta = ((dist * dist + l1 * l1 - l2 * l2) / (2.0 * dist * l1)).clamp(-1.0, 1.0); + let beta = cos_beta.acos(); + let cos_elbow = ((l1 * l1 + l2 * l2 - dist * dist) / (2.0 * l1 * l2)).clamp(-1.0, 1.0); + let elbow = cos_elbow.acos(); + + let theta_upper_world = alpha - s * beta; + let theta_lower_local = s * (std::f64::consts::PI - elbow); + + let up = skel.pose(upper); + skel.set_pose( + upper, + Pose { + rotation: theta_upper_world - parent_rotation(skel, upper), + ..up + }, + ); + let low = skel.pose(lower); + skel.set_pose( + lower, + Pose { + rotation: theta_lower_local, + ..low + }, + ); + skel.update(); +} + +/// **Aim**: rota `bone` para que su eje local `forward` apunte desde su origen +/// hacia `target` (mundo). Llama `update`. Útil para que una cabeza/ojo/torreta +/// siga a un objetivo. +pub fn aim_at(skel: &mut Skeleton, bone: BoneId, forward: Vec2, target: Point) { + let origin = bone_origin(skel, bone); + let d = target - origin; + if d.hypot() < 1e-9 || forward.hypot() < 1e-9 { + return; + } + let desired_world = d.y.atan2(d.x) - forward.y.atan2(forward.x); + let pose = skel.pose(bone); + skel.set_pose( + bone, + Pose { + rotation: desired_world - parent_rotation(skel, bone), + ..pose + }, + ); + skel.update(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::skel::Pose; + + fn two_bone(l1: f64) -> (Skeleton, BoneId, BoneId) { + let mut s = Skeleton::new(); + let a = s.add_bone(None, Pose::identity()); + let b = s.add_bone(Some(a), Pose::translate(Vec2::new(l1, 0.0))); + s.bind(); + s.update(); + (s, a, b) + } + + fn tip(skel: &Skeleton, lower: BoneId, tip_local: Vec2) -> Point { + skel.world(lower) * Point::new(tip_local.x, tip_local.y) + } + + #[test] + fn ik_alcanza_un_objetivo_dentro_de_rango() { + let (mut s, a, b) = two_bone(50.0); + let tip_local = Vec2::new(50.0, 0.0); // L2 = 50, alcance 0..100 + let target = Point::new(60.0, 40.0); // dist ≈ 72 < 100 + solve_two_bone_ik(&mut s, a, b, tip_local, target, false); + let t = tip(&s, b, tip_local); + assert!( + (t.x - target.x).abs() < 1e-6 && (t.y - target.y).abs() < 1e-6, + "tip {t:?} debería alcanzar {target:?}" + ); + } + + #[test] + fn ik_fuera_de_rango_se_estira() { + let (mut s, a, b) = two_bone(50.0); + let tip_local = Vec2::new(50.0, 0.0); + let target = Point::new(300.0, 0.0); // muy lejos → estira a ~100 en x + solve_two_bone_ik(&mut s, a, b, tip_local, target, false); + let t = tip(&s, b, tip_local); + assert!((t.x - 100.0).abs() < 1e-3, "x del tip {} ~ 100", t.x); + assert!(t.y.abs() < 1e-3, "y del tip {} ~ 0 (en línea al objetivo)", t.y); + } + + #[test] + fn ik_flip_da_el_otro_codo() { + let (mut s, a, b) = two_bone(50.0); + let tip_local = Vec2::new(50.0, 0.0); + let target = Point::new(60.0, 40.0); + + solve_two_bone_ik(&mut s, a, b, tip_local, target, false); + let elbow_down = bone_origin(&s, b); + solve_two_bone_ik(&mut s, a, b, tip_local, target, true); + let elbow_up = bone_origin(&s, b); + + // Ambas soluciones alcanzan el target, pero el codo cae en lados + // opuestos de la línea base→target. + assert!( + (elbow_down.y - elbow_up.y).abs() > 1.0, + "los codos deberían diferir: {elbow_down:?} vs {elbow_up:?}" + ); + let t = tip(&s, b, tip_local); + assert!((t.x - target.x).abs() < 1e-6 && (t.y - target.y).abs() < 1e-6); + } + + #[test] + fn aim_apunta_el_eje_forward_al_objetivo() { + let mut s = Skeleton::new(); + let bone = s.add_bone(None, Pose::identity()); + s.bind(); + s.update(); + aim_at(&mut s, bone, Vec2::new(1.0, 0.0), Point::new(0.0, 10.0)); + // El eje +x del hueso ahora debe apuntar hacia +y (al objetivo). + let dir = s.world(bone) * Point::new(1.0, 0.0); + assert!(dir.x.abs() < 1e-9 && dir.y > 0.0, "dir {dir:?} debería ser +y"); + } +} diff --git a/llimphi-anim/src/lib.rs b/llimphi-anim/src/lib.rs new file mode 100644 index 0000000..77dbb48 --- /dev/null +++ b/llimphi-anim/src/lib.rs @@ -0,0 +1,758 @@ +//! `llimphi-anim` — máquina de estados de animación, estilo Rive, **clip-agnóstica**. +//! +//! Es el escalón sobre el playback lineal: en vez de "reproduce esta animación", +//! modela "según estos *inputs*, en qué estado estoy y a cuál transiciono, con +//! qué *blend*". Es lo que vuelve una animación *interactiva* (hover, progreso, +//! triggers) en lugar de un loop fijo. +//! +//! ## Por qué clip-agnóstico +//! +//! El núcleo **no sabe de Lottie ni de vello**. Un clip es un [`ClipId`] (u32) +//! con una duración conocida; la máquina secuencia y mezcla clips por id y +//! tiempo, y emite un [`RenderFrame`] que dice *qué* renderizar (clip primario + +//! un clip entrante con su mezcla 0..1 durante una transición). El consumidor +//! mapea `ClipId → asset` y pinta. Hoy ese consumidor es `llimphi-lottie`; +//! mañana puede ser un rig de huesos nativo o un tween de `llimphi-motion` — el +//! mismo motor de estados sirve a todos. +//! +//! ## Modelo (espejo del de Rive) +//! +//! - **Inputs**: `bool`, `number`, `trigger` (los triggers se consumen en cada +//! `advance`). +//! - **Estado**: un clip con velocidad y loop, más sus transiciones salientes. +//! - **Transición**: destino + condiciones (AND) sobre inputs + duración de +//! blend. Las transiciones *any-state* se evalúan sin importar el estado +//! actual (típico para "fire trigger → ir a X desde donde sea"). +//! - **Condición**: `bool == v`, `number v`, `trigger disparado`, o +//! `clip terminó` (para "cuando la animación acaba, pasa a la siguiente"). +//! +//! ## Bucle +//! +//! El consumidor empuja inputs ([`Instance::set_bool`] etc.), llama +//! [`Instance::advance`] con el `dt` del frame, y pinta [`Instance::render_frame`]. +//! Encaja directo en el bucle Elm de Llimphi vía `Handle::spawn_periodic`. + +#![forbid(unsafe_code)] + +pub mod constraint; +pub mod physics; +pub mod skel; + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +/// Índice de un estado dentro de la máquina (devuelto por `add_state`). +pub type StateId = usize; + +/// Identificador opaco de un clip de animación. Lo interpreta el consumidor +/// (p. ej. índice en un `Vec` o clave de un mapa). +pub type ClipId = u32; + +/// Operador de comparación para condiciones sobre inputs numéricos. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Cmp { + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +impl Cmp { + fn test(self, a: f64, b: f64) -> bool { + match self { + Cmp::Eq => a == b, + Cmp::Ne => a != b, + Cmp::Lt => a < b, + Cmp::Le => a <= b, + Cmp::Gt => a > b, + Cmp::Ge => a >= b, + } + } +} + +/// Condición de guarda de una transición. Una transición dispara cuando **todas** +/// sus condiciones se cumplen (AND). +#[derive(Debug, Clone)] +pub enum Condition { + /// El input booleano `name` vale `value` (default `false` si no está seteado). + Bool { name: String, value: bool }, + /// El input numérico `name` comparado con `value` por `op` (default `0.0`). + Number { name: String, op: Cmp, value: f64 }, + /// El trigger `name` fue disparado en este frame (se consume al `advance`). + Trigger { name: String }, + /// El clip del estado actual terminó (sólo aplica a estados no-loop). + ClipDone, +} + +impl Condition { + /// `bool == value`. + pub fn bool(name: impl Into, value: bool) -> Self { + Condition::Bool { + name: name.into(), + value, + } + } + /// `number value`. + pub fn number(name: impl Into, op: Cmp, value: f64) -> Self { + Condition::Number { + name: name.into(), + op, + value, + } + } + /// trigger disparado. + pub fn trigger(name: impl Into) -> Self { + Condition::Trigger { name: name.into() } + } + /// el clip actual terminó. + pub fn clip_done() -> Self { + Condition::ClipDone + } +} + +/// Región sensible al puntero, en coordenadas **normalizadas** `0..1` sobre el +/// rect donde se pinta la animación (origen arriba-izquierda). Resolución- e +/// independiente del tamaño del clip: el consumidor mapea el puntero de pantalla +/// a este espacio. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Area { + /// Toda la superficie de la animación. + All, + /// Un rectángulo normalizado. + Rect { x: f64, y: f64, w: f64, h: f64 }, +} + +impl Area { + fn contains(&self, px: f64, py: f64) -> bool { + match self { + Area::All => (0.0..=1.0).contains(&px) && (0.0..=1.0).contains(&py), + Area::Rect { x, y, w, h } => { + px >= *x && px <= x + w && py >= *y && py <= y + h + } + } + } +} + +/// Qué evento de puntero dispara un [`Listener`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PointerTrigger { + /// El puntero entró al área. + Enter, + /// El puntero salió del área. + Exit, + /// Se presionó el botón con el puntero dentro del área. + Down, + /// Se soltó el botón con el puntero dentro del área. + Up, + /// El puntero se movió dentro del área (cada movimiento). + Move, +} + +/// Qué le hace un [`Listener`] a los inputs cuando dispara. +#[derive(Debug, Clone)] +pub enum Action { + /// Setea un input booleano. + SetBool { name: String, value: bool }, + /// Dispara un trigger. + Fire { name: String }, +} + +impl Action { + pub fn set_bool(name: impl Into, value: bool) -> Self { + Action::SetBool { + name: name.into(), + value, + } + } + pub fn fire(name: impl Into) -> Self { + Action::Fire { name: name.into() } + } +} + +/// Un *listener* estilo Rive: cuando el puntero hace `trigger` sobre `area`, +/// aplica `action` a los inputs. Es el puente puntero → máquina de estados +/// (hover/click → bool/trigger → transición). +#[derive(Debug, Clone)] +pub struct Listener { + pub area: Area, + pub trigger: PointerTrigger, + pub action: Action, +} + +/// Una transición saliente: a qué estado, bajo qué condiciones, con cuánto blend. +#[derive(Debug, Clone)] +pub struct Transition { + pub to: StateId, + pub conditions: Vec, + /// Duración del crossfade hacia el estado destino, en segundos. `0.0` = + /// instantáneo (sin mezcla). + pub duration_secs: f64, +} + +/// Un estado de la máquina: un clip con su velocidad y loop, más sus salidas. +#[derive(Debug, Clone)] +struct State { + name: String, + clip: ClipId, + speed: f64, + looping: bool, + transitions: Vec, +} + +/// La máquina de estados *definición* (inmutable, compartible). Construida con +/// `new` + `add_state`/`transition`, congelada en un `Arc` por `instance`. +#[derive(Debug, Clone, Default)] +pub struct StateMachine { + states: Vec, + /// Duración conocida de cada clip (segundos). La necesita la condición + /// `ClipDone` y el wrap de tiempo. + clip_durations: HashMap, + /// Transiciones evaluadas desde *cualquier* estado. + any: Vec, + /// Listeners de puntero (hover/click → inputs). + listeners: Vec, + entry: StateId, +} + +impl StateMachine { + pub fn new() -> Self { + Self::default() + } + + /// Agrega un estado-clip. Devuelve su `StateId`. El primero agregado es el + /// estado de entrada por defecto (cambialo con [`set_entry`]). + /// + /// [`set_entry`]: StateMachine::set_entry + pub fn add_state( + &mut self, + name: impl Into, + clip: ClipId, + speed: f64, + looping: bool, + ) -> StateId { + let id = self.states.len(); + self.states.push(State { + name: name.into(), + clip, + speed, + looping, + transitions: Vec::new(), + }); + id + } + + /// Registra la duración (segundos) de un clip — necesaria para `ClipDone`. + pub fn set_clip_duration(&mut self, clip: ClipId, secs: f64) { + self.clip_durations.insert(clip, secs); + } + + /// Fija el estado de entrada. + pub fn set_entry(&mut self, entry: StateId) { + self.entry = entry; + } + + /// Agrega una transición `from → to` con condiciones y blend. + pub fn transition( + &mut self, + from: StateId, + to: StateId, + conditions: Vec, + duration_secs: f64, + ) { + self.states[from].transitions.push(Transition { + to, + conditions, + duration_secs, + }); + } + + /// Agrega una transición *any-state* `* → to` (se evalúa desde cualquier + /// estado, antes que las salientes del estado actual). + pub fn transition_any( + &mut self, + to: StateId, + conditions: Vec, + duration_secs: f64, + ) { + self.any.push(Transition { + to, + conditions, + duration_secs, + }); + } + + /// Agrega un listener de puntero: cuando el puntero hace `trigger` sobre + /// `area`, aplica `action` a los inputs (que luego rigen transiciones). + pub fn listener(&mut self, area: Area, trigger: PointerTrigger, action: Action) { + self.listeners.push(Listener { + area, + trigger, + action, + }); + } + + /// Crea una instancia ejecutable de esta máquina. + pub fn instance(self) -> Instance { + let entry = self.entry; + Instance { + machine: Arc::new(self), + inputs: Inputs::default(), + current: entry, + state_time: 0.0, + active: None, + last_pointer: None, + } + } + + fn clip_of(&self, state: StateId) -> ClipId { + self.states[state].clip + } + fn speed_of(&self, state: StateId) -> f64 { + self.states[state].speed + } + fn duration_of_clip(&self, clip: ClipId) -> Option { + self.clip_durations.get(&clip).copied() + } +} + +/// Estado de los inputs de una instancia. Booleanos y números persisten; los +/// triggers se limpian al final de cada `advance`. +#[derive(Debug, Clone, Default)] +struct Inputs { + bools: HashMap, + numbers: HashMap, + triggers: HashSet, +} + +/// Una transición en curso: hacia dónde, su tiempo propio y cuánto blend lleva. +#[derive(Debug, Clone)] +struct Active { + to: StateId, + to_time: f64, + elapsed: f64, + duration: f64, +} + +/// Instancia ejecutable: inputs vivos + estado actual + transición en curso. +/// `Clone` barato más allá de los inputs (la definición va por `Arc`). +#[derive(Debug, Clone)] +pub struct Instance { + machine: Arc, + inputs: Inputs, + current: StateId, + state_time: f64, + active: Option, + /// Última posición del puntero en coords normalizadas `0..1` (`None` = + /// fuera de la animación). La usan los listeners de `Down`/`Up` y la + /// detección de `Enter`/`Exit`. + last_pointer: Option<(f64, f64)>, +} + +/// Una muestra de clip a renderizar: qué clip y en qué instante (segundos). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ClipSample { + pub clip: ClipId, + pub time_secs: f64, +} + +/// Lo que el consumidor debe pintar este frame: el clip primario y, si hay una +/// transición en curso, el clip entrante con su mezcla (`0.0`=nada del entrante, +/// `1.0`=todo el entrante). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RenderFrame { + pub primary: ClipSample, + pub blend: Option<(ClipSample, f32)>, +} + +impl Instance { + /// Setea un input booleano. + pub fn set_bool(&mut self, name: impl Into, value: bool) { + self.inputs.bools.insert(name.into(), value); + } + /// Setea un input numérico. + pub fn set_number(&mut self, name: impl Into, value: f64) { + self.inputs.numbers.insert(name.into(), value); + } + /// Dispara un trigger (se consume en el próximo `advance`). + pub fn fire(&mut self, name: impl Into) { + self.inputs.triggers.insert(name.into()); + } + + /// Nombre del estado actual (de origen mientras hay una transición en curso). + pub fn current_state(&self) -> &str { + &self.machine.states[self.current].name + } + /// ¿Hay una transición (crossfade) en curso? + pub fn is_transitioning(&self) -> bool { + self.active.is_some() + } + + /// ¿El clip del estado actual terminó? (`false` si el estado es loop o si no + /// se registró su duración.) + fn clip_done(&self) -> bool { + let st = &self.machine.states[self.current]; + if st.looping { + return false; + } + match self.machine.duration_of_clip(st.clip) { + Some(dur) => self.state_time >= dur, + None => false, + } + } + + fn cond_met(&self, c: &Condition, clip_done: bool) -> bool { + match c { + Condition::Bool { name, value } => { + self.inputs.bools.get(name).copied().unwrap_or(false) == *value + } + Condition::Number { name, op, value } => { + op.test(self.inputs.numbers.get(name).copied().unwrap_or(0.0), *value) + } + Condition::Trigger { name } => self.inputs.triggers.contains(name), + Condition::ClipDone => clip_done, + } + } + + fn transition_ready(&self, t: &Transition, clip_done: bool) -> bool { + // Sin condiciones = nunca dispara sola (evita loops degenerados); + // las transiciones útiles siempre tienen al menos una guarda. + !t.conditions.is_empty() && t.conditions.iter().all(|c| self.cond_met(c, clip_done)) + } + + /// Busca la primera transición disparable: any-state primero, luego las + /// salientes del estado actual. No retorna self-transiciones (to == current). + fn pick_transition(&self, clip_done: bool) -> Option { + self.machine + .any + .iter() + .chain(self.machine.states[self.current].transitions.iter()) + .find(|t| t.to != self.current && self.transition_ready(t, clip_done)) + .cloned() + } + + fn begin(&mut self, t: Transition) { + if t.duration_secs <= 0.0 { + // Instantáneo: saltamos sin blend. + self.current = t.to; + self.state_time = 0.0; + self.active = None; + } else { + self.active = Some(Active { + to: t.to, + to_time: 0.0, + elapsed: 0.0, + duration: t.duration_secs, + }); + } + } + + /// Avanza la transición en curso (si la hay) por `dt` y la confirma cuando + /// el blend completa. Aislado para poder aplicarlo tanto a una transición + /// que ya venía como a una recién arrancada en este mismo frame. + fn step_active(&mut self, dt: f64) { + let Some(at) = self.active.as_ref() else { + return; + }; + let to = at.to; + let speed = self.machine.speed_of(to); + let at = self.active.as_mut().unwrap(); + at.to_time += dt * speed; + at.elapsed += dt; + if at.elapsed >= at.duration { + let to_time = at.to_time; + self.current = to; + self.state_time = to_time; + self.active = None; + } + } + + fn apply_action(&mut self, action: &Action) { + match action { + Action::SetBool { name, value } => { + self.inputs.bools.insert(name.clone(), *value); + } + Action::Fire { name } => { + self.inputs.triggers.insert(name.clone()); + } + } + } + + /// Reporta la posición del puntero en coords **normalizadas** `0..1` sobre + /// el rect de la animación, o `None` si el puntero salió. Dispara los + /// listeners `Enter`/`Exit`/`Move` correspondientes y deja la posición + /// guardada para los de `Down`/`Up`. + pub fn pointer_move(&mut self, pos: Option<(f64, f64)>) { + let machine = self.machine.clone(); + let was = self.last_pointer; + for l in &machine.listeners { + let was_in = was.map_or(false, |(x, y)| l.area.contains(x, y)); + let now_in = pos.map_or(false, |(x, y)| l.area.contains(x, y)); + let fire = match l.trigger { + PointerTrigger::Enter => !was_in && now_in, + PointerTrigger::Exit => was_in && !now_in, + PointerTrigger::Move => now_in, + _ => false, + }; + if fire { + self.apply_action(&l.action); + } + } + self.last_pointer = pos; + } + + /// Botón presionado: dispara los listeners `Down` cuya área contiene la + /// última posición conocida del puntero. + pub fn pointer_down(&mut self) { + self.pointer_button(PointerTrigger::Down); + } + + /// Botón soltado: dispara los listeners `Up`. + pub fn pointer_up(&mut self) { + self.pointer_button(PointerTrigger::Up); + } + + fn pointer_button(&mut self, which: PointerTrigger) { + let Some((px, py)) = self.last_pointer else { + return; + }; + let machine = self.machine.clone(); + for l in &machine.listeners { + if l.trigger == which && l.area.contains(px, py) { + self.apply_action(&l.action); + } + } + } + + /// Avanza la máquina `dt` segundos: corre el tiempo del estado, avanza/ + /// confirma la transición en curso, evalúa una nueva transición (y le aplica + /// el `dt` de este frame si arranca) y consume los triggers. + pub fn advance(&mut self, dt: f64) { + let dt = dt.max(0.0); + self.state_time += dt * self.machine.speed_of(self.current); + + // Transición que ya venía de frames anteriores. + self.step_active(dt); + + // Nueva transición sólo si no estamos mezclando (sin interrupción de + // blend en el MVP). Si arranca, le aplicamos el dt de este frame para + // que el blend progrese (y se complete si el dt es grande). + if self.active.is_none() { + let done = self.clip_done(); + if let Some(t) = self.pick_transition(done) { + self.begin(t); + self.step_active(dt); + } + } + + self.inputs.triggers.clear(); + } + + /// Qué pintar este frame. Durante una transición, `primary` es el estado de + /// origen y `blend` el entrante con su mezcla 0..1. + pub fn render_frame(&self) -> RenderFrame { + let primary = ClipSample { + clip: self.machine.clip_of(self.current), + time_secs: self.state_time, + }; + let blend = self.active.as_ref().map(|at| { + let mix = (at.elapsed / at.duration).clamp(0.0, 1.0) as f32; + ( + ClipSample { + clip: self.machine.clip_of(at.to), + time_secs: at.to_time, + }, + mix, + ) + }); + RenderFrame { primary, blend } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const IDLE: ClipId = 0; + const WALK: ClipId = 1; + const JUMP: ClipId = 2; + + /// idle ⇄ walk por un bool "moving", con blend de 0.2 s. + fn idle_walk() -> StateMachine { + let mut sm = StateMachine::new(); + let idle = sm.add_state("idle", IDLE, 1.0, true); + let walk = sm.add_state("walk", WALK, 1.0, true); + sm.set_entry(idle); + sm.transition(idle, walk, vec![Condition::bool("moving", true)], 0.2); + sm.transition(walk, idle, vec![Condition::bool("moving", false)], 0.2); + sm + } + + #[test] + fn arranca_en_entry() { + let inst = idle_walk().instance(); + assert_eq!(inst.current_state(), "idle"); + assert_eq!(inst.render_frame().primary.clip, IDLE); + assert!(inst.render_frame().blend.is_none()); + } + + #[test] + fn bool_dispara_transicion_con_blend() { + let mut inst = idle_walk().instance(); + inst.set_bool("moving", true); + inst.advance(0.1); // arranca la transición (dur 0.2) + assert!(inst.is_transitioning(), "debería estar mezclando"); + let rf = inst.render_frame(); + assert_eq!(rf.primary.clip, IDLE); // origen + let (incoming, mix) = rf.blend.expect("hay blend"); + assert_eq!(incoming.clip, WALK); + assert!((mix - 0.5).abs() < 1e-6, "0.1/0.2 = 0.5, fue {mix}"); + // Completar el blend. + inst.advance(0.15); + assert!(!inst.is_transitioning()); + assert_eq!(inst.current_state(), "walk"); + assert!(inst.render_frame().blend.is_none()); + } + + #[test] + fn vuelve_cuando_el_bool_se_apaga() { + let mut inst = idle_walk().instance(); + inst.set_bool("moving", true); + inst.advance(0.3); // idle→walk completo + assert_eq!(inst.current_state(), "walk"); + inst.set_bool("moving", false); + inst.advance(0.3); // walk→idle completo + assert_eq!(inst.current_state(), "idle"); + } + + #[test] + fn trigger_es_de_un_solo_frame() { + let mut sm = idle_walk(); + let jump = sm.add_state("jump", JUMP, 1.0, false); + sm.set_clip_duration(JUMP, 0.5); + // any-state: fire "jump" → jump (instantáneo) + sm.transition_any(jump, vec![Condition::trigger("jump")], 0.0); + // jump termina → vuelve a idle + sm.transition(jump, 0, vec![Condition::clip_done()], 0.0); + let mut inst = sm.instance(); + + inst.fire("jump"); + inst.advance(0.016); + assert_eq!(inst.current_state(), "jump"); + // El trigger se consumió: avanzar de nuevo no re-dispara nada raro. + inst.advance(0.016); + assert_eq!(inst.current_state(), "jump"); + // Cuando el clip de 0.5 s termina, vuelve a idle. + inst.advance(0.5); + assert_eq!(inst.current_state(), "idle"); + } + + #[test] + fn condicion_numerica() { + let mut sm = StateMachine::new(); + let slow = sm.add_state("slow", IDLE, 1.0, true); + let fast = sm.add_state("fast", WALK, 1.0, true); + sm.set_entry(slow); + sm.transition( + slow, + fast, + vec![Condition::number("speed", Cmp::Gt, 5.0)], + 0.0, + ); + let mut inst = sm.instance(); + inst.set_number("speed", 3.0); + inst.advance(0.016); + assert_eq!(inst.current_state(), "slow"); + inst.set_number("speed", 9.0); + inst.advance(0.016); + assert_eq!(inst.current_state(), "fast"); + } + + #[test] + fn transicion_instantanea_sin_blend() { + let mut sm = StateMachine::new(); + let a = sm.add_state("a", IDLE, 1.0, true); + let b = sm.add_state("b", WALK, 1.0, true); + sm.set_entry(a); + sm.transition(a, b, vec![Condition::bool("go", true)], 0.0); + let mut inst = sm.instance(); + inst.set_bool("go", true); + inst.advance(0.016); + assert!(!inst.is_transitioning()); + assert_eq!(inst.current_state(), "b"); + } + + #[test] + fn hover_enter_exit_maneja_un_bool() { + // idle ⇄ walk por "moving"; hover sobre toda la animación setea moving. + let mut sm = idle_walk(); + sm.listener(Area::All, PointerTrigger::Enter, Action::set_bool("moving", true)); + sm.listener(Area::All, PointerTrigger::Exit, Action::set_bool("moving", false)); + let mut inst = sm.instance(); + + // Puntero entra al centro → moving=true → transiciona a walk. + inst.pointer_move(Some((0.5, 0.5))); + inst.advance(0.3); + assert_eq!(inst.current_state(), "walk"); + + // Puntero sale → moving=false → vuelve a idle. + inst.pointer_move(None); + inst.advance(0.3); + assert_eq!(inst.current_state(), "idle"); + } + + #[test] + fn click_dispara_trigger_en_su_area() { + let mut sm = idle_walk(); + let jump = sm.add_state("jump", JUMP, 1.0, false); + sm.set_clip_duration(JUMP, 0.5); + sm.transition_any(jump, vec![Condition::trigger("tap")], 0.0); + sm.transition(jump, 0, vec![Condition::clip_done()], 0.0); + // Sólo la mitad derecha responde al click. + sm.listener( + Area::Rect { x: 0.5, y: 0.0, w: 0.5, h: 1.0 }, + PointerTrigger::Down, + Action::fire("tap"), + ); + let mut inst = sm.instance(); + + // Click en la mitad IZQUIERDA: fuera del área, no dispara. + inst.pointer_move(Some((0.2, 0.5))); + inst.pointer_down(); + inst.advance(0.016); + assert_eq!(inst.current_state(), "idle"); + + // Click en la mitad DERECHA: dispara "tap" → jump. + inst.pointer_move(Some((0.8, 0.5))); + inst.pointer_down(); + inst.advance(0.016); + assert_eq!(inst.current_state(), "jump"); + } + + #[test] + fn move_fuera_del_area_no_dispara() { + let mut sm = idle_walk(); + sm.listener( + Area::Rect { x: 0.0, y: 0.0, w: 0.4, h: 0.4 }, + PointerTrigger::Enter, + Action::set_bool("moving", true), + ); + let mut inst = sm.instance(); + // Entra pero a una zona fuera del rect del listener. + inst.pointer_move(Some((0.9, 0.9))); + inst.advance(0.3); + assert_eq!(inst.current_state(), "idle", "fuera del área no debe disparar"); + } + + #[test] + fn la_velocidad_escala_el_tiempo() { + let mut sm = StateMachine::new(); + let s = sm.add_state("s", IDLE, 2.0, true); + sm.set_entry(s); + let mut inst = sm.instance(); + inst.advance(1.0); + // speed 2.0 → 1 s real = 2 s de clip. + assert!((inst.render_frame().primary.time_secs - 2.0).abs() < 1e-9); + } +} diff --git a/llimphi-anim/src/physics.rs b/llimphi-anim/src/physics.rs new file mode 100644 index 0000000..93a11b5 --- /dev/null +++ b/llimphi-anim/src/physics.rs @@ -0,0 +1,284 @@ +//! Física de partículas (Verlet) + constraints de distancia, para mover +//! esqueletos con **leyes físicas**: cuerdas, péndulos, ragdolls que caen y se +//! balancean bajo gravedad, con piso y paredes. +//! +//! El patrón es: una cadena de [`Particle`]s unidas por [`Link`]s (distancias +//! rígidas = longitudes de hueso) se integra con gravedad; luego +//! [`pose_chain_from_points`] convierte las posiciones de las partículas en poses +//! de una cadena de huesos, y el skinning deforma la malla. Así la física maneja +//! el esqueleto, no una animación keyframeada. +//! +//! Verlet (en vez de Euler con velocidades explícitas) hace los constraints de +//! distancia triviales y estables: se resuelven por proyección iterativa. + +use kurbo::{Point, Vec2}; + +use crate::skel::{BoneId, Skeleton}; + +/// Una partícula puntual. `inv_mass = 0` la fija (pin); `damping` global la frena. +#[derive(Debug, Clone, Copy)] +pub struct Particle { + pub pos: Point, + pub prev: Point, + pub inv_mass: f64, +} + +impl Particle { + pub fn new(pos: Point, pinned: bool) -> Self { + Self { + pos, + prev: pos, + inv_mass: if pinned { 0.0 } else { 1.0 }, + } + } + pub fn pinned(&self) -> bool { + self.inv_mass == 0.0 + } +} + +/// Restricción de distancia entre dos partículas (un "hueso" rígido). +#[derive(Debug, Clone, Copy)] +pub struct Link { + pub a: usize, + pub b: usize, + pub rest: f64, + /// 0..1: 1 = rígido, <1 = elástico. + pub stiffness: f64, +} + +/// Mundo físico 2D: partículas + links + gravedad + piso/paredes. +#[derive(Debug, Clone)] +pub struct Physics { + pub particles: Vec, + pub links: Vec, + pub gravity: Vec2, + /// Factor de retención de velocidad por step (0..1, ~0.99). + pub damping: f64, + /// Si está, las partículas no bajan de esta `y` (piso). + pub floor_y: Option, + /// Paredes `(x0, x1)` que contienen en x. + pub walls_x: Option<(f64, f64)>, +} + +impl Default for Physics { + fn default() -> Self { + Self { + particles: Vec::new(), + links: Vec::new(), + gravity: Vec2::new(0.0, 980.0), // px/s² hacia abajo (y crece para abajo) + damping: 0.99, + floor_y: None, + walls_x: None, + } + } +} + +impl Physics { + pub fn new() -> Self { + Self::default() + } + + /// Agrega una partícula y devuelve su índice. + pub fn particle(&mut self, pos: Point, pinned: bool) -> usize { + let i = self.particles.len(); + self.particles.push(Particle::new(pos, pinned)); + i + } + + /// Une dos partículas con `rest` = su distancia actual y rigidez total. + pub fn link(&mut self, a: usize, b: usize) { + let rest = (self.particles[b].pos - self.particles[a].pos).hypot(); + self.links.push(Link { a, b, rest, stiffness: 1.0 }); + } + + /// Une dos partículas con una distancia de reposo y rigidez dadas. + pub fn link_with(&mut self, a: usize, b: usize, rest: f64, stiffness: f64) { + self.links.push(Link { a, b, rest, stiffness }); + } + + /// Empuja las partículas lejos de `center` dentro de `radius` (campo de + /// repulsión — p. ej. el cursor barriendo las cuerdas). + pub fn repel(&mut self, center: Point, radius: f64, strength: f64) { + for p in &mut self.particles { + if p.pinned() { + continue; + } + let d = p.pos - center; + let dist = d.hypot(); + if dist < radius && dist > 1e-6 { + let push = (1.0 - dist / radius) * strength; + p.pos += d * (push / dist); + } + } + } + + /// Un paso de simulación: integra (gravedad + inercia) y resuelve los + /// constraints `iterations` veces (más iteraciones = más rígido/estable). + pub fn step(&mut self, dt: f64, iterations: usize) { + let dt2 = dt * dt; + for p in &mut self.particles { + if p.pinned() { + continue; + } + let vel = (p.pos - p.prev) * self.damping; + p.prev = p.pos; + p.pos = p.pos + vel + self.gravity * dt2; + } + for _ in 0..iterations.max(1) { + self.solve_links(); + self.solve_bounds(); + } + } + + fn solve_links(&mut self) { + for k in 0..self.links.len() { + let Link { a, b, rest, stiffness } = self.links[k]; + let pa = self.particles[a].pos; + let pb = self.particles[b].pos; + let wa = self.particles[a].inv_mass; + let wb = self.particles[b].inv_mass; + let wsum = wa + wb; + if wsum < 1e-12 { + continue; + } + let delta = pb - pa; + let d = delta.hypot(); + if d < 1e-9 { + continue; + } + let diff = (d - rest) / d * stiffness; + let corr = delta * diff; + self.particles[a].pos = pa + corr * (wa / wsum); + self.particles[b].pos = pb - corr * (wb / wsum); + } + } + + fn solve_bounds(&mut self) { + for p in &mut self.particles { + if p.pinned() { + continue; + } + if let Some(fy) = self.floor_y { + if p.pos.y > fy { + p.pos.y = fy; + // Fricción: amortigua el deslizamiento horizontal en el piso. + p.prev.x = p.pos.x + (p.prev.x - p.pos.x) * 0.5; + } + } + if let Some((x0, x1)) = self.walls_x { + p.pos.x = p.pos.x.clamp(x0, x1); + } + } + } + + /// Posiciones actuales de las partículas (para construir poses / pintar). + pub fn positions(&self) -> Vec { + self.particles.iter().map(|p| p.pos).collect() + } +} + +/// Posa una cadena de huesos para que siga una cadena de puntos (las partículas +/// físicas). `points` tiene `bones.len() + 1` entradas: el hueso `i` va de +/// `points[i]` a `points[i+1]`. Setea la translación de la raíz a `points[0]` y +/// las rotaciones a los ángulos de cada segmento (relativos en la jerarquía). +/// Llama `update`. Es el puente física → esqueleto. +pub fn pose_chain_from_points(skel: &mut Skeleton, bones: &[BoneId], points: &[Point]) { + if bones.is_empty() || points.len() < bones.len() + 1 { + return; + } + let seg_angle = |i: usize| { + let d = points[i + 1] - points[i]; + if d.hypot() < 1e-9 { + 0.0 + } else { + d.y.atan2(d.x) + } + }; + + // Raíz: su origen va a points[0]; rotación = ángulo del primer segmento. + let root = bones[0]; + let mut rp = skel.pose(root); + rp.translation = points[0].to_vec2(); + rp.rotation = seg_angle(0); + skel.set_pose(root, rp); + + // Eslabones: rotación local = ángulo del segmento − ángulo del anterior + // (la rotación world se acumula por la jerarquía). + for i in 1..bones.len() { + let mut p = skel.pose(bones[i]); + p.rotation = seg_angle(i) - seg_angle(i - 1); + skel.set_pose(bones[i], p); + } + skel.update(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::skel::{Pose, Skeleton}; + + #[test] + fn la_gravedad_hace_caer_una_particula_libre() { + let mut w = Physics::new(); + let i = w.particle(Point::new(0.0, 0.0), false); + w.step(0.1, 1); + assert!(w.particles[i].pos.y > 0.0, "debería caer (y crece)"); + } + + #[test] + fn una_particula_fija_no_se_mueve() { + let mut w = Physics::new(); + let i = w.particle(Point::new(5.0, 5.0), true); + for _ in 0..10 { + w.step(0.1, 2); + } + assert_eq!(w.particles[i].pos, Point::new(5.0, 5.0)); + } + + #[test] + fn un_pendulo_cuelga_a_la_distancia_de_reposo() { + let mut w = Physics::new(); + let anchor = w.particle(Point::new(0.0, 0.0), true); + let bob = w.particle(Point::new(0.0, 50.0), false); + w.link(anchor, bob); // rest = 50 + for _ in 0..400 { + w.step(1.0 / 120.0, 8); + } + // En reposo cuelga recto hacia abajo a ~50 del ancla. + let p = w.particles[bob].pos; + assert!((p.x).abs() < 1.0, "x ~ 0, fue {}", p.x); + assert!((p.y - 50.0).abs() < 1.0, "y ~ 50, fue {}", p.y); + } + + #[test] + fn el_piso_detiene_la_caida() { + let mut w = Physics::new(); + w.floor_y = Some(100.0); + let i = w.particle(Point::new(0.0, 0.0), false); + for _ in 0..200 { + w.step(1.0 / 120.0, 2); + } + assert!(w.particles[i].pos.y <= 100.0 + 1e-6, "no pasa el piso"); + assert!(w.particles[i].pos.y > 90.0, "llegó cerca del piso"); + } + + #[test] + fn pose_chain_sigue_los_puntos() { + // Cadena de 2 huesos; puntos en L vertical-luego-horizontal. + let mut s = Skeleton::new(); + let a = s.add_bone(None, Pose::translate(Vec2::new(0.0, 0.0))); + let b = s.add_bone(Some(a), Pose::translate(Vec2::new(50.0, 0.0))); + s.bind(); + let points = [ + Point::new(0.0, 0.0), + Point::new(0.0, 50.0), // primer segmento apunta hacia abajo (+y) + Point::new(50.0, 50.0), // segundo apunta a la derecha (+x) + ]; + pose_chain_from_points(&mut s, &[a, b], &points); + // El origen del hueso b debe estar en points[1]; su punta (local 50,0) en points[2]. + let b_origin = s.world(b) * Point::ZERO; + assert!((b_origin.x - 0.0).abs() < 1e-6 && (b_origin.y - 50.0).abs() < 1e-6, "{b_origin:?}"); + let tip = s.world(b) * Point::new(50.0, 0.0); + assert!((tip.x - 50.0).abs() < 1e-6 && (tip.y - 50.0).abs() < 1e-6, "tip {tip:?}"); + } +} diff --git a/llimphi-anim/src/skel.rs b/llimphi-anim/src/skel.rs new file mode 100644 index 0000000..7bcf373 --- /dev/null +++ b/llimphi-anim/src/skel.rs @@ -0,0 +1,616 @@ +//! Jerarquía de huesos 2D + skinning ponderado de vértices (linear blend +//! skinning, LBS) — el sustrato de la deformación esqueletal estilo Rive. +//! +//! **Puro y sin renderer.** Este módulo sólo calcula geometría: dada una +//! jerarquía de huesos posada y una malla con pesos por vértice, produce las +//! **posiciones deformadas** de los vértices (`kurbo::Point`). Quién las pinta +//! (vello vía paths para malla vectorial, o clip+imagen para malla texturizada) +//! es trabajo del consumidor; el spike confirmó que ambas rutas funcionan en +//! vello 0.7. +//! +//! ## Bind pose vs pose actual +//! +//! El esqueleto se construye en su **bind pose** (la configuración en la que se +//! ató la malla) y se llama [`Skeleton::bind`] para congelarla: por cada hueso +//! se guarda su *inverse-bind* (el world transform inverso en el bind). Al +//! animar, se cambian las poses locales ([`Skeleton::set_pose`]), +//! [`Skeleton::update`] recompone los world transforms, y la matriz de skinning +//! de un hueso es `world_actual · inverse_bind`. En el bind pose esa matriz es +//! la identidad → los vértices quedan en reposo. +//! +//! ## LBS +//! +//! La posición deformada de un vértice es la media ponderada, sobre los huesos +//! que lo influyen, de aplicarle a su posición de reposo la matriz de skinning +//! de cada hueso: `v' = Σ_b w_b · (skin_b · v_rest)`. + +use std::collections::HashMap; + +use kurbo::{Affine, Point, Vec2}; + +use crate::RenderFrame; + +/// Índice de un hueso dentro del esqueleto. +pub type BoneId = usize; + +/// Pose local de un hueso (TRS) relativa a su padre. Se compone como +/// `translate · rotate · scale` — el orden estándar. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Pose { + pub translation: Vec2, + /// Rotación en radianes (convención de kurbo). + pub rotation: f64, + pub scale: Vec2, +} + +impl Default for Pose { + fn default() -> Self { + Self::identity() + } +} + +impl Pose { + pub fn identity() -> Self { + Self { + translation: Vec2::ZERO, + rotation: 0.0, + scale: Vec2::new(1.0, 1.0), + } + } + pub fn translate(t: Vec2) -> Self { + Self { + translation: t, + ..Self::identity() + } + } + pub fn rotate(r: f64) -> Self { + Self { + rotation: r, + ..Self::identity() + } + } + /// Translación + rotación (lo más común al animar un hueso). + pub fn new(translation: Vec2, rotation: f64, scale: Vec2) -> Self { + Self { + translation, + rotation, + scale, + } + } + pub fn to_affine(&self) -> Affine { + Affine::translate(self.translation) + * Affine::rotate(self.rotation) + * Affine::scale_non_uniform(self.scale.x, self.scale.y) + } +} + +#[derive(Debug, Clone)] +struct Bone { + parent: Option, + local: Pose, +} + +/// Una jerarquía de huesos posable. Los huesos se agregan **padre antes que +/// hijo** (el índice del padre debe ser menor), así el cómputo de world +/// transforms es una sola pasada hacia adelante. +#[derive(Debug, Clone, Default)] +pub struct Skeleton { + bones: Vec, + /// World transform inverso de cada hueso, capturado en `bind`. + inverse_bind: Vec, + /// Scratch: world transforms actuales (recomputados por `update`). + world: Vec, +} + +impl Skeleton { + pub fn new() -> Self { + Self::default() + } + + /// Agrega un hueso con `parent` (o `None` = raíz) y su pose local. Devuelve + /// su `BoneId`. **Pánico** si el padre no fue agregado antes (índice ≥ id). + pub fn add_bone(&mut self, parent: Option, local: Pose) -> BoneId { + if let Some(p) = parent { + assert!( + p < self.bones.len(), + "el padre {p} debe agregarse antes que el hijo" + ); + } + let id = self.bones.len(); + self.bones.push(Bone { parent, local }); + self.inverse_bind.push(Affine::IDENTITY); + self.world.push(Affine::IDENTITY); + id + } + + fn recompute_world(&mut self) { + for i in 0..self.bones.len() { + let local = self.bones[i].local.to_affine(); + self.world[i] = match self.bones[i].parent { + Some(p) => self.world[p] * local, + None => local, + }; + } + } + + /// Congela la pose actual como **bind pose**: recompone los world transforms + /// y guarda el inverse-bind de cada hueso. Llamar una vez tras construir el + /// esqueleto en reposo (antes de animar). + pub fn bind(&mut self) { + self.recompute_world(); + for i in 0..self.bones.len() { + self.inverse_bind[i] = self.world[i].inverse(); + } + } + + /// Cambia la pose local de un hueso (animar). Requiere [`update`] después + /// para que los world transforms reflejen el cambio. + /// + /// [`update`]: Skeleton::update + pub fn set_pose(&mut self, bone: BoneId, local: Pose) { + self.bones[bone].local = local; + } + + /// Pose local actual de un hueso. + pub fn pose(&self, bone: BoneId) -> Pose { + self.bones[bone].local + } + + /// Recalcula los world transforms desde las poses actuales. Llamar tras + /// posear y antes de deformar/leer `world`/`skin_matrix`. + pub fn update(&mut self) { + self.recompute_world(); + } + + /// Matriz de skinning del hueso: `world_actual · inverse_bind`. En el bind + /// pose es la identidad. + pub fn skin_matrix(&self, bone: BoneId) -> Affine { + self.world[bone] * self.inverse_bind[bone] + } + + /// World transform actual del hueso (útil para dibujar el hueso mismo o + /// adjuntar algo a su punta). + pub fn world(&self, bone: BoneId) -> Affine { + self.world[bone] + } + + /// Padre de un hueso (`None` si es raíz). Lo usan los constraints para + /// convertir una rotación deseada en mundo a la pose local. + pub fn parent(&self, bone: BoneId) -> Option { + self.bones[bone].parent + } + + pub fn len(&self) -> usize { + self.bones.len() + } + pub fn is_empty(&self) -> bool { + self.bones.is_empty() + } +} + +/// Influencia de un hueso sobre un vértice. Idealmente los pesos de un vértice +/// suman 1; si no, `deform` normaliza. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Weight { + pub bone: BoneId, + pub weight: f64, +} + +/// Un vértice de la malla: su posición de reposo (en model/bind space), su UV +/// `0..1` (para textura, lo usa el render de malla texturizada) y los huesos que +/// lo influyen. +#[derive(Debug, Clone)] +pub struct Vertex { + pub rest: Point, + pub uv: (f64, f64), + pub weights: Vec, +} + +impl Vertex { + /// Vértice rígido a un solo hueso (peso 1). + pub fn rigid(rest: Point, uv: (f64, f64), bone: BoneId) -> Self { + Self { + rest, + uv, + weights: vec![Weight { bone, weight: 1.0 }], + } + } +} + +/// Una malla deformable: vértices con pesos + topología de triángulos (índices +/// a `vertices`). El render la consume tras `deform`. +#[derive(Debug, Clone, Default)] +pub struct Mesh { + pub vertices: Vec, + pub triangles: Vec<[u32; 3]>, +} + +impl Mesh { + pub fn new() -> Self { + Self::default() + } + + /// Calcula las posiciones deformadas (LBS) en `out`, una por vértice, en el + /// mismo orden que `self.vertices`. `skel` debe tener sus world transforms + /// actualizados ([`Skeleton::update`]). Reutiliza el buffer `out` (sin + /// asignar por frame). + pub fn deform_into(&self, skel: &Skeleton, out: &mut Vec) { + out.clear(); + out.reserve(self.vertices.len()); + for v in &self.vertices { + if v.weights.is_empty() { + out.push(v.rest); + continue; + } + let mut acc = Vec2::ZERO; + let mut wsum = 0.0; + for w in &v.weights { + let p = skel.skin_matrix(w.bone) * v.rest; + acc += p.to_vec2() * w.weight; + wsum += w.weight; + } + let p = if wsum.abs() > 1e-12 { + (acc * (1.0 / wsum)).to_point() + } else { + v.rest + }; + out.push(p); + } + } + + /// Variante que asigna y devuelve el `Vec` (conveniencia para tests/uso + /// ocasional; en el bucle de render preferí [`deform_into`]). + /// + /// [`deform_into`]: Mesh::deform_into + pub fn deform(&self, skel: &Skeleton) -> Vec { + let mut out = Vec::new(); + self.deform_into(skel, &mut out); + out + } +} + +/// Interpola linealmente dos poses (translación/escala lerp, rotación lerp del +/// ángulo). Es el blend de poses que usa el crossfade esqueletal — mezclar +/// *poses* da una deformación correcta, a diferencia del crossfade de píxeles. +pub fn lerp_pose(a: Pose, b: Pose, f: f64) -> Pose { + let lerp = |x: f64, y: f64| x + (y - x) * f; + Pose { + translation: Vec2::new( + lerp(a.translation.x, b.translation.x), + lerp(a.translation.y, b.translation.y), + ), + rotation: lerp(a.rotation, b.rotation), + scale: Vec2::new(lerp(a.scale.x, b.scale.x), lerp(a.scale.y, b.scale.y)), + } +} + +/// Keyframe de pose de un hueso en un instante. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PoseKey { + pub t: f64, + pub pose: Pose, +} + +/// Pista de animación de un hueso: keyframes de pose ordenados por tiempo. +#[derive(Debug, Clone, Default)] +pub struct BoneTrack { + pub bone: BoneId, + pub keys: Vec, +} + +impl BoneTrack { + /// Pose en el instante `t` (lineal entre keyframes, clamp en los extremos). + pub fn sample(&self, t: f64) -> Pose { + match self.keys.as_slice() { + [] => Pose::identity(), + [only] => only.pose, + keys => { + if t <= keys[0].t { + return keys[0].pose; + } + let last = keys[keys.len() - 1]; + if t >= last.t { + return last.pose; + } + for w in keys.windows(2) { + let (a, b) = (w[0], w[1]); + if t >= a.t && t <= b.t { + let span = b.t - a.t; + let f = if span > 1e-12 { (t - a.t) / span } else { 0.0 }; + return lerp_pose(a.pose, b.pose, f); + } + } + last.pose + } + } + } +} + +/// Una animación esqueletal: pistas de pose por hueso, con duración y loop. Es +/// lo que un *estado* de la máquina reproduce (un clip = una `BoneAnimation`). +/// Los huesos no incluidos en ninguna pista conservan su pose actual. +#[derive(Debug, Clone, Default)] +pub struct BoneAnimation { + pub duration: f64, + pub looping: bool, + pub tracks: Vec, +} + +impl BoneAnimation { + /// Samplea todas las pistas en `t` (envuelto por la duración si es loop) a + /// pares `(hueso, pose)`, reusando el buffer `out`. + pub fn sample_into(&self, t: f64, out: &mut Vec<(BoneId, Pose)>) { + let tt = if self.looping && self.duration > 0.0 { + t.rem_euclid(self.duration) + } else { + t + }; + out.clear(); + out.extend(self.tracks.iter().map(|tr| (tr.bone, tr.sample(tt)))); + } + + /// Posa el esqueleto con esta animación en el instante `t` y llama `update`. + pub fn apply(&self, skel: &mut Skeleton, t: f64) { + let mut buf = Vec::new(); + self.sample_into(t, &mut buf); + for (b, p) in buf { + skel.set_pose(b, p); + } + skel.update(); + } +} + +/// Posa el esqueleto según el [`RenderFrame`] de una máquina de estados, usando +/// las `clips` ([`BoneAnimation`]) indexadas por `ClipId`. Durante una +/// transición blendea **poses** (no píxeles) por la mezcla del frame. Llama +/// `skel.update()` al final. Es el puente que une máquina-de-estados → +/// esqueleto: estado/transición deciden la pose, el skinning la convierte en +/// deformación. +/// +/// [`RenderFrame`]: crate::RenderFrame +pub fn pose_from_render_frame(skel: &mut Skeleton, frame: &RenderFrame, clips: &[BoneAnimation]) { + let mut poses: HashMap = HashMap::new(); + let mut buf = Vec::new(); + if let Some(a) = clips.get(frame.primary.clip as usize) { + a.sample_into(frame.primary.time_secs, &mut buf); + for (b, p) in buf.drain(..) { + poses.insert(b, p); + } + } + if let Some((inc, mix)) = frame.blend { + if let Some(a) = clips.get(inc.clip as usize) { + a.sample_into(inc.time_secs, &mut buf); + let mix = mix as f64; + for (b, p) in buf.drain(..) { + poses + .entry(b) + .and_modify(|cur| *cur = lerp_pose(*cur, p, mix)) + .or_insert(p); + } + } + } + for (b, p) in poses { + skel.set_pose(b, p); + } + skel.update(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn approx(a: Point, x: f64, y: f64) -> bool { + (a.x - x).abs() < 1e-9 && (a.y - y).abs() < 1e-9 + } + + #[test] + fn bind_pose_deja_los_vertices_en_reposo() { + let mut s = Skeleton::new(); + let b = s.add_bone(None, Pose::translate(Vec2::new(5.0, 7.0))); + s.bind(); + s.update(); + let mut m = Mesh::new(); + m.vertices.push(Vertex::rigid(Point::new(3.0, 4.0), (0.0, 0.0), b)); + let d = m.deform(&s); + // Sin re-posar: la matriz de skinning es identidad → reposo intacto. + assert!(approx(d[0], 3.0, 4.0), "fue {:?}", d[0]); + } + + #[test] + fn rotar_un_hueso_rota_su_vertice() { + let mut s = Skeleton::new(); + let b = s.add_bone(None, Pose::identity()); + s.bind(); + // Rotar 90°: (10,0) → (0,10) en convención de kurbo. + s.set_pose(b, Pose::rotate(std::f64::consts::FRAC_PI_2)); + s.update(); + let mut m = Mesh::new(); + m.vertices.push(Vertex::rigid(Point::new(10.0, 0.0), (0.0, 0.0), b)); + let d = m.deform(&s); + assert!(approx(d[0], 0.0, 10.0), "fue {:?}", d[0]); + } + + #[test] + fn cadena_padre_hijo_compone_transforms() { + let mut s = Skeleton::new(); + let root = s.add_bone(None, Pose::identity()); + let child = s.add_bone(Some(root), Pose::translate(Vec2::new(10.0, 0.0))); + s.bind(); + // Rotar la raíz 90° rota rígidamente al hijo y su vértice. + s.set_pose(root, Pose::rotate(std::f64::consts::FRAC_PI_2)); + s.update(); + let mut m = Mesh::new(); + m.vertices + .push(Vertex::rigid(Point::new(20.0, 0.0), (0.0, 0.0), child)); + let d = m.deform(&s); + // (20,0) rotado 90° sobre el origen → (0,20). + assert!(approx(d[0], 0.0, 20.0), "fue {:?}", d[0]); + } + + #[test] + fn peso_repartido_mezcla_dos_huesos() { + let mut s = Skeleton::new(); + let a = s.add_bone(None, Pose::identity()); + let b = s.add_bone(None, Pose::identity()); + s.bind(); + // A queda quieto; B se traslada (0,20). + s.set_pose(b, Pose::translate(Vec2::new(0.0, 20.0))); + s.update(); + let mut m = Mesh::new(); + m.vertices.push(Vertex { + rest: Point::new(0.0, 0.0), + uv: (0.5, 0.5), + weights: vec![ + Weight { bone: a, weight: 0.5 }, + Weight { bone: b, weight: 0.5 }, + ], + }); + let d = m.deform(&s); + // 0.5·(0,0) + 0.5·(0,20) = (0,10). + assert!(approx(d[0], 0.0, 10.0), "fue {:?}", d[0]); + } + + #[test] + fn pesos_sin_normalizar_se_normalizan() { + let mut s = Skeleton::new(); + let a = s.add_bone(None, Pose::identity()); + let b = s.add_bone(None, Pose::translate(Vec2::new(0.0, 10.0))); + s.bind(); + s.update(); + let mut m = Mesh::new(); + // Pesos 2 y 2 (suman 4) → media ponderada, no suma cruda. + m.vertices.push(Vertex { + rest: Point::new(0.0, 0.0), + uv: (0.0, 0.0), + weights: vec![ + Weight { bone: a, weight: 2.0 }, + Weight { bone: b, weight: 2.0 }, + ], + }); + let d = m.deform(&s); + // skin_a = id → (0,0); skin_b = id (b en bind pose, no re-posado) → (0,0). + // Ambos en reposo: (0,0). (Verifica que no explota por wsum=4.) + assert!(approx(d[0], 0.0, 0.0), "fue {:?}", d[0]); + } + + #[test] + fn vertice_sin_pesos_queda_en_reposo() { + let mut s = Skeleton::new(); + let _b = s.add_bone(None, Pose::rotate(1.0)); + s.bind(); + s.update(); + let mut m = Mesh::new(); + m.vertices.push(Vertex { + rest: Point::new(7.0, 7.0), + uv: (0.0, 0.0), + weights: vec![], + }); + let d = m.deform(&s); + assert!(approx(d[0], 7.0, 7.0), "fue {:?}", d[0]); + } + + #[test] + fn track_samplea_lineal_entre_keyframes() { + let tr = BoneTrack { + bone: 0, + keys: vec![ + PoseKey { t: 0.0, pose: Pose::rotate(0.0) }, + PoseKey { t: 2.0, pose: Pose::rotate(2.0) }, + ], + }; + assert!((tr.sample(0.0).rotation - 0.0).abs() < 1e-9); + assert!((tr.sample(2.0).rotation - 2.0).abs() < 1e-9); + assert!((tr.sample(1.0).rotation - 1.0).abs() < 1e-9, "punto medio"); + // Clamp fuera de rango. + assert!((tr.sample(-5.0).rotation - 0.0).abs() < 1e-9); + assert!((tr.sample(99.0).rotation - 2.0).abs() < 1e-9); + } + + #[test] + fn animacion_en_loop_envuelve_el_tiempo() { + let anim = BoneAnimation { + duration: 2.0, + looping: true, + tracks: vec![BoneTrack { + bone: 0, + keys: vec![ + PoseKey { t: 0.0, pose: Pose::rotate(0.0) }, + PoseKey { t: 2.0, pose: Pose::rotate(2.0) }, + ], + }], + }; + let mut buf = Vec::new(); + anim.sample_into(3.0, &mut buf); // 3 % 2 = 1 → rotación ~1.0 + assert!((buf[0].1.rotation - 1.0).abs() < 1e-9, "fue {}", buf[0].1.rotation); + } + + #[test] + fn render_frame_simple_posa_el_esqueleto() { + let mut s = Skeleton::new(); + let b = s.add_bone(None, Pose::identity()); + s.bind(); + let anim = BoneAnimation { + duration: 2.0, + looping: true, + tracks: vec![BoneTrack { + bone: b, + keys: vec![ + PoseKey { t: 0.0, pose: Pose::identity() }, + PoseKey { + t: 1.0, + pose: Pose::rotate(std::f64::consts::FRAC_PI_2), + }, + ], + }], + }; + let clips = vec![anim]; + let frame = crate::RenderFrame { + primary: crate::ClipSample { clip: 0, time_secs: 1.0 }, + blend: None, + }; + pose_from_render_frame(&mut s, &frame, &clips); + let mut m = Mesh::new(); + m.vertices.push(Vertex::rigid(Point::new(10.0, 0.0), (0.0, 0.0), b)); + let d = m.deform(&s); + assert!(approx(d[0], 0.0, 10.0), "fue {:?}", d[0]); + } + + #[test] + fn render_frame_con_blend_mezcla_poses() { + let mut s = Skeleton::new(); + let b = s.add_bone(None, Pose::identity()); + s.bind(); + // clip0: identidad; clip1: 90°. Blend 0.5 → 45°. + let rest = BoneAnimation { + duration: 1.0, + looping: true, + tracks: vec![BoneTrack { + bone: b, + keys: vec![PoseKey { t: 0.0, pose: Pose::identity() }], + }], + }; + let turn = BoneAnimation { + duration: 1.0, + looping: true, + tracks: vec![BoneTrack { + bone: b, + keys: vec![PoseKey { + t: 0.0, + pose: Pose::rotate(std::f64::consts::FRAC_PI_2), + }], + }], + }; + let clips = vec![rest, turn]; + let frame = crate::RenderFrame { + primary: crate::ClipSample { clip: 0, time_secs: 0.0 }, + blend: Some((crate::ClipSample { clip: 1, time_secs: 0.0 }, 0.5)), + }; + pose_from_render_frame(&mut s, &frame, &clips); + let mut m = Mesh::new(); + m.vertices.push(Vertex::rigid(Point::new(10.0, 0.0), (0.0, 0.0), b)); + let d = m.deform(&s); + // 10·(cos45, sin45) ≈ (7.071, 7.071). + let c = 10.0 * std::f64::consts::FRAC_1_SQRT_2; + assert!((d[0].x - c).abs() < 1e-9 && (d[0].y - c).abs() < 1e-9, "fue {:?}", d[0]); + } +} diff --git a/llimphi-compositor/Cargo.toml b/llimphi-compositor/Cargo.toml index 78e0fe2..0a4431c 100644 --- a/llimphi-compositor/Cargo.toml +++ b/llimphi-compositor/Cargo.toml @@ -5,9 +5,9 @@ edition.workspace = true license.workspace = true authors.workspace = true publish.workspace = true -repository.workspace = true description = "llimphi-compositor — el núcleo declarativo de Llimphi sin winit: el árbol `View`, el mount sobre taffy, el paint a `vello::Scene` y el hit-test. No depende de llimphi-hal ni de una surface concreta, así que la misma composición sirve sobre winit (llimphi-ui) o, a futuro, sobre el framebuffer del kernel wawa. `wgpu` entra sólo por la firma de `GpuPaintFn` (tipos, no windowing)." +repository.workspace = true [dependencies] llimphi-layout = { path = "../llimphi-layout", version = "0.1.0" } llimphi-text = { path = "../llimphi-text", version = "0.1.0" } @@ -19,17 +19,17 @@ wgpu = { workspace = true } [dev-dependencies] # Volcado headless del paint a PNG (llvmpipe) para VER sombra/gradiente/borde # sin levantar ventana. Sólo capas inferiores — no llimphi-ui (sin ciclo). -llimphi-hal = { path = "../llimphi-hal" } -llimphi-raster = { path = "../llimphi-raster" } -llimphi-theme = { path = "../llimphi-theme" } +llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" } +llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" } +llimphi-theme = { path = "../llimphi-theme", version = "0.1.0" } png = { workspace = true } pollster = { workspace = true } # Widgets REALES para el showreel: el `View` que devuelven es el mismo # tipo (`llimphi_compositor::View`, re-exportado por llimphi-ui), así que # montan con el mount/paint/measure de este mismo crate. Dev-only → sin ciclo # en el grafo normal (llimphi-ui depende de este crate, no al revés). -llimphi-widget-switch = { path = "../widgets/switch" } -llimphi-widget-slider = { path = "../widgets/slider" } -llimphi-widget-progress = { path = "../widgets/progress" } -llimphi-widget-button = { path = "../widgets/button" } -llimphi-widget-segmented = { path = "../widgets/segmented" } +llimphi-widget-switch = { path = "../widgets/switch", version = "0.1.0" } +llimphi-widget-slider = { path = "../widgets/slider", version = "0.1.0" } +llimphi-widget-progress = { path = "../widgets/progress", version = "0.1.0" } +llimphi-widget-button = { path = "../widgets/button", version = "0.1.0" } +llimphi-widget-segmented = { path = "../widgets/segmented", version = "0.1.0" } diff --git a/llimphi-compositor/LEEME.md b/llimphi-compositor/LEEME.md new file mode 100644 index 0000000..97e6542 --- /dev/null +++ b/llimphi-compositor/LEEME.md @@ -0,0 +1,23 @@ +# llimphi-compositor + +*Read this in English: [README.md](README.md).* + +El núcleo declarativo de Llimphi, sin winit. + +Aquí vive el árbol de vista `View` (DSL declarativo), su instalación +sobre taffy (`mount`), el pintado a `vello::Scene` (`paint`/`paint_gpu`) y +el hit-test. Nada de esto necesita una ventana ni `llimphi-hal`: la +composición `view → layout → scene` es pura y reutilizable. + +El runtime que la maneja vive aparte: +- `llimphi-ui` la corre sobre winit (`run()`). +- a futuro, un runtime sobre el framebuffer del kernel `wawa` puede + reusar exactamente este compositor sin arrastrar winit. + +`wgpu` entra sólo por la firma de `GpuPaintFn` (tipos de Device/Queue/ +Encoder/TextureView); `wgpu` no depende de winit, así que el compositor +sigue libre de windowing. + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-compositor/README.md b/llimphi-compositor/README.md new file mode 100644 index 0000000..c0ad39b --- /dev/null +++ b/llimphi-compositor/README.md @@ -0,0 +1,22 @@ +# llimphi-compositor + +Llimphi's declarative core, without winit. + +Here live the `View` view tree (the declarative DSL), its installation over +taffy (`mount`), the painting to a `vello::Scene` (`paint`/`paint_gpu`) and +hit-testing. None of this needs a window or `llimphi-hal`: the +`view → layout → scene` composition is pure and reusable. + +The runtime that drives it lives apart: + +- `llimphi-ui` runs it over winit (`run()`). +- in the future, a runtime over the `wawa` kernel's framebuffer can reuse exactly + this compositor without dragging winit along. + +`wgpu` enters only through the signature of `GpuPaintFn` (Device/Queue/Encoder/ +TextureView types); `wgpu` does not depend on winit, so the compositor stays free +of windowing. + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-compositor/examples/animated_size_demo.rs b/llimphi-compositor/examples/animated_size_demo.rs index 04872e0..9d1bbb3 100644 --- a/llimphi-compositor/examples/animated_size_demo.rs +++ b/llimphi-compositor/examples/animated_size_demo.rs @@ -7,7 +7,7 @@ //! //! Verifica que el camino `reconcile_size_anim` parcha `style.size` //! ANTES del mount/compute, así el layout cascade ve el tamaño -//! interpolado y los siblings reflowean (acá el padre es un row con +//! interpolado y los siblings reflowean (aquí el padre es un row con //! `gap`; el segundo hijo se va corriendo según crece el primero). //! //! `cargo run -p llimphi-compositor --example animated_size_demo -- [out.png]` diff --git a/llimphi-compositor/examples/pantallazo_button.rs b/llimphi-compositor/examples/pantallazo_button.rs new file mode 100644 index 0000000..5468213 --- /dev/null +++ b/llimphi-compositor/examples/pantallazo_button.rs @@ -0,0 +1,210 @@ +//! Pantallazo headless del `llimphi-widget-button` para VER el relieve nuevo +//! (sombra + borde hairline + sheen + specular) en varios themes claros y +//! oscuros, sin levantar ventana. +//! +//! `cargo run -p llimphi-compositor --example pantallazo_button --release -- [out.png]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_compositor::{measure_text_node, mount, paint, View}; +use llimphi_hal::{wgpu, Hal}; +use llimphi_layout::taffy; +use llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style}; +use llimphi_layout::taffy::{AlignItems, JustifyContent, Rect}; +use llimphi_layout::LayoutTree; +use llimphi_raster::peniko::Color; +use llimphi_raster::{vello, Renderer}; +use llimphi_text::{Alignment, Typesetter}; +use llimphi_widget_button::{button_styled, ButtonPalette}; + +const W: u32 = 1120; +const H: u32 = 620; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn mix(a: Color, b: Color, t: f32) -> Color { + let (ca, cb) = (a.components, b.components); + Color { + components: [ + ca[0] + (cb[0] - ca[0]) * t, + ca[1] + (cb[1] - ca[1]) * t, + ca[2] + (cb[2] - ca[2]) * t, + ca[3], + ], + ..a + } +} + +/// Un botón de ancho fijo dentro de una caja (button_view es 100% de ancho). +fn chip(label: &str, pal: &ButtonPalette) -> View<()> { + let inner = button_styled( + label, + Style { + size: Size { width: percent(1.0_f32), height: length(34.0_f32) }, + padding: Rect { left: length(14.0_f32), right: length(14.0_f32), top: length(0.0_f32), bottom: length(0.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }, + Alignment::Center, + pal, + (), + ); + View::<()>::new(Style { + size: Size { width: length(150.0_f32), height: length(34.0_f32) }, + ..Default::default() + }) + .children(vec![inner]) +} + +/// Fila de una demo por theme: fondo bg_app + nombre + 3 botones +/// (default · hover-preview · primario accent). +fn fila_theme(nombre: &str) -> View<()> { + let theme = llimphi_theme::Theme::by_name(nombre).expect("theme"); + let default_pal = ButtonPalette::from_theme(&theme); + // Preview del estado hover: una paleta cuyo bg es el bg_button_hover. + let mut hover_pal = default_pal; + hover_pal.bg = theme.bg_button_hover; + // Primario: fondo = accent, texto que contraste, radio un toque mayor. + let white = Color::from_rgba8(255, 255, 255, 255); + let black = Color::from_rgba8(0, 0, 0, 255); + let lum = { + let k = theme.accent.components; + 0.2126 * k[0] + 0.7152 * k[1] + 0.0722 * k[2] + }; + let primary_pal = ButtonPalette { + bg: theme.accent, + bg_hover: mix(theme.accent, white, 0.12), + fg: if lum < 0.55 { white } else { black }, + radius: 7.0, + }; + + let etiqueta = View::<()>::new(Style { + size: Size { width: length(120.0_f32), height: length(34.0_f32) }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(nombre.to_string(), 13.0, theme.fg_muted, Alignment::Start); + + View::<()>::new(Style { + size: Size { width: percent(1.0_f32), height: length(72.0_f32) }, + flex_direction: FlexDirection::Row, + align_items: Some(AlignItems::Center), + gap: Size { width: length(16.0_f32), height: length(0.0_f32) }, + padding: Rect { left: length(24.0_f32), right: length(24.0_f32), top: length(0.0_f32), bottom: length(0.0_f32) }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![ + etiqueta, + chip("Guardar", &default_pal), + chip("Guardar", &hover_pal), + chip("Aceptar", &primary_pal), + ]) +} + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "pantallazo_button.png".to_string()); + let temas = ["Dark", "Light", "Tawa", "macOS", "Sunset", "Breeze", "Print", "Aurora"]; + let filas: Vec> = temas.iter().map(|t| fila_theme(t)).collect(); + + let root = View::<()>::new(Style { + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + flex_direction: FlexDirection::Column, + ..Default::default() + }) + .fill(Color::from_rgba8(20, 20, 24, 255)) + .children(filas); + + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, root); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("pantallazo-button"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 24, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out); + eprintln!("pantallazo_button: escrito {out} ({W}x{H})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/llimphi-compositor/examples/ripple_demo.rs b/llimphi-compositor/examples/ripple_demo.rs index 9c7cc52..3a9df09 100644 --- a/llimphi-compositor/examples/ripple_demo.rs +++ b/llimphi-compositor/examples/ripple_demo.rs @@ -7,7 +7,7 @@ //! //! Prueba el camino `View::ripple` → `RippleRegistry::trigger`/`paint` → //! `node_rrect` (clip) → píxeles, sin runtime ni winit. El press real lo -//! sintetiza el runtime (`llimphi-ui`); acá lo emulamos llamando `trigger`. +//! sintetiza el runtime (`llimphi-ui`); aquí lo emulamos llamando `trigger`. //! //! `cargo run -p llimphi-compositor --example ripple_demo -- [out.png]` @@ -85,7 +85,7 @@ fn main() { let mounted = mount(&mut layout, root); let computed = layout.compute(mounted.root, (W as f32, H as f32)).expect("layout"); - // Pintá los botones, luego superponé una salpicadura por columna observada + // Pinta los botones, luego superpón una salpicadura por columna observada // a un progreso creciente (cada registro disparó en t0, se observa a // t0 + paso·i). Todas escriben en la misma escena. let mut ts = Typesetter::new(); diff --git a/llimphi-compositor/src/anim.rs b/llimphi-compositor/src/anim.rs index 474224b..3990b03 100644 --- a/llimphi-compositor/src/anim.rs +++ b/llimphi-compositor/src/anim.rs @@ -65,7 +65,7 @@ pub struct Anim { pub easing: fn(f32) -> f32, /// `true` si la **primera aparición** de la key debe animar la opacidad de /// 0 hacia su valor (fade-in de entrada, estilo `AnimatedSwitcher`). Las - /// animaciones de props (fill/radius/alpha) no entran por acá: sólo cambian + /// animaciones de props (fill/radius/alpha) no entran por aquí: sólo cambian /// el arranque del primer frame. Sin él, la primera aparición se asienta /// instantánea (default histórico de `View::animated`). pub enter: bool, @@ -90,6 +90,60 @@ pub struct Anim { /// la combinación enter+exit de dos keys. Implica captura `live` por frame /// (como `exit`). La primera aparición no cruza (sólo asienta la variante). pub switch: Option, + /// Transformación afín hacia la que **sale el contenido saliente** mientras + /// se desvanece (fantasma): se interpola de identidad a esta afín, aplicada + /// alrededor del centro del rect capturado (misma convención que + /// `View::transform`). `Some(Affine::translate((-w, 0)))` = slide-out a la + /// izquierda; `Some(Affine::scale(1.05))` = zoom-out. Con `exit`, describe + /// cómo sale **este nodo** al desaparecer (capturada mientras vive). Con + /// `switch`, describe cómo sale el **contenido viejo** al cambiar la + /// variante — se lee del frame NUEVO (quien conmuta conoce la dirección: + /// push vs pop). `None` = fade en el lugar (comportamiento histórico). + pub exit_to_xf: Option, + /// `false` = el contenido nuevo de un `switch` NO arranca transparente + /// (entra opaco — necesario para un push estilo UIKit donde la página + /// entrante tapa a la saliente). `true` = fade-in histórico. + pub enter_fade: bool, + /// `true` = el fantasma del contenido saliente se pinta **DEBAJO** del + /// contenido nuevo, en la posición z del nodo (vía + /// [`AnimRegistry::prepare_underlays`] + `MountedNode::underlay`), en vez + /// de encima de toda la escena. Es el orden natural de una transición de + /// páginas: lo nuevo cubre a lo viejo. Como `exit_to_xf`, en un `switch` + /// lo declara el frame NUEVO. + pub exit_under: bool, + /// `true` = el fantasma NO se desvanece: mantiene alpha 1 hasta que su + /// reloj expira (y ahí desaparece de golpe — para entonces el contenido + /// nuevo ya lo cubre). Para salidas opacas (Cover/push UIKit). + pub exit_hold: bool, +} + +/// Estilo completo de una transición de `switch` direccional — agrupa los +/// cinco knobs que [`crate::View::animated_switch_styled`] pasa al [`Anim`]. +/// `Default` = cross-fade puro (idéntico a `animated_switch`). +#[derive(Clone, Copy, Debug)] +pub struct SwitchStyle { + /// Afín desde la que entra el contenido nuevo (`None` = en el lugar). + pub enter_from_xf: Option, + /// `false` = el contenido nuevo entra opaco (sin fade-in). + pub enter_fade: bool, + /// Afín hacia la que sale el contenido viejo (`None` = en el lugar). + pub exit_to_xf: Option, + /// `true` = el fantasma va DEBAJO del contenido nuevo (posición z real). + pub exit_under: bool, + /// `true` = el fantasma no se desvanece (salida opaca). + pub exit_hold: bool, +} + +impl Default for SwitchStyle { + fn default() -> Self { + Self { + enter_from_xf: None, + enter_fade: true, + exit_to_xf: None, + exit_under: false, + exit_hold: false, + } + } } /// Ease-out cúbico, el default razonable para transiciones implícitas @@ -257,8 +311,8 @@ fn reconcile_size_anim_inner( .entry(sa.key) .or_insert_with(|| SizeAnimEntry::settled(target, now, sa.duration, sa.easing)); if entry.to != target { - // Cambió el objetivo: congelá el valor actual como nuevo - // origen y rearrancá el reloj — mismo patrón que el + // Cambió el objetivo: congela el valor actual como nuevo + // origen y rearranca el reloj — mismo patrón que el // `AnimRegistry` de props. entry.from = entry.value(now); entry.to = target; @@ -421,26 +475,59 @@ struct LiveExit { scene: Scene, duration: Duration, easing: fn(f32) -> f32, + /// Centro del rect del nodo al capturarse — origen de la afín de salida. + centro: (f32, f32), + /// Afín de salida declarada por el propio nodo (`Anim::exit_to_xf`). + to_xf: Option, + /// El fantasma va debajo del contenido nuevo (`Anim::exit_under`). + under: bool, + /// El fantasma no se desvanece (`Anim::exit_hold`). + hold: bool, } /// Un nodo que ya salió del árbol y se está desvaneciendo: su subescena retenida -/// + el reloj de fade-out. +/// + el reloj de fade-out (y, opcionalmente, la afín hacia la que se desliza). struct Ghost { scene: Scene, start: Instant, duration: Duration, easing: fn(f32) -> f32, + centro: (f32, f32), + to_xf: Option, + under: bool, + hold: bool, } impl Ghost { - /// Opacidad actual del fantasma: `1 → 0` con easing aplicado. + /// Opacidad actual del fantasma: `1 → 0` con easing aplicado — salvo con + /// `hold`, que la mantiene en 1 hasta que el reloj expira (salida opaca). fn alpha(&self, now: Instant) -> f32 { + if self.hold { + return if self.done(now) { 0.0 } else { 1.0 }; + } + 1.0 - self.t(now) + } + + /// Progreso eased `0 → 1` del fantasma. + fn t(&self, now: Instant) -> f32 { if self.duration.is_zero() { - return 0.0; + return 1.0; } let elapsed = now.saturating_duration_since(self.start).as_secs_f32(); let raw = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0); - 1.0 - (self.easing)(raw) + (self.easing)(raw) + } + + /// Afín del frame: identidad → `to_xf`, conjugada alrededor del centro del + /// rect capturado (misma convención que `View::transform`, así un scale + /// "respira" desde su centro y no desde el origen de la ventana). `None` + /// si el fantasma sólo se desvanece en el lugar. + fn xf(&self, now: Instant) -> Option { + let to = self.to_xf?; + let t = self.t(now); + let local = lerp_affine(Affine::IDENTITY, to, t); + let (cx, cy) = (self.centro.0 as f64, self.centro.1 as f64); + Some(Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy))) } fn done(&self, now: Instant) -> bool { @@ -463,6 +550,10 @@ pub struct AnimRegistry { /// Última variante vista por cada key con `switch` — para detectar el /// cambio de contenido que dispara el cross-fade. variants: HashMap, + /// Keys colocadas como underlay ESTE frame (por + /// [`Self::prepare_underlays`]) — `replay_ghosts` las saltea para no + /// pintarlas dos veces. Se limpia al inicio de cada `prepare_underlays`. + under_placed: Vec, } impl AnimRegistry { @@ -512,6 +603,10 @@ impl AnimRegistry { if prev != variant { switched = true; if let Some(le) = self.live.remove(&anim.key) { + // La afín/placement/hold de salida del contenido + // VIEJO los declara el frame nuevo (`anim.exit_*`): + // sólo quien conmuta sabe hacia dónde debe irse lo + // anterior (push ≠ pop). El centro sí es el capturado. self.ghosts.insert( anim.key, Ghost { @@ -519,6 +614,10 @@ impl AnimRegistry { start: now, duration: le.duration, easing: le.easing, + centro: le.centro, + to_xf: anim.exit_to_xf, + under: anim.exit_under, + hold: anim.exit_hold, }, ); } @@ -548,9 +647,13 @@ impl AnimRegistry { }); if switched { // Cross-fade: el contenido nuevo entra desde transparente - // (el viejo ya quedó como fantasma desvaneciéndose encima). + // (el viejo ya quedó como fantasma desvaneciéndose). Con + // `enter_fade: false` entra OPACO (push estilo UIKit: la + // página nueva tapa, no se funde). Si además declara + // `enter_from_xf`, entra desde esa afín (slide/zoom). entry.from = AnimSnapshot { - alpha: Some(0.0), + alpha: if anim.enter_fade { Some(0.0) } else { target.alpha }, + transform: anim.enter_from_xf.or(target.transform), ..target }; entry.to = target; @@ -558,8 +661,8 @@ impl AnimRegistry { entry.duration = anim.duration; entry.easing = anim.easing; } else if entry.to != target { - // Cambió el objetivo: congelá el valor actual como nuevo origen - // y rearrancá el reloj hacia el objetivo nuevo. + // Cambió el objetivo: congela el valor actual como nuevo origen + // y rearranca el reloj hacia el objetivo nuevo. entry.from = entry.value(now); entry.to = target; entry.start = now; @@ -608,6 +711,10 @@ impl AnimRegistry { start: now, duration: le.duration, easing: le.easing, + centro: le.centro, + to_xf: le.to_xf, + under: le.under, + hold: le.hold, }, ); } @@ -638,16 +745,61 @@ impl AnimRegistry { } /// Guarda (o refresca) la subescena retenida de un nodo `exit` presente. El - /// runtime la captura con [`crate::paint_range`] tras el paint. `duration` y - /// `easing` se heredan al fantasma cuando la key desaparezca. - pub fn store_live_exit( - &mut self, - key: u64, - scene: Scene, - duration: Duration, - easing: fn(f32) -> f32, - ) { - self.live.insert(key, LiveExit { scene, duration, easing }); + /// runtime la captura con [`crate::paint_range`] tras el paint. Del `anim` + /// del nodo se heredan al fantasma `duration`/`easing`/`exit_to_xf`/ + /// `exit_under`/`exit_hold` cuando la key desaparezca (camino `exit`; en + /// un `switch` la salida la re-declara el frame nuevo). `centro` es el + /// centro del rect del nodo (origen de la afín de salida). + pub fn store_live_exit(&mut self, key: u64, scene: Scene, centro: (f32, f32), anim: &Anim) { + self.live.insert( + key, + LiveExit { + scene, + duration: anim.duration, + easing: anim.easing, + centro, + to_xf: anim.exit_to_xf, + under: anim.exit_under, + hold: anim.exit_hold, + }, + ); + } + + /// **Scene-split de fantasmas**: escribe en `MountedNode::underlay` la + /// subescena (ya envuelta en su capa de alpha + afín de salida) de cada + /// fantasma `under` cuyo `switch` sigue montado — el paint la apendea + /// DEBAJO del contenido nuevo, en la posición z real del nodo (y dentro + /// del clip/transform de sus ancestros). Las keys colocadas se saltean en + /// [`Self::replay_ghosts`] este frame. Llamar DESPUÉS de [`Self::reconcile`] + /// y ANTES del paint. + pub fn prepare_underlays(&mut self, mounted: &mut Mounted, now: Instant) { + self.under_placed.clear(); + if self.ghosts.is_empty() { + return; + } + for node in &mut mounted.nodes { + let Some(anim) = node.anim else { continue }; + if anim.switch.is_none() { + continue; + } + let Some(g) = self.ghosts.get(&anim.key) else { continue }; + if !g.under { + continue; + } + let a = g.alpha(now); + if a <= 0.0 { + continue; + } + // Envoltorio: capa de alpha (rect generoso — el clip real lo + // ponen los ancestros del nodo en el paint) + la afín de salida. + let mut wrap = Scene::new(); + let big = Rect::new(-1e6, -1e6, 1e6, 1e6); + wrap.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &big); + wrap.append(&g.scene, g.xf(now)); + wrap.pop_layer(); + node.underlay = Some(wrap); + self.under_placed.push(anim.key); + } } /// Reproduce los fantasmas activos sobre `scene`, cada uno con su opacidad @@ -659,13 +811,18 @@ impl AnimRegistry { return false; } let clip = Rect::new(0.0, 0.0, w as f64, h as f64); - for g in self.ghosts.values() { + for (key, g) in &self.ghosts { + // Los colocados como underlay este frame ya se pintaron en su + // posición z (dentro del árbol) — no duplicarlos encima. + if self.under_placed.contains(key) { + continue; + } let a = g.alpha(now); if a <= 0.0 { continue; } scene.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &clip); - scene.append(&g.scene, None); + scene.append(&g.scene, g.xf(now)); scene.pop_layer(); } true @@ -800,11 +957,11 @@ mod tests { let mut reg = AnimRegistry::new(); let t0 = Instant::now(); // Frame 1: el nodo exit está presente. No anima por sí solo, y el - // runtime captura su subescena (acá una vacía de prueba). + // runtime captura su subescena (aquí una vacía de prueba). let mut m = one_exit(); let animating = reg.reconcile(&mut m, t0); assert!(!animating, "presente y quieto no anima"); - reg.store_live_exit(7, Scene::new(), Duration::from_millis(200), ease_out_cubic); + reg.store_live_exit(7, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim")); // Frame 2: la key desaparece → se promueve a fantasma y pide frames. let mut m = empty(); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10)); @@ -834,7 +991,7 @@ mod tests { let mut m = one_switch(1); assert!(!reg.reconcile(&mut m, t0), "primera aparición no cruza"); // El runtime captura su subescena (de prueba, vacía). - reg.store_live_exit(5, Scene::new(), Duration::from_millis(200), ease_out_cubic); + reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim")); // Frame 2: variante 2 → cross-fade. El contenido nuevo arranca casi // transparente y hay un fantasma del contenido viejo desvaneciéndose. let mut m = one_switch(2); @@ -847,7 +1004,7 @@ mod tests { "hay un fantasma del contenido viejo" ); // Re-captura del frame 2 (lo haría el runtime tras el paint). - reg.store_live_exit(5, Scene::new(), Duration::from_millis(200), ease_out_cubic); + reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim")); // Frame 3: misma variante, pasada la duración → asentado y sin fantasma. let mut m = one_switch(2); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(400)); @@ -865,7 +1022,7 @@ mod tests { let t0 = Instant::now(); let mut m = one_switch(1); reg.reconcile(&mut m, t0); - reg.store_live_exit(5, Scene::new(), Duration::from_millis(200), ease_out_cubic); + reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim")); // Misma variante en el frame siguiente: ni fade-in ni fantasma. let mut m = one_switch(1); let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10)); @@ -880,7 +1037,7 @@ mod tests { let t0 = Instant::now(); let mut m = one_exit(); reg.reconcile(&mut m, t0); - reg.store_live_exit(7, Scene::new(), Duration::from_millis(200), ease_out_cubic); + reg.store_live_exit(7, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim")); // Se va → fantasma. let mut m = empty(); assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10))); @@ -1093,4 +1250,177 @@ mod tests { reconcile_size_anim(&mut v, &mut reg, now); assert_eq!(reg.entries.len(), 0); } + + // ── Salida direccional (exit_to_xf) — base del router de páginas ──────── + + #[test] + fn fantasma_con_exit_to_xf_interpola_la_afin_alrededor_del_centro() { + let t0 = Instant::now(); + let g = Ghost { + scene: Scene::new(), + start: t0, + duration: Duration::from_millis(200), + easing: |t| t, // lineal para aserciones exactas + centro: (100.0, 50.0), + to_xf: Some(Affine::translate((-200.0, 0.0))), + under: false, + hold: false, + }; + // t=0: afín ≈ identidad (el fantasma arranca donde estaba). + let xf0 = g.xf(t0).expect("hay afín"); + let c0 = xf0.as_coeffs(); + assert!(c0[4].abs() < 1e-6 && c0[5].abs() < 1e-6, "t=0 → identidad: {c0:?}"); + // t=0.5: mitad del desplazamiento. Una traslación conmuta con la + // conjugación por el centro, así que el coef es -100 exacto. + let xf = g.xf(t0 + Duration::from_millis(100)).expect("afín viva"); + let c = xf.as_coeffs(); + assert!((c[4] + 100.0).abs() < 1e-3, "mitad del slide: {c:?}"); + // Sin to_xf → None (fade puro, camino histórico). + let g2 = Ghost { to_xf: None, scene: Scene::new(), ..g }; + assert!(g2.xf(t0 + Duration::from_millis(100)).is_none()); + } + + #[test] + fn fantasma_con_scale_respira_desde_su_centro() { + let t0 = Instant::now(); + let g = Ghost { + scene: Scene::new(), + start: t0, + duration: Duration::from_millis(100), + easing: |t| t, + centro: (100.0, 100.0), + to_xf: Some(Affine::scale(2.0)), + under: false, + hold: false, + }; + // Al final del tween (t=1) la afín es scale(2) conjugada por el + // centro: el centro queda FIJO (100,100 → 100,100). + let xf = g.xf(t0 + Duration::from_millis(100)).expect("afín"); + let p = xf * vello::kurbo::Point::new(100.0, 100.0); + assert!((p.x - 100.0).abs() < 1e-6 && (p.y - 100.0).abs() < 1e-6, + "el centro no se mueve: {p:?}"); + // Un punto a 50px del centro termina a 100px (escala 2). + let q = xf * vello::kurbo::Point::new(150.0, 100.0); + assert!((q.x - 200.0).abs() < 1e-6, "escala desde el centro: {q:?}"); + } + + #[test] + fn switch_direccional_entra_desde_la_afin_y_el_fantasma_sale_hacia_la_nueva() { + let salida = Affine::translate((-300.0, 0.0)); + let entrada = Affine::translate((300.0, 0.0)); + let one = |variant: u64| -> Mounted<()> { + let v = View::<()>::new(Style::default()) + .fill(rgba(10, 20, 30)) + .animated_switch_from(5, variant, Duration::from_millis(200), + Some(entrada), Some(salida)); + let mut layout = LayoutTree::new(); + mount(&mut layout, v) + }; + let mut reg = AnimRegistry::new(); + let t0 = Instant::now(); + // Frame 1: variante 1 asienta; el runtime captura (to_xf del frame 1, + // que NO es el que debe usarse al conmutar). + let mut m = one(1); + reg.reconcile(&mut m, t0); + let mut senuelo = m.nodes[0].anim.expect("anim"); + senuelo.exit_to_xf = Some(Affine::scale(9.0)); // señuelo: NO debe usarse + reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &senuelo); + // Frame 2: variante 2 → el contenido nuevo arranca DESPLAZADO hacia + // `entrada` (además de transparente) y el fantasma lleva la `salida` + // declarada por el frame nuevo, no el señuelo capturado. + let mut m = one(2); + let animating = reg.reconcile(&mut m, t0 + Duration::from_millis(10)); + assert!(animating); + let xf = m.nodes[0].transform.expect("transform de entrada"); + let c = xf.as_coeffs(); + assert!(c[4] > 200.0, "arranca cerca de +300 en x: {c:?}"); + let g = reg.ghosts.get(&5).expect("fantasma del contenido viejo"); + assert_eq!(g.to_xf, Some(salida), "la salida la declara el frame nuevo"); + } + + #[test] + fn hold_mantiene_el_alpha_hasta_expirar() { + let t0 = Instant::now(); + let g = Ghost { + scene: Scene::new(), + start: t0, + duration: Duration::from_millis(200), + easing: |t| t, + centro: (0.0, 0.0), + to_xf: None, + under: false, + hold: true, + }; + // A mitad del reloj sigue OPACO (salida sin desvanecer). + assert_eq!(g.alpha(t0 + Duration::from_millis(100)), 1.0); + // Expirado: desaparece de golpe. + assert_eq!(g.alpha(t0 + Duration::from_millis(250)), 0.0); + } + + #[test] + fn switch_estilado_entra_opaco_y_coloca_underlay() { + use crate::SwitchStyle; + let style = SwitchStyle { + enter_from_xf: Some(Affine::translate((300.0, 0.0))), + enter_fade: false, + exit_to_xf: None, + exit_under: true, + exit_hold: true, + }; + let one = |variant: u64| -> Mounted<()> { + let v = View::<()>::new(Style::default()) + .fill(rgba(10, 20, 30)) + .animated_switch_styled(5, variant, Duration::from_millis(200), style); + let mut layout = LayoutTree::new(); + mount(&mut layout, v) + }; + let mut reg = AnimRegistry::new(); + let t0 = Instant::now(); + let mut m = one(1); + reg.reconcile(&mut m, t0); + reg.store_live_exit(5, Scene::new(), (50.0, 50.0), &m.nodes[0].anim.expect("anim")); + // Conmutar: el contenido nuevo entra desplazado pero SIN fade (alpha + // intacto — push opaco), y el fantasma queda marcado under+hold. + let mut m = one(2); + let t1 = t0 + Duration::from_millis(10); + assert!(reg.reconcile(&mut m, t1)); + assert_eq!(m.nodes[0].alpha, None, "entra opaco (enter_fade=false)"); + assert!(m.nodes[0].transform.is_some(), "pero sí entra deslizándose"); + let g = reg.ghosts.get(&5).expect("fantasma"); + assert!(g.under && g.hold, "placement y hold del frame nuevo"); + // prepare_underlays hornea la subescena en el nodo y la marca para + // que replay_ghosts no la duplique encima. + reg.prepare_underlays(&mut m, t1); + assert!(m.nodes[0].underlay.is_some(), "underlay colocado"); + assert_eq!(reg.under_placed, vec![5]); + // Un fantasma under colocado no se re-pinta arriba, pero la función + // sigue reportando fantasmas vivos (ticker). + assert!(reg.replay_ghosts(&mut Scene::new(), t1, 100.0, 100.0)); + // Si la key deja de estar montada (nodo desapareció), el fantasma + // cae al camino histórico (encima): sin underlay que colocar. + let mut vacio = empty(); + reg.prepare_underlays(&mut vacio, t1); + assert!(reg.under_placed.is_empty(), "sin nodo switch no hay underlay"); + } + + #[test] + fn exit_to_xf_se_hereda_al_fantasma_al_desaparecer() { + let to = Affine::translate((0.0, 40.0)); + let v = View::<()>::new(Style::default()) + .fill(rgba(9, 9, 9)) + .animated_exit_to(7, Duration::from_millis(200), to); + let mut layout = LayoutTree::new(); + let mut m = mount(&mut layout, v); + let mut reg = AnimRegistry::new(); + let t0 = Instant::now(); + reg.reconcile(&mut m, t0); + // El runtime captura con la to_xf del propio nodo (camino exit). + reg.store_live_exit(7, Scene::new(), (20.0, 20.0), &m.nodes[0].anim.expect("anim")); + // Desaparece → el fantasma hereda la afín capturada. + let mut m = empty(); + assert!(reg.reconcile(&mut m, t0 + Duration::from_millis(10))); + let g = reg.ghosts.get(&7).expect("fantasma"); + assert_eq!(g.to_xf, Some(to)); + assert_eq!(g.centro, (20.0, 20.0)); + } } diff --git a/llimphi-compositor/src/layout_builder.rs b/llimphi-compositor/src/layout_builder.rs index 09ce6dd..bdf417b 100644 --- a/llimphi-compositor/src/layout_builder.rs +++ b/llimphi-compositor/src/layout_builder.rs @@ -223,7 +223,7 @@ mod tests { ]; let expanded = expand_layout_builders(root, &cons); // Ambos expandidos, en orden (verificamos vía el ancho del hijo producido - // que NO depende de la constraint acá — sólo confirmamos que se invocaron + // que NO depende de la constraint aquí — sólo confirmamos que se invocaron // los dos y que ninguno quedó como builder). assert!(expanded.children[0].layout_builder.is_none()); assert!(expanded.children[1].layout_builder.is_none()); diff --git a/llimphi-compositor/src/lib.rs b/llimphi-compositor/src/lib.rs index 10f350a..10aa0b4 100644 --- a/llimphi-compositor/src/lib.rs +++ b/llimphi-compositor/src/lib.rs @@ -33,6 +33,7 @@ mod semantics; mod view; pub use anim::{ ease_out_cubic, reconcile_size_anim, Anim, AnimRegistry, SizeAnim, SizeAnimRegistry, + SwitchStyle, }; pub use hero::{Hero, HeroRegistry}; pub use layout_builder::{collect_builder_constraints, expand_layout_builders, has_layout_builder}; @@ -119,6 +120,19 @@ pub struct TextSpec { /// previo). Sólo el camino uniforme (`layout_clamped`); el de spans /// (RichText) lo ignora en v1, igual que `no_wrap`/clamp. pub overflow_wrap: bool, + /// `font-feature-settings` (+ `font-kerning` plegado como `kern`) en formato + /// CSS crudo (`"liga" 1, "kern" 0`). Vacío = default de la fuente. Afecta + /// shaping y medida (ligaduras/kerning cambian el ancho). Sólo el camino + /// uniforme (`layout_clamped`); el de spans (RichText) lo ignora en v1. + pub font_features: String, + /// `font-variation-settings` en formato CSS crudo (`"wght" 700`). Vacío = + /// ejes en su default. Mismo régimen que [`Self::font_features`]. + pub font_variations: String, + /// `font-stretch`/`font-width` como **porcentaje** (100 = normal, <100 + /// condensada, >100 expandida). Afecta shaping y medida (la face + /// condensada/expandida cambia el ancho). Sólo el camino uniforme + /// (`layout_clamped`); el de spans (RichText) lo ignora en v1. + pub font_width: f32, } /// Fase de un drag activo. `Move` se emite por cada `CursorMoved` con el @@ -156,12 +170,21 @@ pub type ClickAtFn = Arc Option + Send + /// líneas lógicas (misma normalización que `App::on_wheel`: `dy` positivo /// = scroll hacia abajo). El runtime lo invoca cuando la rueda gira con el /// cursor sobre este nodo, ANTES de caer al `App::on_wheel` global: si el -/// handler devuelve `Some(Msg)`, el evento se consume acá. Permite áreas +/// handler devuelve `Some(Msg)`, el evento se consume aquí. Permite áreas /// de scroll autocontenidas (el widget `scroll` lo usa) sin que cada app /// rutee la rueda a mano por su `Model`. Devolver `None` deja pasar el /// evento al `on_wheel` global. pub type ScrollFn = Arc Option + Send + Sync>; +/// Handler de rueda con **reparto parcial** (scroll anidado real): recibe el +/// delta `(dx, dy)` en líneas y devuelve `(msg, rest_x, rest_y)` — el `Msg` +/// por lo que consumió (o `None` si no consumió nada) y el **sobrante** en la +/// misma unidad, que el runtime sigue repartiendo al siguiente scrollable de +/// la cadena (el ancestro). Un mismo evento puede así mover al hijo hasta su +/// tope Y al padre con el resto, en el mismo frame. Ver +/// [`View::on_scroll_take`] y [`consume_scroll_chain`]. +pub type ScrollTakeFn = Arc (Option, f32, f32) + Send + Sync>; + /// Variante de [`DragFn`] que **conoce la posición inicial del press** /// relativa al rect del nodo. Útil cuando el caller necesita identificar /// qué entidad (Concepto, lemming, etc.) bajo el cursor agarró el drag. @@ -240,6 +263,25 @@ pub struct Constraints { /// [`View::layout_builder`]. pub type LayoutBuilderFn = Arc View + Send + Sync>; +/// Constructor **diferido** del contenido de un [`View::memo`] (Bloque 25). +/// Produce el subárbol (un único `View`) que va DENTRO del nodo memo. El runtime +/// lo llama sólo cuando `deps` (o el tamaño del slot) cambió; si no, reusa el +/// `Mounted` cacheado sin reconstruir el `View`. +pub type MemoBuilderFn = Arc View + Send + Sync>; + +/// Nodo **memoizado** (Bloque 25 de `PLAN-NUCLEO-RETENIDO.md`): un subárbol +/// cuyo `View` NO se reconstruye —ni se re-monta ni se re-layoutea— si sus +/// `deps` no cambiaron. El nodo memo lleva un `Style` con **tamaño definido** +/// (length/percent, no auto): el runtime lo monta como hoja de ese tamaño, +/// computa el subárbol en un `LayoutTree` aislado (que taffy cachea) y splica +/// su `Mounted` cacheado. `builder` = `Some` mientras está sin resolver; +/// `resolve_memos` lo extrae y el nodo queda como marcador. Ver [`View::memo`]. +pub struct MemoNode { + pub key: u64, + pub deps: u64, + pub builder: Option>, +} + /// Rect absoluto del nodo (en coordenadas físicas del frame). Lo /// recibe el callback de [`View::paint_with`] para que pueda /// posicionar sus primitivas custom dentro del nodo. @@ -503,6 +545,14 @@ pub struct View { /// [`OverPaintFn`]. Misma firma que [`PaintFn`] — sólo cambia /// *cuándo* corre (post-GPU). `None` = sin over-layer (coste cero). pub over_painter: Option, + /// **Subárbol over** (ver [`View::over`]): este nodo y todos sus + /// descendientes se pintan en la pasada vello FINAL, después del pase + /// GPU directo — con toda la maquinaria normal (fills, texto, imágenes, + /// painters, hover). Es el primitivo para UI **encima** de contenido + /// `gpu_paint_with` (OSC de un reproductor, HUD sobre un canvas GPU). + /// El hit-test no cambia (usa el layout, no la pasada de pintura). + /// `false` = pinta en la pasada base, como siempre (coste cero). + pub over_layer: bool, pub on_click: Option, /// Handler de click que recibe la posición **relativa al rect del /// nodo** (esquina superior-izquierda del nodo = `(0, 0)`). Útil @@ -510,6 +560,10 @@ pub struct View { /// de mundo. Si está presente, gana sobre `on_click`. Devolver /// `None` no dispara update. pub on_click_at: Option>, + /// Gemelo de izquierda de [`Self::on_right_click_screen`]: click izquierdo con + /// coords **absolutas de ventana**, para anclar un popup (select/dropdown) en + /// la capa `view_overlay`. + pub on_click_screen: Option>, /// Equivalente a `on_click` pero para el botón derecho del ratón. /// Pensado para menús contextuales: el nodo declara qué `Msg` /// emitir cuando se le hace right-click, y la app abre el overlay @@ -520,6 +574,13 @@ pub struct View { /// click derecho (la celda no es un nodo aparte, sino una región /// dentro del nodo). Si está presente, gana sobre `on_right_click`. pub on_right_click_at: Option>, + /// Como [`Self::on_right_click_at`] pero recibe coords **absolutas de + /// ventana** (no locales al nodo). Para anclar menús contextuales en la + /// posición del puntero: `on_right_click_at` (local) sirve a grillas/canvas + /// que necesitan saber QUÉ región del rect se clickeó; éste sirve a popups + /// que quieren la posición en pantalla. Los backends le pasan el cursor + /// absoluto. Convive con `on_right_click_at` (un nodo puede tener ambos). + pub on_right_click_screen: Option>, /// Equivalente a `on_click` pero para el botón del medio del ratón /// (rueda presionada). Pensado para abrir en pestaña nueva — los /// browsers usan middle-click como atajo equivalente a Ctrl+Click. @@ -601,6 +662,10 @@ pub struct View { /// `Some(Msg)` consume el evento. Base de las áreas de scroll /// autocontenidas. Ver [`ScrollFn`]. pub on_scroll: Option>, + /// Variante con **reparto parcial** del delta (scroll anidado real): + /// consume lo que puede y devuelve el sobrante, que sigue al ancestro. + /// Tiene prioridad sobre `on_scroll` si ambos están. Ver [`ScrollTakeFn`]. + pub on_scroll_take: Option>, /// Handler de gesto de **escala** (pinch-to-zoom). Si está presente y el /// gesto cae sobre este nodo (Ctrl+rueda en desktop, pinch de trackpad en /// macOS), el runtime lo invoca con el factor incremental + el punto focal @@ -615,7 +680,7 @@ pub struct View { /// dentro de una ventana temporal corta y muy cerca). Es un evento /// **aditivo**: si el nodo también tiene `on_click`, éste igual dispara en /// cada press; el doble-tap llega además en el segundo. Para doble-tap - /// exclusivo, poné el handler en un nodo sin `on_click`. Ver + /// exclusivo, pon el handler en un nodo sin `on_click`. Ver /// [`View::on_double_tap`]. pub on_double_tap: Option, /// Variante posicional de [`Self::on_double_tap`]: recibe la posición del @@ -714,6 +779,18 @@ pub struct View { /// default CSS `50% 50%` (centro del rect) — el caso mayoritario. Ver /// [`TransformPivot`] y [`View::transform_origin`]. pub transform_origin: Option, + /// `position: sticky` (CSS): insets `[top, right, bottom, left]` en px + /// relativos al **scrollport** (el ancestro que recorta, `clip==true`). + /// El nodo se pinta en su posición de flujo normal hasta que el scroll lo + /// lleva al umbral del inset; ahí se "pega" a esa distancia del borde del + /// scrollport, sin salir de él. Como el scroll ya está horneado en el + /// layout (el rect del nodo refleja el desplazamiento), `paint` resuelve el + /// enganche con pura aritmética de rects — no necesita conocer el offset de + /// scroll. `None` ⇒ nodo no-sticky (la abrumadora mayoría). Ver + /// [`View::sticky`]. Limitación v1: el clamp usa el scrollport como caja + /// contenedora (no el bloque contenedor CSS exacto); alcanza para el caso + /// canónico (header/columna sticky a nivel de página). + pub sticky: Option<[Option; 4]>, /// Texto de **tooltip**: si está, el runtime/cliente puede mostrar un /// rótulo flotante cuando el cursor se posa sobre este nodo. Llimphi sólo /// transporta el dato hasta el [`MountedNode`]; *quién* lo pinta (un overlay @@ -752,6 +829,21 @@ pub struct View { /// `None` = source-over normal (la abrumadora mayoría). Ver [`View::blend`]. /// Fase 7.1237. pub blend: Option, + /// **RepaintBoundary** (Bloque 23 de `PLAN-NUCLEO-RETENIDO.md`): marca este + /// subárbol como candidato a **cachear su rasterización** entre frames. Si + /// el subárbol es paint-puro (sin painter/gpu/over/anim/ripple/gradiente/ + /// imagen/hover adentro) y está en una posición "limpia" (sin transform/clip/ + /// alpha ancestral), el runtime rasteriza su sub-escena una vez y la **reusa** + /// (append trasladado) mientras su contenido no cambie — evitando re-pintarlo. + /// `key` estable entre rebuilds. `None` = nodo normal (la abrumadora mayoría). + /// Ver [`View::repaint_boundary`]. + pub repaint_boundary: Option, + /// **Memo** (Bloque 25): si está presente, el contenido de este nodo se + /// construye vía el `builder` diferido y se **reusa entre frames** mientras + /// `deps` no cambie (no se reconstruye el `View` ni se re-monta/re-layoutea). + /// El nodo debe tener un `Style` con tamaño definido. `None` = nodo normal. + /// Ver [`View::memo`]. + pub memo: Option>, pub children: Vec>, } @@ -792,10 +884,13 @@ impl View { painter, gpu_painter, over_painter, + over_layer, on_click, on_click_at, + on_click_screen, on_right_click, on_right_click_at, + on_right_click_screen, on_middle_click, drag, drag_at, @@ -813,6 +908,7 @@ impl View { on_pointer_leave, on_pointer_move_at, on_scroll, + on_scroll_take, on_scale, on_rotate, on_double_tap, @@ -829,6 +925,7 @@ impl View { transform, transform_rel, transform_origin, + sticky, tooltip, cursor, ripple, @@ -836,6 +933,8 @@ impl View { backdrop_blur, filter, blend, + repaint_boundary, + memo, children, } = self; // Wrappers: cada callback que produce `Option` se reenvía y su @@ -860,6 +959,7 @@ impl View { painter, gpu_painter, over_painter, + over_layer, drag_payload, drop_hover_fill, clip, @@ -878,12 +978,24 @@ impl View { transform, transform_rel, transform_origin, + sticky, tooltip, cursor, ripple, backdrop_blur, filter, blend, + repaint_boundary, + // El builder del memo produce `View`; lo envolvemos para que + // produzca `View` mapeando su resultado con `f`. + memo: memo.map(|m| MemoNode { + key: m.key, + deps: m.deps, + builder: m.builder.map(|b| { + let f = f.clone(); + Arc::new(move || b().map_shared(f.clone())) as MemoBuilderFn + }), + }), // — Msg simples — on_click: on_click.map(|m| f(m)), on_right_click: on_right_click.map(|m| f(m)), @@ -897,10 +1009,18 @@ impl View { let f = f.clone(); Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn }), + on_click_screen: on_click_screen.map(|h| { + let f = f.clone(); + Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn + }), 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_right_click_screen: on_right_click_screen.map(|h| { + let f = f.clone(); + Arc::new(move |a, b, c, d| h(a, b, c, d).map(|m| f(m))) as ClickAtFn + }), 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 @@ -936,6 +1056,13 @@ impl View { let f = f.clone(); Arc::new(move |dx, dy| h(dx, dy).map(|m| f(m))) as ScrollFn }), + on_scroll_take: on_scroll_take.map(|h| { + let f = f.clone(); + Arc::new(move |dx, dy| { + let (msg, rx, ry) = h(dx, dy); + (msg.map(|m| f(m)), rx, ry) + }) as ScrollTakeFn + }), 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 @@ -944,7 +1071,7 @@ impl View { 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 produce un View: recursa el map — layout_builder: layout_builder.map(|h| { let f = f.clone(); Arc::new(move |c| h(c).map_shared(f.clone())) as LayoutBuilderFn @@ -1017,6 +1144,14 @@ pub struct TextMeasure { /// palabra larga: con el flag, el ancho mínimo del bloque deja de estar /// fijado por el token más ancho. pub overflow_wrap: bool, + /// Idem [`TextSpec::font_features`]. Entra en la medida porque ligaduras/ + /// kerning cambian el ancho del shaping (y la clave del caché). + pub font_features: String, + /// Idem [`TextSpec::font_variations`]. Mismo razonamiento que `font_features`. + pub font_variations: String, + /// Idem [`TextSpec::font_width`]. Entra en la medida porque la face + /// condensada/expandida cambia el ancho del shaping (y la clave del caché). + pub font_width: f32, } /// Cómo encajar una imagen en el rect del nodo (CSS `object-fit` / @@ -1188,6 +1323,7 @@ pub enum Cursor { ZoomOut, } +#[derive(Clone)] pub struct MountedNode { pub id: NodeId, pub fill: Option, @@ -1215,10 +1351,17 @@ pub struct MountedNode { pub painter: Option, pub gpu_painter: Option, pub over_painter: Option, + /// Subárbol over (ver [`View::over`]): este nodo y sus descendientes + /// pintan en la pasada vello final, después del pase GPU. + pub over_layer: bool, pub on_click: Option, pub on_click_at: Option>, + /// Gemelo de izquierda de [`Self::on_right_click_screen`]: click izquierdo con + /// coords **absolutas de ventana**, para anclar un popup (select/dropdown). + pub on_click_screen: Option>, pub on_right_click: Option, pub on_right_click_at: Option>, + pub on_right_click_screen: Option>, pub on_middle_click: Option, pub drag: Option>, pub drag_at: Option>, @@ -1236,6 +1379,8 @@ pub struct MountedNode { pub on_pointer_leave: Option, pub on_pointer_move_at: Option>, pub on_scroll: Option>, + /// Handler de scroll con reparto parcial. Ver [`ScrollTakeFn`]. + pub on_scroll_take: Option>, /// Handler de gesto de escala (pinch-to-zoom) de este nodo. Ver /// [`View::on_scale`] y [`ScaleFn`]. pub on_scale: Option>, @@ -1264,6 +1409,14 @@ pub struct MountedNode { /// en [`HeroRegistry::reconcile`] para enlazar identidad entre frames y /// escribir `transform` con la afín "fly" cuando el rect cambia. pub hero: Option, + /// Subescena a pintar **DEBAJO** del contenido de este nodo, en su + /// posición z (y dentro del clip/transform de sus ancestros). La escribe + /// el runtime — [`AnimRegistry::prepare_underlays`]: el fantasma del + /// contenido viejo de un `switch` con `exit_under` — no el `View`. El + /// paint la apendea componiendo la afín acumulada de los ancestros, + /// ANTES del transform/alpha propios del nodo (que pertenecen al + /// contenido NUEVO). + pub underlay: Option, /// 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, @@ -1273,6 +1426,10 @@ pub struct MountedNode { /// Pivote de `transform` (CSS `transform-origin`). `None` ⇒ centro. Ver /// [`TransformPivot`] / [`View::transform_origin`]. pub transform_origin: Option, + /// `position: sticky`: insets `[top, right, bottom, left]` px relativos al + /// scrollport. Ver [`View::sticky`]. `paint` lo resuelve con los rects + /// computados (el scroll ya está en el layout). `None` ⇒ no-sticky. + pub sticky: Option<[Option; 4]>, /// Texto de tooltip de este nodo (ver [`View::tooltip`]). El consumidor lo /// lee tras un hit-test de hover para pintar el rótulo flotante. pub tooltip: Option, @@ -1308,6 +1465,12 @@ pub struct MountedNode { /// 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, + /// Key de [`View::repaint_boundary`] si este nodo abre un límite de + /// repintado cacheable. `None` = normal. Lo consume `paint_cached`. + pub repaint_boundary: Option, + /// Key de [`View::memo`] si este nodo es un marcador de subárbol memoizado + /// (Bloque 25). El runtime lo splica tras el layout. `None` = normal. + pub memo_ref: Option, /// Í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 5cc9fee..12770d1 100644 --- a/llimphi-compositor/src/render.rs +++ b/llimphi-compositor/src/render.rs @@ -1,20 +1,51 @@ use super::*; +use std::hash::{Hash, Hasher}; + +/// Holgura (px) que se le suma al ancho de envoltura del texto en el PAINT. +/// +/// Taffy redondea cada borde de caja a entero de forma independiente, así que +/// el ancho *usado* de un nodo puede quedar hasta ~1px por debajo del ancho +/// natural de su contenido (el error de redondeo del borde izquierdo y el +/// derecho se suman: cada uno ∈ [-0.5, 0.5] → pérdida ≤ 1.0). Para un nodo de +/// texto encogido-a-contenido (un ``/`` inline que la medida +/// dimensionó a `nat=86.438` pero taffy dejó en `86.000`), envolver el paint +/// exactamente a ese ancho redondeado parte la última palabra a una segunda +/// línea que la MEDIDA nunca reservó (queda desbordando hacia abajo). Sumar +/// esta holgura hace que el paint tolere la pérdida de redondeo: el texto que +/// cabía en una línea sigue en una línea. No afecta el wrapping real (un +/// párrafo que de verdad excede su bloque por >1px sigue envolviendo). +const TEXT_WRAP_ROUNDING_SLACK: f32 = 1.0; + pub fn mount(layout: &mut LayoutTree, v: View) -> Mounted { let mut nodes = Vec::new(); let mut text_measures = std::collections::HashMap::new(); - let root = mount_recursive(layout, v, &mut nodes, &mut text_measures); + let root = mount_recursive(layout, v, &mut nodes, &mut text_measures, &MountPlan::Fresh); Mounted { root, nodes, text_measures } } +/// Cómo obtiene su `NodeId` cada nodo montado (Bloque 24). `Fresh` crea nodos +/// taffy nuevos (camino histórico). `Reuse` reusa los `NodeId` del frame anterior +/// en el MISMO orden pre-orden (sólo válido si la **forma** del árbol no cambió — +/// lo garantiza `mount_incremental`) y sólo llama `set_style` donde el estilo +/// taffy cambió, dejando limpio el resto para que taffy cachee su layout (ver el +/// probe `taffy_cachea_entre_computes` en llimphi-layout). +pub(crate) enum MountPlan<'a> { + Fresh, + Reuse(&'a [NodeId]), +} + /// Mount en pre-orden directo sobre `out`: pusheamos el padre como /// placeholder (id real desconocido hasta crear el taffy node), recursamos /// hijos sobre el mismo `out`, y al volver completamos `id` + `subtree_end`. -pub fn mount_recursive( +/// El `plan` decide si el `NodeId` es nuevo (`Fresh`) o reusado del frame +/// anterior (`Reuse`, Bloque 24). +pub(crate) fn mount_recursive( layout: &mut LayoutTree, v: View, out: &mut Vec>, text_measures: &mut std::collections::HashMap, + plan: &MountPlan<'_>, ) -> NodeId { let View { style, @@ -34,10 +65,13 @@ pub fn mount_recursive( painter, gpu_painter, over_painter, + over_layer, on_click, on_click_at, + on_click_screen, on_right_click, on_right_click_at, + on_right_click_screen, on_middle_click, drag, drag_at, @@ -55,6 +89,7 @@ pub fn mount_recursive( on_pointer_leave, on_pointer_move_at, on_scroll, + on_scroll_take, on_scale, on_rotate, on_double_tap, @@ -71,6 +106,7 @@ pub fn mount_recursive( transform, transform_rel, transform_origin, + sticky, tooltip, cursor, ripple, @@ -78,8 +114,13 @@ pub fn mount_recursive( backdrop_blur, filter, blend, + repaint_boundary, + memo, children, } = v; + // Un nodo memo se monta como HOJA marcador (su contenido lo splica el + // runtime tras el layout); sus `children` estáticos se ignoran. + let children = if memo.is_some() { Vec::new() } else { children }; let parent_idx = out.len(); out.push(MountedNode { id: NodeId::new(0), // placeholder, lo sobreescribimos abajo @@ -99,10 +140,13 @@ pub fn mount_recursive( painter, gpu_painter, over_painter, + over_layer, on_click, on_click_at, + on_click_screen, on_right_click, on_right_click_at, + on_right_click_screen, on_middle_click, drag, drag_at, @@ -120,6 +164,7 @@ pub fn mount_recursive( on_pointer_leave, on_pointer_move_at, on_scroll, + on_scroll_take, on_scale, on_rotate, on_double_tap, @@ -133,9 +178,13 @@ pub fn mount_recursive( animated_size, semantics, hero, + // El underlay no viene del View: lo escribe el runtime post-reconcile + // (fantasmas de switch en su posición z). Nace vacío en cada mount. + underlay: None, transform, transform_rel, transform_origin, + sticky, tooltip, cursor, ripple, @@ -146,22 +195,44 @@ pub fn mount_recursive( backdrop_blur, filter, blend, + repaint_boundary, + memo_ref: memo.as_ref().map(|m| m.key), subtree_end: 0, }); let mut child_ids = Vec::with_capacity(children.len()); for child in children { - child_ids.push(mount_recursive(layout, child, out, text_measures)); + child_ids.push(mount_recursive(layout, child, out, text_measures, plan)); } - let id = if child_ids.is_empty() { - layout.leaf(style).expect("layout leaf") - } else { - layout.node(style, &child_ids).expect("layout node") + let id = match plan { + MountPlan::Fresh => { + if child_ids.is_empty() { + layout.leaf(style).expect("layout leaf") + } else { + layout.node(style, &child_ids).expect("layout node") + } + } + MountPlan::Reuse(ids) => { + // `parent_idx` es el índice pre-orden de este nodo; con la forma + // validada (misma cantidad de hijos en cada posición) el reuso + // posicional es exacto. Sólo re-estilamos si el estilo cambió: así + // los nodos sin cambios quedan LIMPIOS y taffy cachea su layout. + let reused = ids[parent_idx]; + let changed = layout + .inner() + .style(reused) + .map(|s| *s != style) + .unwrap_or(true); + if changed { + let _ = layout.inner_mut().set_style(reused, style); + } + reused + } }; out[parent_idx].id = id; out[parent_idx].subtree_end = out.len(); // Hoja de texto uniforme: registrá su contenido para que el runtime lo // mida con parley. El texto multicolor (`runs`) lo dimensiona el caller - // (editor: un nodo por línea), así que no lo medimos acá. + // (editor: un nodo por línea), así que no lo medimos aquí. if child_ids.is_empty() { if let Some(text) = out[parent_idx].text.as_ref() { if text.runs.is_none() { @@ -184,6 +255,9 @@ pub fn mount_recursive( word_spacing: text.word_spacing, no_wrap: text.no_wrap, overflow_wrap: text.overflow_wrap, + font_features: text.font_features.clone(), + font_variations: text.font_variations.clone(), + font_width: text.font_width, }, ); } @@ -192,6 +266,369 @@ pub fn mount_recursive( id } +// ───────────────────────────────────────────────────────────────────────── +// Bloque 24 — Layout retenido (mount incremental). El único motivo por el que +// llimphi no aprovecha el caché de taffy es el `clear()` por frame (ids nuevos = +// caché fría). `mount_incremental` reusa los `NodeId` del frame anterior EN EL +// MISMO orden pre-orden cuando la **forma** del árbol no cambió, y sólo dirtea +// lo que cambió (estilo → set_style dentro de mount_recursive; medida de texto → +// mark_dirty aquí), dejando limpio el resto para que taffy saltee su re-solve. +// +// Correctitud demostrada por el arnés de equivalencia (fuzz retenido==fresco). +// Fail-safe: si la forma cambió, cae a `clear()`+rebuild = camino histórico. +// ───────────────────────────────────────────────────────────────────────── + +/// Estado retenido entre frames para el layout incremental. Opaco para el +/// runtime: lo guarda y lo pasa de vuelta. Ver [`mount_incremental`]. +pub struct RetainedLayout { + root: NodeId, + /// `NodeId` en pre-orden (índice = orden en `Mounted::nodes`). + ids: Vec, + /// Cantidad de hijos por nodo, en pre-orden — la **firma de forma**. + child_counts: Vec, + /// Firma de medida por nodo: `Some(h)` compara por hash; `None` = "siempre + /// re-medir" (nodos con `spans`, conservador). Ver [`measure_sig_of`]. + measure_sig: Vec>, +} + +impl RetainedLayout { + /// `NodeId` raíz del árbol retenido (para `compute`). + pub fn root(&self) -> NodeId { + self.root + } +} + +/// Firma de medida de un nodo: `Some(0)` = no lo mide taffy (sizing por Style o +/// texto con `runs`, dimensionado por el caller) → nunca necesita re-medida. +/// `Some(hash)` = texto uniforme, compara por hash de los campos que afectan el +/// shaping/line-break (el `color` NO entra). `None` = texto con `spans` → +/// conservador: se re-mide siempre (spans son raros; evita un sig frágil). +fn measure_sig_of(node: &MountedNode) -> Option { + let Some(t) = node.text.as_ref() else { + return Some(0); + }; + if t.runs.is_some() { + return Some(0); + } + if t.spans.is_some() { + return None; + } + let mut h = std::collections::hash_map::DefaultHasher::new(); + t.content.hash(&mut h); + t.size_px.to_bits().hash(&mut h); + (t.alignment as u8).hash(&mut h); + t.italic.hash(&mut h); + t.weight.to_bits().hash(&mut h); + t.line_height.to_bits().hash(&mut h); + t.max_lines.hash(&mut h); + t.ellipsis.hash(&mut h); + t.font_family.hash(&mut h); + t.letter_spacing.to_bits().hash(&mut h); + t.word_spacing.to_bits().hash(&mut h); + t.no_wrap.hash(&mut h); + t.overflow_wrap.hash(&mut h); + t.font_features.hash(&mut h); + t.font_variations.hash(&mut h); + t.font_width.to_bits().hash(&mut h); + Some(h.finish()) +} + +/// Firma de forma del `View` (cantidad de hijos por nodo, en pre-orden). Es una +/// pasada read-only que NO consume el árbol; se compara contra la retenida para +/// decidir reuso vs rebuild. +fn view_child_counts(v: &View, out: &mut Vec) { + out.push(v.children.len()); + for c in &v.children { + view_child_counts(c, out); + } +} + +/// Monta `view` reusando el layout retenido cuando la **forma** no cambió. En +/// ese caso NO se limpia el árbol taffy: se reusan los `NodeId`, se re-estila +/// sólo lo cambiado y se marcan dirty los textos cuya medida cambió — así taffy +/// cachea el resto. Si la forma cambió (o no hay estado previo), cae a +/// `clear()`+rebuild (idéntico al camino histórico). Devuelve el `Mounted`, el +/// nuevo `RetainedLayout` (para el próximo frame) y si hubo reuso. +pub fn mount_incremental( + layout: &mut LayoutTree, + view: View, + prev: Option<&RetainedLayout>, +) -> (Mounted, RetainedLayout, bool) { + let mut child_counts = Vec::new(); + view_child_counts(&view, &mut child_counts); + let can_reuse = prev.is_some_and(|p| p.child_counts == child_counts); + + let mut nodes = Vec::new(); + let mut text_measures = std::collections::HashMap::new(); + + if can_reuse { + let p = prev.expect("can_reuse ⇒ prev"); + // Reuso: sin clear. `set_style` se hace dentro de mount_recursive donde + // el estilo cambió (deja limpio el resto). + let root = mount_recursive( + layout, + view, + &mut nodes, + &mut text_measures, + &MountPlan::Reuse(&p.ids), + ); + // Post-pasada de medida: un texto puede cambiar de tamaño sin cambiar su + // `Style` (el contenido no está en el Style). Marcamos dirty esos nodos + // para que taffy los re-mida; el resto queda cacheado. + let mut measure_sig = Vec::with_capacity(nodes.len()); + for (i, n) in nodes.iter().enumerate() { + let sig = measure_sig_of(n); + let prev_sig = p.measure_sig.get(i).copied().flatten(); + // `None` (spans) ⇒ siempre dirty. `Some(h)` ⇒ dirty si cambió. + let dirty = match sig { + None => true, + Some(h) => prev_sig != Some(h), + }; + if dirty { + let _ = layout.inner_mut().mark_dirty(n.id); + } + measure_sig.push(sig); + } + let retained = RetainedLayout { + root, + ids: p.ids.clone(), + child_counts, + measure_sig, + }; + (Mounted { root, nodes, text_measures }, retained, true) + } else { + // Rebuild fresco (camino histórico). NodeIds nuevos. + layout.clear(); + let root = mount_recursive(layout, view, &mut nodes, &mut text_measures, &MountPlan::Fresh); + let ids: Vec = nodes.iter().map(|n| n.id).collect(); + let measure_sig: Vec> = nodes.iter().map(measure_sig_of).collect(); + let retained = RetainedLayout { + root, + ids, + child_counts, + measure_sig, + }; + (Mounted { root, nodes, text_measures }, retained, false) + } +} + +// ───────────────────────────────────────────────────────────────────────── +// Bloque 25 — Memo de view() (enfoque A: clone-splice). Un `View::memo(key, +// deps, builder)` es un subárbol cuyo `View` NO se reconstruye si `deps` (ni el +// tamaño del slot) cambiaron: el builder no se llama, y el `Mounted` cacheado se +// splica en el árbol principal. El subárbol vive en un `LayoutTree` AISLADO que +// taffy cachea → 0 re-solve. Ahorra view-build + mount + re-solve del subárbol; +// paga un `clone` liviano (los handlers son `Arc`). Paint/hit-test quedan +// INTACTOS (el splice produce un `Mounted` plano normal). Correctitud probada +// por el arnés (Mounted memoizado == fresco). +// ───────────────────────────────────────────────────────────────────────── + +/// Registro de subárboles memoizados (Bloque 25). Vive en el runtime entre +/// frames. Por key: `deps` + tamaño del slot con que se computó + el +/// `LayoutTree` aislado (cacheado por taffy) + el `Mounted` local + sus rects +/// locales. +pub struct MemoRegistry { + entries: std::collections::HashMap>, + seen: std::collections::HashSet, +} + +struct MemoEntry { + deps: u64, + avail: (f32, f32), + layout: LayoutTree, + nodes: Vec>, + rects: Vec, +} + +impl Default for MemoRegistry { + fn default() -> Self { + Self { + entries: std::collections::HashMap::new(), + seen: std::collections::HashSet::new(), + } + } +} + +impl MemoRegistry { + pub fn new() -> Self { + Self::default() + } + /// Subárboles memoizados vivos. + pub fn len(&self) -> usize { + self.entries.len() + } + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +/// `true` si el árbol tiene algún nodo `memo` (fast-path para el runtime). +pub fn has_memo(view: &View) -> bool { + view.memo.is_some() || view.children.iter().any(has_memo) +} + +/// Extrae los builders de los nodos `memo` del `view` (dejándolos como +/// marcadores `builder: None`), en pre-orden. El runtime los pasa a +/// [`splice_memos`] tras el layout. Debe correr ANTES de `mount`. +pub fn take_memo_builders( + view: &mut View, + out: &mut Vec<(u64, u64, MemoBuilderFn, crate::Style)>, +) { + if view.memo.as_ref().is_some_and(|m| m.builder.is_some()) { + // Capturamos el `Style` del nodo memo: el subárbol aislado se computa + // como `container(style) -> content`, igual que el fresco, para que el + // contenido se estire dentro del box del memo (no se dimensione como raíz). + let style = view.style.clone(); + let m = view.memo.as_mut().expect("memo"); + let b = m.builder.take().expect("builder"); + out.push((m.key, m.deps, b, style)); + } + for c in &mut view.children { + take_memo_builders(c, out); + } +} + +/// Splica los subárboles memoizados en el `Mounted`/`ComputedLayout` ya +/// computados: para cada memo, si `deps` y el tamaño del slot no cambiaron reusa +/// el `Mounted` cacheado (sin llamar el builder); si cambiaron, llama el builder, +/// monta+computa el subárbol en su `LayoutTree` aislado y lo cachea. Luego clona +/// los nodos cacheados con `NodeId` sintéticos (rango alto, colisión-proof), +/// offsetea sus rects a la posición del marcador, y los inserta como hijos del +/// marcador. `measure` mide una hoja de texto (misma que el compute principal). +pub fn splice_memos( + mounted: &mut Mounted, + computed: &mut ComputedLayout, + pending: &[(u64, u64, MemoBuilderFn, crate::Style)], + reg: &mut MemoRegistry, + mut measure: F, +) where + Msg: Clone, + F: FnMut( + &TextMeasure, + llimphi_layout::taffy::Size>, + llimphi_layout::taffy::Size, + ) -> llimphi_layout::taffy::Size, +{ + reg.seen.clear(); + let mut synth: u64 = u64::MAX; + for (key, deps, builder, style) in pending { + reg.seen.insert(*key); + // Re-scan del marcador: los índices se corren tras cada splice. + let Some(mi) = mounted.nodes.iter().position(|n| n.memo_ref == Some(*key)) else { + continue; + }; + let Some(slot) = computed.get(mounted.nodes[mi].id) else { + continue; + }; + let abs = (slot.x, slot.y); + let avail = (slot.w, slot.h); + + // Reuso si deps y tamaño del slot coinciden; si no, rebuild. + let hit = reg + .entries + .get(key) + .is_some_and(|e| e.deps == *deps && e.avail == avail); + if !hit { + // El aislado replica EXACTAMENTE el fresco: `container(style) -> + // content`. El `container` (índice 0) == el marcador; se computa con + // el mismo slot que el marcador, así el `content` se estira igual. + let root_view = View::new(style.clone()).children(vec![builder()]); + let mut layout = reg + .entries + .remove(key) + .map(|e| e.layout) + .unwrap_or_default(); + layout.clear(); + let sub_mounted = mount(&mut layout, root_view); + let sub_computed = { + let tm = &sub_mounted.text_measures; + layout + .compute_with_measure(sub_mounted.root, avail, |nid, k, a| match tm.get(&nid) { + Some(t) => measure(t, k, a), + None => llimphi_layout::taffy::Size { + width: 0.0, + height: 0.0, + }, + }) + .expect("layout memo") + }; + // Origen del container (índice 0) para hacer los rects RELATIVOS a él + // (luego se offsetean al `abs` del marcador). + let origin = sub_computed + .get(sub_mounted.nodes[0].id) + .map(|r| (r.x, r.y)) + .unwrap_or((0.0, 0.0)); + let rects = sub_mounted + .nodes + .iter() + .map(|n| { + let r = sub_computed.get(n.id).unwrap_or(llimphi_layout::Rect { + x: 0.0, + y: 0.0, + w: 0.0, + h: 0.0, + }); + llimphi_layout::Rect { + x: r.x - origin.0, + y: r.y - origin.1, + w: r.w, + h: r.h, + } + }) + .collect(); + reg.entries.insert( + *key, + MemoEntry { + deps: *deps, + avail, + layout, + nodes: sub_mounted.nodes, + rects, + }, + ); + } + + let e = reg.entries.get(key).expect("insertado o reusado"); + // Salteamos el índice 0 (el container == el marcador, ya en el árbol + // principal): sólo splicamos su CONTENIDO (`[1..]`). + let sub_count = e.nodes.len().saturating_sub(1); + if sub_count == 0 { + continue; + } + let base = mi + 1; + let mut cloned: Vec> = Vec::with_capacity(sub_count); + for (i, n) in e.nodes.iter().enumerate().skip(1) { + let mut c = n.clone(); + let sid = NodeId::from(synth); + synth -= 1; + c.id = sid; + // Índice local `i` (≥1) → absoluto `mi + i`; subtree_end local + mi. + c.subtree_end = n.subtree_end + mi; + let r = e.rects[i]; + computed.rects.insert( + sid, + llimphi_layout::Rect { + x: r.x + abs.0, + y: r.y + abs.1, + w: r.w, + h: r.h, + }, + ); + cloned.push(c); + } + // Corre los subtree_end existentes que abarcaban el marcador (>mi). + for n in mounted.nodes.iter_mut() { + if n.subtree_end > mi { + n.subtree_end += sub_count; + } + } + mounted.nodes.splice(base..base, cloned); + } + // GC de las keys que no aparecieron este frame. + let seen = ®.seen; + reg.entries.retain(|k, _| seen.contains(k)); +} + /// Mide una hoja de texto para taffy: shaping + line-break con parley contra /// el ancho disponible, devolviendo el bounding box. Si el ancho ya está /// resuelto (`known.width`) se usa ese; si no, se deriva del `available` @@ -216,6 +653,25 @@ pub fn measure_text_node( AvailableSpace::MinContent => Some(0.0), }) }; + // Redondeo defensivo del ancho max-content (`max_width == None`): taffy + // computa el layout con floats pero REDONDEA el rect final a píxeles enteros + // (`round_layout`), lo que puede floorear una caja shrink-to-fit por debajo + // del ancho natural del texto (p.ej. natural 95.288 → caja 95.000). Luego el + // paint re-shapea el texto a ese ancho y lo PARTE en 2 líneas mientras el + // layout reservó alto de 1 → clip. Al reportar `ceil(full_width)` como tamaño + // intrínseco, taffy asigna un entero ≥ ancho natural y el texto nunca se + // parte por sub-píxel. Sólo afecta la medición sin restricción de ancho + // (max-content / nowrap); el wrap constreñido (Definite/MinContent) devuelve + // su ancho de línea real sin tocar. + // + // `full_width` (no `width`): parley EXCLUYE el espacio final del `width()` + // (la ws de fin de línea "cuelga" per CSS), pero al re-shapear en el paint a + // ese ancho SÍ cuenta el avance del espacio → lo empuja a una 2ª línea + // fantasma que la medida nunca reservó (clip). P.ej. `"archived mailing + // list "` mide width=148.932 pero full=153.432; con caja 149 el paint parte + // a 2 líneas, con caja 154 (=ceil(full)) queda en 1. Sin espacio final + // `full_width == width`, así que es no-op para el caso común. + let ceil_intrinsic = max_width.is_none(); // 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). @@ -239,8 +695,9 @@ pub fn measure_text_node( max_width, tm.alignment, ); + let w = if ceil_intrinsic { layout.full_width().ceil() } else { layout.width() }; return llimphi_layout::taffy::Size { - width: layout.width(), + width: w, height: layout.height(), }; } @@ -264,9 +721,47 @@ pub fn measure_text_node( tm.letter_spacing, tm.word_spacing, tm.overflow_wrap, + &tm.font_features, + &tm.font_variations, + tm.font_width, ); let m = llimphi_text::measurement(&layout); - llimphi_layout::taffy::Size { width: m.width, height: m.height } + let width = if ceil_intrinsic { layout.full_width().ceil() } else { m.width }; + llimphi_layout::taffy::Size { width, height: m.height } +} + +thread_local! { + /// `Typesetter` reusado para [`measured_height`] (crear uno es caro — + /// enumera fuentes del sistema). Independiente del del runtime; mismas + /// fuentes ⇒ mismas métricas. + static MEASURE_TS: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +/// Mide el **alto** que tendría `view` layouteado a un ancho `available_width` +/// (con alto libre). Monta + computa en un `LayoutTree` descartable, usando un +/// `Typesetter` thread-local para el shaping del texto. Pensado para construir +/// los `offsets` de una lista perezosa de **alto variado** cuyas alturas +/// dependen del contenido (reflow de texto): el caller lo llama por ítem CUANDO +/// cambia el contenido —no por frame— y cachea el resultado, luego pasa los +/// `offsets` a `lazy_list_varied`. Devuelve `0.0` si el layout falla. +pub fn measured_height(view: View, available_width: f32) -> f32 { + let mut lt = LayoutTree::new(); + let m = mount(&mut lt, view); + MEASURE_TS.with(|cell| { + let mut slot = cell.borrow_mut(); + let ts = slot.get_or_insert_with(llimphi_text::Typesetter::new); + let tmap = &m.text_measures; + lt.compute_with_measure(m.root, (available_width.max(0.0), 1.0e6), |nid, k, a| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(ts, tm, k, a), + None => llimphi_layout::taffy::Size { width: 0.0, height: 0.0 }, + } + }) + .ok() + .and_then(|c| c.get(m.root).map(|r| r.h)) + .unwrap_or(0.0) + }) } /// Construye el `RoundedRect` del nodo respetando radio por esquina si lo @@ -434,7 +929,7 @@ pub fn collect_filters( 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. + // post-pasada GPU — la salteamos aquí. Fase 7.1234. if matches!(op, FilterOp::DropShadow(_)) { continue; } @@ -478,6 +973,65 @@ pub(crate) fn resolve_node_transform( Some(Affine::translate((ox, oy)) * local * Affine::translate((-ox, -oy))) } +/// `position: sticky` — desplazamiento `(dx, dy)` en px a aplicar al nodo para +/// pegarlo dentro de su `scrollport` cuando el scroll (ya horneado en `r`, el +/// rect absoluto computado) lo lleva al umbral de un inset. `insets = [top, +/// right, bottom, left]` en px desde el borde del scrollport `sp = (x, y, w, +/// h)`. Devuelve `(0.0, 0.0)` mientras el nodo sigue en su posición de flujo +/// (aún no alcanzó el umbral) — cero efecto sobre nodos no-sticky. +/// +/// El truco: como el offset de scroll ya está en `r` (puriy lo hornea en el +/// layout, no como transform), NO hace falta conocerlo — basta comparar el rect +/// actual contra el umbral. Vertical: `top` gana sobre `bottom` cuando ambos +/// disparan (CSS). `top` pega el borde superior a `sp.y + top` empujando hacia +/// abajo (dy ≥ 0); `bottom` pega el inferior a `sp.y + sp.h - bottom` empujando +/// hacia arriba (dy ≤ 0). Horizontal análogo con left/right. v1: se clampa al +/// umbral, NO al bloque contenedor CSS exacto (usa el scrollport). Alcanza para +/// el caso canónico (header/columna sticky a nivel de página); un contenedor +/// más corto que la página dejaría el sticky pegado de más. +pub(crate) fn sticky_offset( + insets: [Option; 4], + r: llimphi_layout::Rect, + sp: (f32, f32, f32, f32), +) -> (f32, f32) { + let (spx, spy, spw, sph) = sp; + // Vertical: top primero (gana), luego bottom si top no disparó. + let mut dy = 0.0_f32; + if let Some(t) = insets[0] { + let pin = spy + t; + if r.y < pin { + dy = pin - r.y; + } + } + if dy == 0.0 { + if let Some(b) = insets[2] { + let pin_bottom = spy + sph - b; + let cur_bottom = r.y + r.h; + if cur_bottom > pin_bottom { + dy = pin_bottom - cur_bottom; + } + } + } + // Horizontal: left primero, luego right. + let mut dx = 0.0_f32; + if let Some(l) = insets[3] { + let pin = spx + l; + if r.x < pin { + dx = pin - r.x; + } + } + if dx == 0.0 { + if let Some(rt) = insets[1] { + let pin_right = spx + spw - rt; + let cur_right = r.x + r.w; + if cur_right > pin_right { + dx = pin_right - cur_right; + } + } + } + (dx, dy) +} + /// 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 @@ -702,7 +1256,19 @@ pub fn paint_range( Affine, Option, ); - let mut layer_stack: Vec<(usize, Option)> = Vec::new(); + // Tercer campo: `es_clip` — cuenta SOLO las capas de clip para el cap de + // profundidad (ver abajo). Alpha/blend/mask no cuentan (raras y someras). + let mut layer_stack: Vec<(usize, Option, bool)> = Vec::new(); + // Profundidad de CLIPS anidados vigente. Vello colapsa la ESCENA ENTERA + // (100% transparente, sin error) cuando el stack de blend/clip supera su + // límite (~6 capas en Iris Xe/Vulkan; medido con vim_drawer_probe + // PROBE_CLIPS: +2 sobre el árbol del drawer pinta, +4 vacía). Cap duro: + // los clips MÁS PROFUNDOS que MAX_CLIP_DEPTH no se pushean — un recorte + // cosmético que casi nunca recorta nada a esa altura; perderlo es + // invisible al lado de perder la escena completa (el bug del "cuerpo + // fantasma" del drawer de pata, 2026-07-15). + const MAX_CLIP_DEPTH: usize = 3; + let mut clip_depth: usize = 0; // 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` @@ -711,16 +1277,33 @@ pub fn paint_range( // al previo (cero regresión). let mut xf_stack: Vec<(usize, Affine)> = Vec::new(); let mut cur_xf = base_xf; + // Stack de scrollports para `position: sticky`: cada nodo que recorta + // (`clip==true`) es el scrollport de su subárbol. Un nodo sticky se pega + // relativo al scrollport activo (o al viewport entero si no hay ninguno). + // Entrada = `(subtree_end, rect_absoluto)`, restaurada al salir del subárbol. + let mut scrollport_stack: Vec<(usize, (f32, f32, f32, f32))> = Vec::new(); + // Salto de subárboles "over" (View::over): índices en [idx, skip_until) + // no se pintan en esta pasada — los pinta `paint_over_layers` después + // del pase GPU. Los cierres de capas/xf pendientes se resuelven solos + // al retomar (los `while` de abajo drenan todo lo que quedó con + // `end ≤ idx`); dentro del rango salteado no se pushea nada. + let mut skip_until = 0usize; for idx in start..end { + if idx < skip_until { + continue; + } 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() { + while let Some(&(end, _, _)) = layer_stack.last() { if idx >= end { - let (_, mask) = layer_stack.pop().unwrap(); + let (_, mask, es_clip) = layer_stack.pop().unwrap(); if let Some((img, extra, rect, xf, placement)) = &mask { paint_mask_close(scene, img, extra, *rect, *xf, *placement); } + if es_clip { + clip_depth = clip_depth.saturating_sub(1); + } scene.pop_layer(); } else { break; @@ -735,9 +1318,55 @@ pub fn paint_range( break; } } + // Salí de un scrollport al pasar su subárbol. + while let Some(&(end, _)) = scrollport_stack.last() { + if idx >= end { + scrollport_stack.pop(); + } else { + break; + } + } + // Subárbol "over" (View::over): no pinta en la pasada base. SÍ + // pinta cuando esta llamada ARRANCA en él (idx == start) — así lo + // invoca `paint_over_layers`, que reusa este mismo camino para la + // pasada final. + if node.over_layer && idx != start { + skip_until = node.subtree_end; + continue; + } let Some(r) = computed.get(node.id) else { continue; }; + // Underlay (scene-split de fantasmas): el contenido VIEJO de un + // switch se apendea acá — en la posición z del nodo, dentro del + // clip/alpha de los ancestros ya abiertos, y componiendo su afín + // acumulada (la subescena está en coords absolutas). Va ANTES del + // sticky/transform/alpha PROPIOS del nodo: esos pertenecen al + // contenido nuevo, no al fantasma. El envoltorio (alpha del fade + + // afín de salida) ya viene horneado por `prepare_underlays`. + if let Some(under) = &node.underlay { + scene.append(under, Some(cur_xf)); + } + // `position: sticky`: pega el nodo dentro del scrollport activo (o el + // viewport entero si no hay ancestro que recorte). Se resuelve ANTES del + // transform CSS y de abrir el clip de este nodo, para que toda su pintura + // caiga en la posición pegada. El offset se compone en `cur_xf` vía el + // mismo stack que `transform` (se restaura al salir del subárbol). + if let Some(insets) = node.sticky { + let sp = scrollport_stack + .last() + .map(|&(_, rect)| rect) + .unwrap_or((0.0, 0.0, f32::MAX, f32::MAX)); + let (dx, dy) = sticky_offset(insets, r, sp); + if dx != 0.0 || dy != 0.0 { + xf_stack.push((node.subtree_end, cur_xf)); + cur_xf *= Affine::translate((dx as f64, dy as f64)); + } + } + // Este nodo, si recorta, es el scrollport de sus descendientes sticky. + if node.clip { + scrollport_stack.push((node.subtree_end, (r.x, r.y, r.w, r.h))); + } // Transform CSS del nodo: se aplica alrededor del centro de su rect // (`transform-origin: 50% 50%`) y se compone sobre la del padre. Se // empuja ANTES del alpha/fill para que toda la pintura del subtree @@ -761,7 +1390,7 @@ pub fn paint_range( (r.y + r.h) as f64, ); scene.push_layer(Fill::NonZero, bm, 1.0, cur_xf, &rect); - layer_stack.push((node.subtree_end, None)); + layer_stack.push((node.subtree_end, None, false)); } // Alpha de subtree: push ANTES de cualquier paint de este nodo // para que fill/text/image/painter/children entren en la misma @@ -779,7 +1408,7 @@ pub fn paint_range( (r.y + r.h) as f64, ); scene.push_layer(Fill::NonZero, Mix::Normal, a, cur_xf, &rect); - layer_stack.push((node.subtree_end, None)); + layer_stack.push((node.subtree_end, None, false)); } // `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 @@ -804,6 +1433,7 @@ pub fn paint_range( cur_xf, node.mask_placement, )), + false, )); } // Sombra (drop shadow): se pinta ANTES del relleno para quedar @@ -975,7 +1605,7 @@ pub fn paint_range( text.underline, text.strikethrough, spans, - Some(r.w), + Some(r.w + TEXT_WRAP_ROUNDING_SLACK), text.alignment, ); let origin = @@ -1007,6 +1637,7 @@ pub fn paint_range( text.weight, text.underline, text.strikethrough, + text.font_family.as_deref(), ); // `cur_xf *` para que el texto multicolor herede la // transformación del subárbol (scroll/rotación del padre), igual @@ -1026,7 +1657,8 @@ pub fn paint_range( // `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 paint_max_width = + if text.no_wrap { None } else { Some(r.w + TEXT_WRAP_ROUNDING_SLACK) }; let layout = typesetter.layout_clamped( &text.content, text.size_px, @@ -1043,6 +1675,9 @@ pub fn paint_range( text.letter_spacing, text.word_spacing, text.overflow_wrap, + &text.font_features, + &text.font_variations, + text.font_width, ); let origin = if matches!(text.alignment, llimphi_text::Alignment::Center) { @@ -1066,8 +1701,12 @@ pub fn paint_range( // 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; + // false sólo si un path() no parsea (no se abre capa → no se cierra) + // o si el CAP de profundidad de clips está alcanzado (ver arriba: + // más hondo que MAX_CLIP_DEPTH vello colapsa la escena entera; un + // clip profundo sin pushear es cosmético, la escena viva no). + let mut pushed = clip_depth < MAX_CLIP_DEPTH; + if pushed { // 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. @@ -1129,14 +1768,16 @@ pub fn paint_range( ); scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &clip_rect); } + } if pushed { - layer_stack.push((node.subtree_end, None)); + layer_stack.push((node.subtree_end, None, true)); + clip_depth += 1; } } } - // Cerrá capas (clip + alpha + mask) que llegaron al final sin pop + // Cierra 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() { + 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); } @@ -1195,11 +1836,89 @@ pub fn paint_gpu( } /// `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. +/// "over" vía [`View::paint_over`]) **o** marcó su subárbol como capa over +/// ([`View::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()) + mounted + .nodes + .iter() + .any(|n| n.over_painter.is_some() || n.over_layer) +} + +/// `true` si algún nodo del árbol registró un handler de **click derecho** +/// ([`View::on_right_click`] / [`View::on_right_click_at`]). Espejo de +/// [`has_over_painter`], pensado para tests que verifican el wiring del menú +/// contextual sin simular el evento real. +pub fn has_right_click_handler(mounted: &Mounted) -> bool { + mounted + .nodes + .iter() + .any(|n| n.on_right_click.is_some() || n.on_right_click_at.is_some() || n.on_right_click_screen.is_some()) +} + +/// Pinta los **subárboles** marcados [`View::over`] en `scene`, con la +/// maquinaria completa de `paint_range` (fills, texto, imágenes, painters, +/// hover, clips y transforms internos). Espejo de [`paint_over`] pero para +/// árboles de Views enteros, no closures: el caller rasteriza esta escena +/// DESPUÉS del pase GPU y la compone sobre la intermedia. Igual que +/// `paint_over`, no compone clips/alpha/transform de ancestros FUERA del +/// subárbol (la capa over es contenido en coordenadas de pantalla). +/// Devuelve `true` si pintó alguno. +#[allow(clippy::too_many_arguments)] +pub fn paint_over_layers( + scene: &mut vello::Scene, + mounted: &Mounted, + computed: &ComputedLayout, + typesetter: &mut llimphi_text::Typesetter, + hover_idx: Option, + drop_hover_idx: Option, +) -> bool { + let dbg = debug_over_enabled(); + let mut any = false; + let mut idx = 0; + let mut count = 0usize; + while idx < mounted.nodes.len() { + let node = &mounted.nodes[idx]; + if node.over_layer { + if dbg { + let r = computed.get(node.id); + eprintln!( + "[over] capa #{count} idx={idx} subtree_end={} rect={:?}", + node.subtree_end, r + ); + } + paint_range( + scene, + mounted, + computed, + typesetter, + hover_idx, + drop_hover_idx, + idx, + node.subtree_end, + Affine::IDENTITY, + ); + any = true; + count += 1; + idx = node.subtree_end; + } else { + idx += 1; + } + } + if dbg { + eprintln!("[over] total capas over pintadas: {count} (nodos={})", mounted.nodes.len()); + } + any +} + +/// ¿Log de diagnóstico del over-layer activo? (`LLIMPHI_DEBUG_OVER=1`). Cacheado +/// para no pagar un `env::var` por capa por frame. +fn debug_over_enabled() -> bool { + use std::sync::OnceLock; + static D: OnceLock = OnceLock::new(); + *D.get_or_init(|| std::env::var("LLIMPHI_DEBUG_OVER").is_ok()) } /// Pinta la pasada vello "over" en `scene`: recorre el árbol en orden @@ -1264,6 +1983,18 @@ where F: Fn(&MountedNode) -> bool, { let mut hit: Option = None; + // Segundo candidato, para los subárboles [`View::over`]: `paint` los SALTA + // y `paint_over_layers` los pinta DESPUÉS, así que quedan encima de todo lo + // demás aunque su lugar en el árbol sea anterior. Sin esto, un nodo base + // posterior en orden de árbol le roba el click a un botón que el usuario ve + // arriba (caso real: la barra de control de media es `.over(true)`). + // El z efectivo es: [nodos normales, orden de árbol] < [capas over, orden de + // árbol], así que basta un candidato aparte y preferirlo al final. Las capas + // over anidadas las pinta la externa (`paint_over_layers` salta a + // `subtree_end`), o sea que no agregan un nivel de z: alcanza con saber si + // estamos DENTRO de alguna. + let mut hit_over: Option = None; + let mut over_end: usize = 0; let mut clip_stack: Vec = Vec::new(); // Espejo del stack de transformaciones de `paint`: `cur_xf` es el // producto acumulado de los `transform` de los ancestros activos @@ -1289,6 +2020,9 @@ where } } let node = &mounted.nodes[idx]; + if node.over_layer && idx >= over_end { + over_end = node.subtree_end; + } let Some(r) = computed.get(node.id) else { idx += 1; continue; @@ -1324,11 +2058,15 @@ where clip_stack.push(node.subtree_end); } if inside && pred(node) { - hit = Some(idx); + if idx < over_end { + hit_over = Some(idx); + } else { + hit = Some(idx); + } } idx += 1; } - hit + hit_over.or(hit) } /// Hit-test específico para clicks (incluye nodos draggables). @@ -1341,6 +2079,7 @@ pub fn hit_test_click( hit_test_pred(mounted, computed, x, y, |n| { n.on_click.is_some() || n.on_click_at.is_some() + || n.on_click_screen.is_some() || n.drag.is_some() || n.drag_at.is_some() || n.drag_velocity.is_some() @@ -1358,7 +2097,7 @@ pub fn hit_test_right_click( y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| { - n.on_right_click.is_some() || n.on_right_click_at.is_some() + n.on_right_click.is_some() || n.on_right_click_at.is_some() || n.on_right_click_screen.is_some() }) } @@ -1383,6 +2122,19 @@ pub fn hit_test_hover( hit_test_pred(mounted, computed, x, y, |n| n.hover_fill.is_some()) } +/// Hit-test específico para **tooltips** (nodos con `tooltip`). Separado del +/// hover: un nodo puede declarar tooltip sin `hover_fill` (p. ej. los iconos +/// fugaces de pata, que pintan por `paint_with` y no quieren tinte de hover) — +/// con el predicado de hover su tooltip jamás se encontraba. +pub fn hit_test_tooltip( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| n.tooltip.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. @@ -1501,7 +2253,7 @@ pub fn hit_test_scroll_chain( } clip_stack.push(node.subtree_end); } - if inside && node.on_scroll.is_some() { + if inside && (node.on_scroll.is_some() || node.on_scroll_take.is_some()) { chain.push(idx); } idx += 1; @@ -1512,6 +2264,61 @@ pub fn hit_test_scroll_chain( chain } +/// Reparte un evento de rueda a lo largo de la cadena de scroll (front→back, +/// de [`hit_test_scroll_chain`]). Para cada nodo: +/// +/// - con [`on_scroll_take`](crate::View::on_scroll_take): consume lo que +/// puede y el **sobrante** sigue al próximo de la cadena — un mismo evento +/// puede mover al hijo hasta su tope Y al ancestro con el resto; +/// - con `on_scroll` clásico: semántica histórica — `Some` consume TODO lo +/// restante y corta; `None` deja pasar el delta completo. +/// +/// Devuelve los `Msg` en orden (el runtime los aplica en secuencia sobre el +/// `update`). Vacío = nadie consumió (el evento cae al `on_wheel` global). +/// Pura respecto del árbol: no muta nada, testeable headless. +pub fn consume_scroll_chain( + mounted: &Mounted, + chain: &[usize], + dx: f32, + dy: f32, +) -> Vec { + const EPS: f32 = 1e-4; + let mut msgs = Vec::new(); + let (mut rx, mut ry) = (dx, dy); + for &idx in chain { + if rx.abs() <= EPS && ry.abs() <= EPS { + break; + } + let node = &mounted.nodes[idx]; + if let Some(take) = &node.on_scroll_take { + let (msg, nx, ny) = take(rx, ry); + if let Some(m) = msg { + msgs.push(m); + } + // Defensa: un handler no puede "crear" delta ni invertir su + // signo — el sobrante se acota al delta que recibió. + rx = clamp_rest(nx, rx); + ry = clamp_rest(ny, ry); + } else if let Some(h) = &node.on_scroll { + if let Some(m) = h(rx, ry) { + msgs.push(m); + break; + } + } + } + msgs +} + +/// Acota el sobrante declarado por un handler al rango `[0, delta]` (con el +/// signo del delta): ni negativo del otro lado, ni mayor que lo recibido. +fn clamp_rest(rest: f32, delta: f32) -> f32 { + if delta >= 0.0 { + rest.clamp(0.0, delta) + } else { + rest.clamp(delta, 0.0) + } +} + /// 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 @@ -1649,6 +2456,1004 @@ pub fn next_focus(order: &[u64], current: Option, reverse: bool) -> Option< Some(order[next_idx]) } +// ───────────────────────────────────────────────────────────────────────── +// RepaintBoundary — caché de rasterización de subárboles (Bloque 23 de +// PLAN-NUCLEO-RETENIDO.md). Ver `View::repaint_boundary`. +// +// Diseño *fail-safe*: `paint_range` queda INTACTO (cero regresión para todo el +// que no use boundaries). Toda la lógica vive en `paint_cached`, un wrapper que +// pinta los tramos no-boundary con el mismo `paint_range` de siempre e intercala +// el append (cacheado o fresco) de cada boundary. Ante cualquier duda sobre si +// un subárbol es cacheable, se pinta fresco → nunca produce un frame incorrecto. +// ───────────────────────────────────────────────────────────────────────── + +/// Caché de rasterización de los [`View::repaint_boundary`]. Vive en el runtime +/// entre frames. Cada entrada guarda la sub-escena vello del subárbol en coords +/// **locales** al origen del boundary + un hash de su contenido; mientras el +/// hash no cambie se reusa (`scene.append` trasladado al origen actual), así +/// sobrevive a un scroll/translación del subárbol sin re-pintar. +#[derive(Default)] +pub struct RepaintCache { + entries: HashMap, + seen: std::collections::HashSet, + /// Aciertos de caché acumulados (telemetría / tests). + pub hits: u64, + /// Fallos de caché acumulados (telemetría / tests). + pub misses: u64, +} + +struct CachedBoundary { + scene: vello::Scene, + hash: u64, +} + +impl RepaintCache { + pub fn new() -> Self { + Self::default() + } + /// Entradas vivas. + pub fn len(&self) -> usize { + self.entries.len() + } + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + /// Vacía el caché (p. ej. al cambiar el viewport o el tema). + pub fn clear(&mut self) { + self.entries.clear(); + self.seen.clear(); + } + fn begin_frame(&mut self) { + self.seen.clear(); + } + /// Descarta las entradas cuyo boundary no apareció este frame. + fn gc(&mut self) { + let seen = &self.seen; + self.entries.retain(|k, _| seen.contains(k)); + } +} + +/// `true` si algún nodo del árbol declara `repaint_boundary` (el runtime lo usa +/// para decidir si vale la pena `paint_cached` o alcanza con `paint`). +pub fn has_repaint_boundary(mounted: &Mounted) -> bool { + mounted.nodes.iter().any(|n| n.repaint_boundary.is_some()) +} + +/// ¿El nodo abre una capa vello / transform / scrollport que un `scene.append` +/// trasladado NO podría reproducir si fuera ancestro de un boundary? Si es +/// ancestro de un boundary, ese boundary NO está en posición "limpia". +fn opens_layer(n: &MountedNode) -> bool { + n.clip + || n.alpha.is_some() + || n.blend.is_some() + || n.transform.is_some() + || n.transform_rel.is_some() + || n.mask_image.is_some() + || n.sticky.is_some() + || n.over_layer + || n.clip_inset.is_some() + || n.clip_ellipse.is_some() + || n.clip_polygon.is_some() + || n.clip_path_svg.is_some() + || n.clip_ref_inset.is_some() +} + +/// Recolecta los `repaint_boundary` en posición **limpia** (sin capa ancestral +/// abierta), en pre-orden. Un boundary bajo un transform/clip/alpha NO se +/// recolecta → cae a un tramo normal y se pinta fresco. +fn collect_clean_boundaries(mounted: &Mounted) -> Vec<(usize, usize, u64)> { + let mut out = Vec::new(); + let mut layer_ends: Vec = Vec::new(); + for idx in 0..mounted.nodes.len() { + while layer_ends.last().is_some_and(|&e| idx >= e) { + layer_ends.pop(); + } + let node = &mounted.nodes[idx]; + if let Some(key) = node.repaint_boundary { + if layer_ends.is_empty() { + out.push((idx, node.subtree_end, key)); + } + } + // El propio boundary puede abrir su capa (clip/alpha propios): se registra + // DESPUÉS de recolectarlo (su capa es interna a su sub-escena). + if opens_layer(node) { + layer_ends.push(node.subtree_end); + } + } + out +} + +/// ¿El subárbol `[idx, end)` es cacheable? Paint-puro (sin closures de pintado +/// ni contenido dinámico) y sin el nodo hovereado/drop adentro. Conservador: +/// ante la duda `false` (se pinta fresco). Gradiente/imagen/máscara quedan fuera +/// en v1 (no se hashean de forma barata) — follow-up. +fn boundary_cacheable( + mounted: &Mounted, + idx: usize, + end: usize, + hover_idx: Option, + drop_hover_idx: Option, +) -> bool { + if hover_idx.is_some_and(|h| h >= idx && h < end) { + return false; + } + if drop_hover_idx.is_some_and(|d| d >= idx && d < end) { + return false; + } + mounted.nodes[idx..end].iter().all(|n| { + n.painter.is_none() + && n.gpu_painter.is_none() + && n.over_painter.is_none() + && n.fill_gradient.is_none() + && n.image.is_none() + && n.mask_image.is_none() + && n.anim.is_none() + && n.ripple.is_none() + && n.hero.is_none() + }) +} + +fn hash_color(c: &Color, h: &mut impl Hasher) { + for comp in c.components { + comp.to_bits().hash(h); + } +} + +/// Hash del contenido pintable del subárbol `[idx, end)`, con los rects +/// **relativos al origen** — así una traslación pura del boundary (scroll) deja +/// el hash igual y es cache-hit. Cubre las variantes que `boundary_cacheable` +/// deja pasar (fill/text/borde/sombra/radio/alpha/clip/transform propios). +fn boundary_hash( + mounted: &Mounted, + computed: &ComputedLayout, + idx: usize, + end: usize, + origin: (f32, f32), +) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + (end - idx).hash(&mut h); + for i in idx..end { + let n = &mounted.nodes[i]; + match computed.get(n.id) { + Some(r) => { + (r.x - origin.0).to_bits().hash(&mut h); + (r.y - origin.1).to_bits().hash(&mut h); + r.w.to_bits().hash(&mut h); + r.h.to_bits().hash(&mut h); + } + None => u32::MAX.hash(&mut h), + } + n.fill.is_some().hash(&mut h); + if let Some(c) = &n.fill { + hash_color(c, &mut h); + } + n.radius.to_bits().hash(&mut h); + if let Some(cr) = &n.corner_radii { + for v in [cr.top_left, cr.top_right, cr.bottom_right, cr.bottom_left] { + v.to_bits().hash(&mut h); + } + } + if let Some(b) = &n.border { + b.width.to_bits().hash(&mut h); + hash_color(&b.color, &mut h); + } + if let Some(s) = &n.shadow { + hash_color(&s.color, &mut h); + s.blur.to_bits().hash(&mut h); + s.spread.to_bits().hash(&mut h); + s.dx.to_bits().hash(&mut h); + s.dy.to_bits().hash(&mut h); + } + if let Some(a) = n.alpha { + a.to_bits().hash(&mut h); + } + n.clip.hash(&mut h); + if let Some(t) = n.transform { + for c in t.as_coeffs() { + c.to_bits().hash(&mut h); + } + } + if let Some((x, y)) = n.transform_rel { + x.to_bits().hash(&mut h); + y.to_bits().hash(&mut h); + } + if let Some(t) = &n.text { + t.content.hash(&mut h); + t.size_px.to_bits().hash(&mut h); + hash_color(&t.color, &mut h); + t.weight.to_bits().hash(&mut h); + t.line_height.to_bits().hash(&mut h); + t.italic.hash(&mut h); + (t.alignment as u8).hash(&mut h); + } + } + h.finish() +} + +/// Como [`paint`], pero **cachea la rasterización** de los subárboles marcados +/// con [`View::repaint_boundary`] que estén en posición limpia y sean +/// paint-puros. El resto del árbol (y los boundaries no cacheables) se pinta +/// idéntico a `paint`. Si el árbol no tiene boundaries, delega en `paint` — +/// coste ~0. +pub fn paint_cached( + scene: &mut vello::Scene, + mounted: &Mounted, + computed: &ComputedLayout, + typesetter: &mut llimphi_text::Typesetter, + hover_idx: Option, + drop_hover_idx: Option, + cache: &mut RepaintCache, +) { + cache.begin_frame(); + let boundaries = collect_clean_boundaries(mounted); + if boundaries.is_empty() { + cache.gc(); + paint(scene, mounted, computed, typesetter, hover_idx, drop_hover_idx); + return; + } + let n = mounted.nodes.len(); + let mut cursor = 0usize; + for (bidx, bend, key) in boundaries { + // Subsumido por un boundary externo ya procesado. + if bidx < cursor { + continue; + } + // Tramo previo al boundary: pintura normal. + if cursor < bidx { + paint_range( + scene, mounted, computed, typesetter, hover_idx, drop_hover_idx, cursor, bidx, + Affine::IDENTITY, + ); + } + let origin = computed + .get(mounted.nodes[bidx].id) + .map(|r| (r.x, r.y)) + .unwrap_or((0.0, 0.0)); + if boundary_cacheable(mounted, bidx, bend, hover_idx, drop_hover_idx) { + let hash = boundary_hash(mounted, computed, bidx, bend, origin); + let hit = cache + .entries + .get(&key) + .is_some_and(|c| c.hash == hash); + if hit { + cache.hits += 1; + } else { + cache.misses += 1; + // Rasterizá el subárbol a una sub-escena en coords LOCALES. + let mut sub = vello::Scene::new(); + paint_range( + &mut sub, mounted, computed, typesetter, hover_idx, drop_hover_idx, bidx, bend, + Affine::translate((-origin.0 as f64, -origin.1 as f64)), + ); + cache.entries.insert(key, CachedBoundary { scene: sub, hash }); + } + cache.seen.insert(key); + // Append trasladado al origen actual (sobrevive al scroll). + if let Some(c) = cache.entries.get(&key) { + scene.append( + &c.scene, + Some(Affine::translate((origin.0 as f64, origin.1 as f64))), + ); + } + } else { + // No cacheable → fresco inline (idéntico a `paint`). + paint_range( + scene, mounted, computed, typesetter, hover_idx, drop_hover_idx, bidx, bend, + Affine::IDENTITY, + ); + } + cursor = bend; + } + // Cola tras el último boundary. + if cursor < n { + paint_range( + scene, mounted, computed, typesetter, hover_idx, drop_hover_idx, cursor, n, + Affine::IDENTITY, + ); + } + cache.gc(); +} + +#[cfg(test)] +mod bloque25_harness { + //! Arnés del Bloque 25 (memo, enfoque A): genera árboles con nodos `memo`, + //! los muta, y verifica que el camino memoizado (`take_memo_builders` + mount + //! + compute + `splice_memos`) da **exactamente el mismo `Mounted`** (rects, + //! nodo a nodo en pre-orden) que el camino fresco (memo expandido inline). + //! Como los memos NO tocados no re-buildean, también ejercita el reuso. + use super::*; + use crate::{mount, View}; + use llimphi_layout::taffy::prelude::*; + use llimphi_layout::LayoutTree; + use std::hash::{Hash, Hasher}; + + struct Rng(u64); + impl Rng { + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.0 = x; + x + } + fn range(&mut self, n: usize) -> usize { + (self.next() % (n as u64).max(1)) as usize + } + } + + #[derive(Clone)] + enum Spec { + Leaf { w: f32, h: f32, text: Option }, + Row(Vec), + Col(Vec), + /// Nodo memo de tamaño definido `w×h` envolviendo `content` (sin memos + /// anidados en v1). `deps` se deriva de `content` en `build`. + Memo { key: u64, w: f32, h: f32, content: Box }, + } + + fn spec_hash(s: &Spec) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + fn go(s: &Spec, h: &mut impl Hasher) { + match s { + Spec::Leaf { w, h: hh, text } => { + 0u8.hash(h); + w.to_bits().hash(h); + hh.to_bits().hash(h); + text.hash(h); + } + Spec::Row(k) => { + 1u8.hash(h); + for c in k { + go(c, h); + } + } + Spec::Col(k) => { + 2u8.hash(h); + for c in k { + go(c, h); + } + } + Spec::Memo { key, w, h: hh, content } => { + 3u8.hash(h); + key.hash(h); + w.to_bits().hash(h); + hh.to_bits().hash(h); + go(content, h); + } + } + } + go(s, &mut h); + h.finish() + } + + fn gen(rng: &mut Rng, depth: usize, allow_memo: bool, next_key: &mut u64) -> Spec { + if depth == 0 || rng.range(100) < 35 { + let text = if rng.range(2) == 0 { + Some(format!("n{}", rng.range(1000))) + } else { + None + }; + Spec::Leaf { + w: 20.0 + rng.range(60) as f32, + h: 12.0 + rng.range(30) as f32, + text, + } + } else if allow_memo && rng.range(100) < 35 { + let content = gen(rng, depth - 1, false, next_key); + let key = *next_key; + *next_key += 1; + Spec::Memo { + key, + w: 120.0 + rng.range(120) as f32, + h: 120.0 + rng.range(120) as f32, + content: Box::new(content), + } + } else { + let n = 1 + rng.range(3); + let kids = (0..n).map(|_| gen(rng, depth - 1, allow_memo, next_key)).collect(); + if rng.range(2) == 0 { + Spec::Row(kids) + } else { + Spec::Col(kids) + } + } + } + + /// Muta manteniendo la forma (cambia valores; a veces el contenido de un memo + /// → su `deps` cambia → rebuild; a veces no → reuso). + fn mutate(s: &mut Spec, rng: &mut Rng) { + match s { + Spec::Leaf { w, h, text } => match rng.range(4) { + 0 => *w = 20.0 + rng.range(60) as f32, + 1 => *h = 12.0 + rng.range(30) as f32, + 2 => *text = Some(format!("m{}", rng.range(1000))), + _ => {} + }, + Spec::Row(k) | Spec::Col(k) => { + for c in k { + mutate(c, rng); + } + } + Spec::Memo { content, .. } => { + // 50% muta el contenido (→ rebuild), 50% lo deja (→ reuso). + if rng.range(2) == 0 { + mutate(content, rng); + } + } + } + } + + fn build(s: &Spec, memoize: bool) -> View<()> { + match s { + Spec::Leaf { w, h, text } => { + let st = Style { + size: Size { + width: length(*w), + height: if text.is_some() { auto() } else { length(*h) }, + }, + flex_shrink: 0.0, + ..Default::default() + }; + let v = View::<()>::new(st); + match text { + Some(t) => v.text(t.clone(), 14.0, Color::WHITE), + None => v, + } + } + Spec::Row(k) | Spec::Col(k) => { + let row = matches!(s, Spec::Row(_)); + View::<()>::new(Style { + flex_direction: if row { + FlexDirection::Row + } else { + FlexDirection::Column + }, + ..Default::default() + }) + .children(k.iter().map(|c| build(c, memoize)).collect()) + } + Spec::Memo { key, w, h, content } => { + let st = Style { + size: Size { width: length(*w), height: length(*h) }, + flex_shrink: 0.0, + ..Default::default() + }; + if memoize { + let c = (**content).clone(); + let deps = spec_hash(content); + View::<()>::new(st).memo(*key, deps, move || { + MEMO_BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + build(&c, true) + }) + } else { + View::<()>::new(st).children(vec![build(content, false)]) + } + } + } + } + + fn fake_measure( + tm: &TextMeasure, + known: Size>, + avail: Size, + ) -> Size { + let chars = tm.content.chars().count().max(1) as f32; + let gw = tm.size_px * 0.5; + let ideal = chars * gw; + let maxw = match known.width { + Some(w) => w, + None => match avail.width { + AvailableSpace::Definite(w) => w, + AvailableSpace::MaxContent => f32::INFINITY, + AvailableSpace::MinContent => gw, + }, + }; + let w = ideal.min(maxw).max(gw); + let lines = if w > 0.0 { (ideal / w).ceil().max(1.0) } else { 1.0 }; + Size { + width: w, + height: lines * tm.size_px * tm.line_height.max(1.0), + } + } + + static MEMO_BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + #[test] + fn memo_igual_a_fresco_fuzz() { + use std::sync::atomic::Ordering; + MEMO_BUILDS.store(0, Ordering::Relaxed); + let mut opportunities = 0u64; + for seed in 0..40u64 { + opportunities += run_seed(0xD1B5_4A32_D192_ED03 ^ seed.wrapping_mul(0x100_0193)); + } + let builds = MEMO_BUILDS.load(Ordering::Relaxed); + // Correctitud ya asertada dentro de run_seed. Aquí probamos el REUSO: el + // builder se llamó MENOS veces que oportunidades (markers·frames) → hubo + // memos reusados sin reconstruir; y >0 → los cambios sí rebuildean. + assert!(builds > 0, "algún memo debe rebuildear (builds={builds})"); + assert!( + builds < opportunities, + "el reuso debe ocurrir (builds={builds} de {opportunities} oportunidades)" + ); + println!("[bloque25] seeds=40 builds={builds}/{opportunities} oportunidades (resto reusado); rects == fresco (±1px)"); + } + + /// Devuelve la cantidad de oportunidades de memo (Σ markers por frame). + fn run_seed(seed: u64) -> u64 { + let mut rng = Rng(seed | 1); + let mut next_key = 1u64; + let mut spec = gen(&mut rng, 5, true, &mut next_key); + let mut reg: MemoRegistry<()> = MemoRegistry::new(); + let vp = (900.0, 700.0); + let mut opportunities = 0u64; + + for frame in 0..60 { + if frame > 0 { + mutate(&mut spec, &mut rng); + } + // Fresco: memos expandidos inline. + let mut ft = LayoutTree::new(); + let mf = mount(&mut ft, build(&spec, false)); + let cf = { + let tm = &mf.text_measures; + ft.compute_with_measure(mf.root, vp, |nid, k, a| match tm.get(&nid) { + Some(t) => fake_measure(t, k, a), + None => Size { width: 0.0, height: 0.0 }, + }) + .unwrap() + }; + // Memoizado. + let mut vm = build(&spec, true); + let mut pending = Vec::new(); + take_memo_builders(&mut vm, &mut pending); + opportunities += pending.len() as u64; + let mut mt = LayoutTree::new(); + let mut mm = mount(&mut mt, vm); + let mut cm = { + let tm = &mm.text_measures; + mt.compute_with_measure(mm.root, vp, |nid, k, a| match tm.get(&nid) { + Some(t) => fake_measure(t, k, a), + None => Size { width: 0.0, height: 0.0 }, + }) + .unwrap() + }; + splice_memos(&mut mm, &mut cm, &pending, &mut reg, fake_measure); + + // Tolerancia 1px: taffy redondea el layout a píxel de forma + // DEPENDIENTE de la posición absoluta (alinea bordes a la grilla). El + // subárbol aislado se computa en base-0 y se offsetea, así que su + // redondeo puede diferir del fresco (computado en la posición real) + // por ≤1px en tamaños. Es acotado, invisible, y CONSISTENTE en una + // app real (siempre se usa el camino memo, nunca se alterna con el + // fresco → sin jitter). La ESTRUCTURA y las posiciones coinciden. + let tol = 1.01_f32; + let mut mismatch = mf.nodes.len() != mm.nodes.len(); + if !mismatch { + for i in 0..mf.nodes.len() { + let a = cf.get(mf.nodes[i].id).unwrap(); + let b = cm.get(mm.nodes[i].id).unwrap(); + if (a.x - b.x).abs() > tol + || (a.y - b.y).abs() > tol + || (a.w - b.w).abs() > tol + || (a.h - b.h).abs() > tol + { + mismatch = true; + break; + } + } + } + if mismatch && std::env::var("MEMO_DEBUG").is_ok() { + eprintln!("### seed={seed:#x} frame={frame} ###"); + eprintln!("--- FRESCO ({} nodos) ---", mf.nodes.len()); + for (i, n) in mf.nodes.iter().enumerate() { + eprintln!(" {i}: memo_ref={:?} end={} rect={:?}", n.memo_ref, n.subtree_end, cf.get(n.id)); + } + eprintln!("--- MEMO ({} nodos) ---", mm.nodes.len()); + for (i, n) in mm.nodes.iter().enumerate() { + eprintln!(" {i}: memo_ref={:?} end={} rect={:?}", n.memo_ref, n.subtree_end, cm.get(n.id)); + } + panic!("dump"); + } + assert!(!mismatch, "seed {seed:#x} frame {frame}: memo != fresco"); + } + opportunities + } +} + +#[cfg(test)] +mod bloque24_harness { + //! Arnés de equivalencia del Bloque 24: genera árboles al azar, los muta + //! (estilo, texto, estructura) y verifica que `mount_incremental` (retenido) + //! da **exactamente los mismos rects** que `mount` fresco, frame a frame. + //! Es la red de seguridad ANTES de cablear el layout retenido a la ruta + //! caliente. La medida es falsa pero DETERMINISTA y depende del texto, así + //! que si el reuso no marcara dirty un texto cambiado, los rects diferirían + //! y el test fallaría. + use super::*; + use crate::{mount, View}; + use llimphi_layout::taffy::prelude::*; + use llimphi_layout::LayoutTree; + + struct Rng(u64); + impl Rng { + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.0 = x; + x + } + fn range(&mut self, n: usize) -> usize { + (self.next() % (n as u64).max(1)) as usize + } + } + + #[derive(Clone)] + enum Spec { + Leaf { w: f32, h: f32, text: Option }, + Row(Vec), + Col(Vec), + } + + fn gen_spec(rng: &mut Rng, depth: usize) -> Spec { + if depth == 0 || rng.range(100) < 40 { + let text = if rng.range(2) == 0 { + Some(format!("n{}", rng.range(1000))) + } else { + None + }; + Spec::Leaf { + w: 20.0 + rng.range(80) as f32, + h: 12.0 + rng.range(40) as f32, + text, + } + } else { + let n = 1 + rng.range(4); + let kids = (0..n).map(|_| gen_spec(rng, depth - 1)).collect(); + if rng.range(2) == 0 { + Spec::Row(kids) + } else { + Spec::Col(kids) + } + } + } + + /// Muta manteniendo la FORMA (child-counts) → ejercita el camino de reuso. + fn mutate_style(s: &mut Spec, rng: &mut Rng) { + match s { + Spec::Leaf { w, h, text } => match rng.range(4) { + 0 => *w = 20.0 + rng.range(80) as f32, + 1 => *h = 12.0 + rng.range(40) as f32, + 2 => *text = Some(format!("m{}", rng.range(1000))), + _ => {} + }, + Spec::Row(k) | Spec::Col(k) => { + for c in k { + mutate_style(c, rng); + } + } + } + } + + /// Cambia la FORMA (agrega/quita hijos) → fuerza el camino fresco. + fn mutate_structure(s: &mut Spec, rng: &mut Rng) { + match s { + Spec::Leaf { .. } => *s = gen_spec(rng, 3), + Spec::Row(k) | Spec::Col(k) => { + if k.len() > 1 && rng.range(2) == 0 { + let i = rng.range(k.len()); + k.remove(i); + } else { + let i = rng.range(k.len() + 1); + k.insert(i, gen_spec(rng, 2)); + } + } + } + } + + fn build(s: &Spec) -> View<()> { + match s { + Spec::Leaf { w, h, text } => { + let st = Style { + size: Size { + width: length(*w), + height: if text.is_some() { auto() } else { length(*h) }, + }, + flex_shrink: 0.0, + ..Default::default() + }; + let v = View::<()>::new(st); + match text { + Some(t) => v.text(t.clone(), 14.0, Color::WHITE), + None => v, + } + } + Spec::Row(k) | Spec::Col(k) => { + let row = matches!(s, Spec::Row(_)); + View::<()>::new(Style { + flex_direction: if row { + FlexDirection::Row + } else { + FlexDirection::Column + }, + ..Default::default() + }) + .children(k.iter().map(build).collect()) + } + } + } + + /// Medida FALSA pero determinista: ancho ∝ nº de chars, alto por wrapping. + /// Depende del contenido → un cambio de texto altera la medida. + fn fake_measure( + tmap: &std::collections::HashMap, + nid: NodeId, + known: Size>, + avail: Size, + ) -> Size { + let Some(tm) = tmap.get(&nid) else { + return Size { width: 0.0, height: 0.0 }; + }; + let chars = tm.content.chars().count().max(1) as f32; + let gw = tm.size_px * 0.5; + let ideal = chars * gw; + let maxw = match known.width { + Some(w) => w, + None => match avail.width { + AvailableSpace::Definite(w) => w, + AvailableSpace::MaxContent => f32::INFINITY, + AvailableSpace::MinContent => gw, + }, + }; + let w = ideal.min(maxw).max(gw); + let lines = if w > 0.0 { (ideal / w).ceil().max(1.0) } else { 1.0 }; + Size { + width: w, + height: lines * tm.size_px * tm.line_height.max(1.0), + } + } + + #[test] + fn retenido_igual_a_fresco_fuzz() { + // Muchas semillas → árboles y secuencias de mutación variados. + let mut tot_reused = 0u32; + let mut tot_fresh = 0u32; + for seed in 0..40u64 { + let (r, f) = run_seed(0x9E37_79B9_7F4A_7C15 ^ seed.wrapping_mul(0x1000_0001B3)); + tot_reused += r; + tot_fresh += f; + } + assert!(tot_reused > 30 * 40, "el reuso debe dominar (reused={tot_reused})"); + assert!(tot_fresh > 5 * 40, "y el rebuild también (fresh={tot_fresh})"); + println!("[bloque24] seeds=40 reused={tot_reused} fresh={tot_fresh} (todos los rects == fresco)"); + } + + fn run_seed(seed: u64) -> (u32, u32) { + let mut rng = Rng(seed | 1); + let mut spec = gen_spec(&mut rng, 4); + let mut ret_tree = LayoutTree::new(); + let mut prev: Option = None; + let mut reused = 0; + let mut fresh = 0; + + for frame in 0..80 { + if frame > 0 { + if frame % 6 == 0 { + mutate_structure(&mut spec, &mut rng); + } else { + mutate_style(&mut spec, &mut rng); + } + } + // Viewport variable: prueba que el reuso recoge cambios de + // available-space (resize) aunque no marque dirty (taffy re-resuelve + // por cambio de espacio disponible). + let vp = match frame % 4 { + 0 => (800.0, 600.0), + 1 => (640.0, 600.0), + 2 => (900.0, 500.0), + _ => (720.0, 680.0), + }; + + // Camino fresco de referencia. + let mut fresh_tree = LayoutTree::new(); + let mf = mount(&mut fresh_tree, build(&spec)); + let cf = { + let tf = &mf.text_measures; + fresh_tree + .compute_with_measure(mf.root, vp, |nid, k, a| fake_measure(tf, nid, k, a)) + .unwrap() + }; + + // Camino retenido. + let (mr, retained, did_reuse) = + mount_incremental(&mut ret_tree, build(&spec), prev.as_ref()); + let cr = { + let tr = &mr.text_measures; + ret_tree + .compute_with_measure(mr.root, vp, |nid, k, a| fake_measure(tr, nid, k, a)) + .unwrap() + }; + if did_reuse { + reused += 1; + } else { + fresh += 1; + } + + assert_eq!(mf.nodes.len(), mr.nodes.len(), "frame {frame}: nº de nodos"); + for i in 0..mf.nodes.len() { + let a = cf.get(mf.nodes[i].id).expect("rect fresco"); + let b = cr.get(mr.nodes[i].id).expect("rect retenido"); + assert!( + (a.x - b.x).abs() < 1e-3 + && (a.y - b.y).abs() < 1e-3 + && (a.w - b.w).abs() < 1e-3 + && (a.h - b.h).abs() < 1e-3, + "frame {frame} nodo {i} reuse={did_reuse}: fresco {a:?} != retenido {b:?}" + ); + } + prev = Some(retained); + } + (reused, fresh) + } +} + +#[cfg(test)] +mod repaint_boundary_tests { + use super::*; + use crate::{mount, View}; + use llimphi_layout::taffy::prelude::*; + use llimphi_layout::LayoutTree; + + fn mount_compute(v: View<()>, vp: (f32, f32)) -> (Mounted<()>, ComputedLayout) { + let mut lt = LayoutTree::new(); + let m = mount(&mut lt, v); + let c = lt.compute(m.root, vp).expect("layout"); + (m, c) + } + + /// Un boundary top-level se recolecta; uno bajo un `clip` no (no está limpio). + #[test] + fn recolecta_solo_boundaries_limpios() { + let limpio = View::<()>::new(Style::default()) + .repaint_boundary(1) + .children(vec![View::new(Style::default()).fill(Color::WHITE)]); + let (m, _) = mount_compute( + View::<()>::new(Style::default()).children(vec![limpio]), + (200.0, 200.0), + ); + assert_eq!(collect_clean_boundaries(&m).len(), 1); + + // Ahora el mismo boundary DENTRO de un nodo con clip → no limpio. + let dentro_de_clip = View::<()>::new(Style::default()).clip(true).children(vec![ + View::<()>::new(Style::default()) + .repaint_boundary(2) + .children(vec![View::new(Style::default()).fill(Color::WHITE)]), + ]); + let (m2, _) = mount_compute(dentro_de_clip, (200.0, 200.0)); + assert_eq!(collect_clean_boundaries(&m2).len(), 0); + } + + /// El gate rechaza subárboles con painter / gradiente / hover adentro. + #[test] + fn gate_rechaza_no_cacheables() { + // painter → no cacheable. + let con_painter = View::<()>::new(Style::default()) + .paint_with(|_s, _t, _r| {}); + let (m, _) = mount_compute( + View::<()>::new(Style::default()) + .repaint_boundary(1) + .children(vec![con_painter]), + (100.0, 100.0), + ); + assert!(!boundary_cacheable(&m, 0, m.nodes.len(), None, None)); + + // Fill/texto puro → cacheable. + let puro = View::<()>::new(Style::default()) + .repaint_boundary(1) + .fill(Color::WHITE) + .children(vec![View::new(Style::default()).fill(Color::BLACK)]); + let (m2, _) = mount_compute(puro, (100.0, 100.0)); + assert!(boundary_cacheable(&m2, 0, m2.nodes.len(), None, None)); + // …salvo que el hover caiga adentro. + assert!(!boundary_cacheable(&m2, 0, m2.nodes.len(), Some(1), None)); + } + + /// El hash es estable ante un rebuild idéntico, cambia con el fill, y NO + /// cambia bajo una traslación pura del boundary (rects relativos). + #[test] + fn hash_estable_sensible_y_translation_invariante() { + let build = |fill: Color, x: f32| { + View::<()>::new(Style { + inset: Rect { left: length(x), top: length(0.0), right: auto(), bottom: auto() }, + position: Position::Absolute, + size: Size { width: length(50.0), height: length(50.0) }, + ..Default::default() + }) + .repaint_boundary(1) + .fill(fill) + .children(vec![View::new(Style { + size: Size { width: length(20.0), height: length(20.0) }, + ..Default::default() + }) + .fill(Color::BLACK)]) + }; + let vp = (300.0, 300.0); + let (m_a, c_a) = mount_compute(build(Color::WHITE, 0.0), vp); + let o_a = c_a.get(m_a.nodes[0].id).map(|r| (r.x, r.y)).unwrap(); + let h_a = boundary_hash(&m_a, &c_a, 0, m_a.nodes.len(), o_a); + + // Rebuild idéntico → mismo hash. + let (m_b, c_b) = mount_compute(build(Color::WHITE, 0.0), vp); + let o_b = c_b.get(m_b.nodes[0].id).map(|r| (r.x, r.y)).unwrap(); + assert_eq!(h_a, boundary_hash(&m_b, &c_b, 0, m_b.nodes.len(), o_b)); + + // Cambió el fill → hash distinto. + let (m_c, c_c) = mount_compute(build(Color::from_rgba8(1, 2, 3, 255), 0.0), vp); + let o_c = c_c.get(m_c.nodes[0].id).map(|r| (r.x, r.y)).unwrap(); + assert_ne!(h_a, boundary_hash(&m_c, &c_c, 0, m_c.nodes.len(), o_c)); + + // Trasladado 100px en x → MISMO hash (rects relativos al origen). + let (m_d, c_d) = mount_compute(build(Color::WHITE, 100.0), vp); + let o_d = c_d.get(m_d.nodes[0].id).map(|r| (r.x, r.y)).unwrap(); + assert_eq!(h_a, boundary_hash(&m_d, &c_d, 0, m_d.nodes.len(), o_d)); + } + + /// End-to-end (CPU, sin GPU): pintar dos veces el mismo árbol con boundary + /// da 1 miss + 1 hit; cambiar el fill fuerza otro miss. + #[test] + fn paint_cached_hit_y_miss() { + let mut ts = llimphi_text::Typesetter::new(); + let mut cache = RepaintCache::new(); + let build = |fill: Color| { + View::<()>::new(Style { + size: Size { width: percent(1.0), height: percent(1.0) }, + ..Default::default() + }) + .children(vec![View::<()>::new(Style { + size: Size { width: length(80.0), height: length(80.0) }, + ..Default::default() + }) + .repaint_boundary(7) + .fill(fill) + .children(vec![View::new(Style { + size: Size { width: length(30.0), height: length(30.0) }, + ..Default::default() + }) + .fill(Color::BLACK)])]) + }; + let vp = (200.0, 200.0); + + let (m1, c1) = mount_compute(build(Color::WHITE), vp); + let mut s1 = vello::Scene::new(); + paint_cached(&mut s1, &m1, &c1, &mut ts, None, None, &mut cache); + assert_eq!((cache.hits, cache.misses), (0, 1), "primer frame = miss"); + assert_eq!(cache.len(), 1); + + // Mismo contenido → hit. + let (m2, c2) = mount_compute(build(Color::WHITE), vp); + let mut s2 = vello::Scene::new(); + paint_cached(&mut s2, &m2, &c2, &mut ts, None, None, &mut cache); + assert_eq!((cache.hits, cache.misses), (1, 1), "segundo frame igual = hit"); + + // Cambió el fill → miss de nuevo. + let (m3, c3) = mount_compute(build(Color::from_rgba8(9, 9, 9, 255)), vp); + let mut s3 = vello::Scene::new(); + paint_cached(&mut s3, &m3, &c3, &mut ts, None, None, &mut cache); + assert_eq!((cache.hits, cache.misses), (1, 2), "cambio de fill = miss"); + + // Un frame SIN boundary limpia el caché por GC. + let (m4, c4) = mount_compute( + View::<()>::new(Style::default()).fill(Color::WHITE), + vp, + ); + let mut s4 = vello::Scene::new(); + paint_cached(&mut s4, &m4, &c4, &mut ts, None, None, &mut cache); + assert_eq!(cache.len(), 0, "boundary ausente → GC"); + } +} + #[cfg(test)] mod tests { use crate::{hit_test_click, mount, View}; @@ -1656,6 +3461,217 @@ mod tests { use llimphi_layout::{LayoutTree, Style}; use vello::kurbo::Affine; + #[test] + fn sticky_top_se_pega_al_scrollport_al_pasar_el_umbral() { + use super::sticky_offset; + use llimphi_layout::Rect; + // Scrollport = el área de página (arranca en y=50, alto 400). + let sp = (0.0, 50.0, 300.0, 400.0); + let top = [Some(0.0), None, None, None]; // top:0 relativo al scrollport + // En flujo, por debajo del umbral (y=200 > 50): sin enganche. + let en_flujo = Rect { x: 0.0, y: 200.0, w: 300.0, h: 40.0 }; + assert_eq!(sticky_offset(top, en_flujo, sp), (0.0, 0.0)); + // Justo en el umbral (y == scrollport.y): sin enganche todavía. + let en_umbral = Rect { x: 0.0, y: 50.0, w: 300.0, h: 40.0 }; + assert_eq!(sticky_offset(top, en_umbral, sp), (0.0, 0.0)); + // Scrolleado por encima del umbral (y=10 < 50): se pega, dy = 50-10 = 40. + let scrolleado = Rect { x: 0.0, y: 10.0, w: 300.0, h: 40.0 }; + let (dx, dy) = sticky_offset(top, scrolleado, sp); + assert_eq!(dx, 0.0); + assert!((dy - 40.0).abs() < 1e-4, "dy debe pegarlo a y=50 (dy=40), es {dy}"); + // Aún más arriba (y=-90): dy = 50-(-90) = 140 → borde superior en 50. + let muy_arriba = Rect { x: 0.0, y: -90.0, w: 300.0, h: 40.0 }; + assert!((sticky_offset(top, muy_arriba, sp).1 - 140.0).abs() < 1e-4); + } + + #[test] + fn sticky_bottom_y_precedencia_top() { + use super::sticky_offset; + use llimphi_layout::Rect; + let sp = (0.0, 0.0, 300.0, 200.0); // scrollport 0..200 en y + // bottom:0 → pega el borde inferior a y=200 cuando lo pasa. + let bottom = [None, None, Some(0.0), None]; + let paso = Rect { x: 0.0, y: 180.0, w: 300.0, h: 40.0 }; // bottom=220 > 200 + let (_dx, dy) = sticky_offset(bottom, paso, sp); + assert!((dy - (-20.0)).abs() < 1e-4, "empuja hacia arriba 20 (bottom a 200), es {dy}"); + // top gana sobre bottom cuando ambos disparan. + let ambos = [Some(0.0), None, Some(0.0), None]; + let arriba = Rect { x: 0.0, y: -30.0, w: 300.0, h: 40.0 }; + let (_x, dy2) = sticky_offset(ambos, arriba, sp); + assert!((dy2 - 30.0).abs() < 1e-4, "top gana: dy=30 (pega a y=0), es {dy2}"); + } + + #[test] + fn over_subtree_salta_la_base_y_pinta_en_la_pasada_over() { + // View::over(true): el subárbol NO pinta en la pasada base (paint) y + // SÍ pinta completo en la pasada over (paint_over_layers) — el + // contrato que permite UI encima de contenido gpu_paint_with. + use super::{has_over_painter, paint, paint_over_layers}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let base_hits = Arc::new(AtomicUsize::new(0)); + let over_hits = Arc::new(AtomicUsize::new(0)); + let (b, o) = (base_hits.clone(), over_hits.clone()); + let sz = |w: f32, h: f32| Style { + size: Size { width: length(w), height: length(h) }, + ..Default::default() + }; + let tree: View<()> = View::new(sz(200.0, 200.0)).children(vec![ + View::new(sz(100.0, 100.0)) + .paint_with(move |_s, _t, _r| { b.fetch_add(1, Ordering::Relaxed); }), + View::new(sz(100.0, 100.0)).over(true).children(vec![View::new(sz(50.0, 50.0)) + .paint_with(move |_s, _t, _r| { o.fetch_add(1, Ordering::Relaxed); })]), + ]); + + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, tree); + assert!(has_over_painter(&mounted), "el flag over debe activar la pasada"); + let computed = layout + .compute_with_measure(mounted.root, (400.0, 400.0), |_, _, _| { + llimphi_layout::taffy::prelude::Size::ZERO + }) + .expect("layout"); + let mut ts = llimphi_text::Typesetter::new(); + + // Pasada base: pinta el hermano normal, saltea el subárbol over. + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + assert_eq!(base_hits.load(Ordering::Relaxed), 1, "el nodo normal pinta en base"); + assert_eq!(over_hits.load(Ordering::Relaxed), 0, "el subárbol over NO pinta en base"); + + // Pasada over: pinta el subárbol completo (y sólo él). + let mut over_scene = vello::Scene::new(); + let any = paint_over_layers(&mut over_scene, &mounted, &computed, &mut ts, None, None); + assert!(any, "hay un subárbol over"); + assert_eq!(over_hits.load(Ordering::Relaxed), 1, "el subárbol over pinta en la pasada over"); + assert_eq!(base_hits.load(Ordering::Relaxed), 1, "el normal no se repinta en over"); + } + + #[test] + fn dos_capas_over_hermano_y_anidada_ambas_pintan() { + // Estructura de media: sidebar `.over` como HERMANO, y la barra `.over` + // ANIDADA dentro de otro hermano (content → canvas → barra). El bug del + // "no se ve con el sidebar abierto" sería que `paint_over_layers`, al + // saltar a `subtree_end` de la primera capa, se comiera la segunda. + use super::{paint_over_layers}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let sidebar_hits = Arc::new(AtomicUsize::new(0)); + let bar_hits = Arc::new(AtomicUsize::new(0)); + let (s, b) = (sidebar_hits.clone(), bar_hits.clone()); + let sz = |w: f32, h: f32| Style { + size: Size { width: length(w), height: length(h) }, + ..Default::default() + }; + // root[ sidebar(.over)[panel] , content[ canvas[ bar(.over) ] ] ] + let tree: View<()> = View::new(sz(400.0, 300.0)).children(vec![ + View::new(sz(100.0, 300.0)).over(true).children(vec![ + View::new(sz(80.0, 280.0)).paint_with(move |_s, _t, _r| { + s.fetch_add(1, Ordering::Relaxed); + }), + ]), + View::new(sz(300.0, 300.0)).children(vec![ + View::new(sz(300.0, 300.0)).children(vec![ + View::new(sz(280.0, 44.0)).over(true).paint_with(move |_s, _t, _r| { + b.fetch_add(1, Ordering::Relaxed); + }), + ]), + ]), + ]); + + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, tree); + let computed = layout + .compute_with_measure(mounted.root, (400.0, 300.0), |_, _, _| { + llimphi_layout::taffy::prelude::Size::ZERO + }) + .expect("layout"); + let mut ts = llimphi_text::Typesetter::new(); + let mut over_scene = vello::Scene::new(); + paint_over_layers(&mut over_scene, &mounted, &computed, &mut ts, None, None); + assert_eq!(sidebar_hits.load(Ordering::Relaxed), 1, "la capa over del sidebar pinta"); + assert_eq!( + bar_hits.load(Ordering::Relaxed), + 1, + "la capa over ANIDADA (barra) también pinta — no se la come el subtree_end del sidebar" + ); + } + + #[test] + fn intrinsic_incluye_espacio_final_evita_wrap_fantasma() { + // Bug: un texto con ESPACIO FINAL medido a max-content (shrink-to-fit) + // recibía caja = ceil(width), pero parley EXCLUYE la ws de fin del + // `width()`. El paint re-shapea a `caja + slack` y ahí el avance del + // espacio SÍ cuenta → parte a una 2ª línea fantasma que la medida + // reservó como 1 → clip. El fix mide el intrínseco con `full_width` + // (incluye el espacio), así la caja es ≥ ancho+espacio y el paint no + // parte. Verificado end-to-end: medir a MaxContent y re-medir a + // Definite(caja + slack) debe dar el MISMO alto (una línea). + use super::{measure_text_node, TEXT_WRAP_ROUNDING_SLACK}; + use crate::TextMeasure; + use llimphi_layout::taffy::{AvailableSpace, Size}; + + let tm = |content: &str| TextMeasure { + content: content.to_string(), + size_px: 16.0, + alignment: llimphi_text::Alignment::Start, + italic: false, + font_family: None, + line_height: 1.2, + weight: 400.0, + max_lines: None, + ellipsis: false, + underline: false, + strikethrough: false, + spans: None, + letter_spacing: 0.0, + word_spacing: 0.0, + no_wrap: false, + overflow_wrap: false, + font_features: String::new(), + font_variations: String::new(), + font_width: 100.0, + }; + let mut ts = llimphi_text::Typesetter::new(); + let max_content = Size { + width: AvailableSpace::MaxContent, + height: AvailableSpace::MaxContent, + }; + let none = Size { width: None, height: None }; + + for content in ["archived mailing list ", "one two three four five ", "Houdini "] { + // 1) Medida intrínseca (max-content), como la usa taffy para la caja. + let intr = measure_text_node(&mut ts, &tm(content), none, max_content); + let box_w = intr.width; // ya viene ceil'd por el fix + // 2) Paint real: re-shape a Definite(caja + slack). + let painted = measure_text_node( + &mut ts, + &tm(content), + Size { width: Some(box_w + TEXT_WRAP_ROUNDING_SLACK), height: None }, + Size { + width: AvailableSpace::Definite(box_w + TEXT_WRAP_ROUNDING_SLACK), + height: AvailableSpace::MaxContent, + }, + ); + assert!( + (painted.height - intr.height).abs() < 0.6, + "{content:?}: el paint no debe partir a 2 líneas — intr h={:.1} caja={:.1} paint h={:.1}", + intr.height, box_w, painted.height + ); + } + + // Control: SIN espacio final la caja no cambia (full_width == width). + let sin = measure_text_node(&mut ts, &tm("archived mailing list"), none, max_content); + let con = measure_text_node(&mut ts, &tm("archived mailing list "), none, max_content); + assert!( + con.width > sin.width + 1.0, + "el espacio final debe ensanchar la caja intrínseca: sin={:.1} con={:.1}", + sin.width, con.width + ); + } + #[test] fn transform_origin_fija_el_pivote() { // El pivote de `transform-origin` queda FIJO bajo la transformación: @@ -1720,6 +3736,61 @@ mod tests { ); } + /// El slack de redondeo del paint absorbe la pérdida de hasta ~1px que + /// taffy introduce al redondear los bordes de una caja encogida-a-contenido. + /// Regresión de "«Learn more» envuelve a 2 líneas": la MEDIDA dimensiona el + /// nodo a `nat` (una línea), taffy lo deja en `nat - d` (con `d ≤ 1.0` por + /// redondeo), y el paint —si envolviera exactamente a ese ancho— partiría la + /// última palabra a una 2ª línea que desborda. Test font-independiente: + /// creamos un déficit `d` dentro del rango de taffy y verificamos que sin + /// slack envuelve y CON slack no. + #[test] + fn slack_de_redondeo_evita_wrap_espurio_en_texto_encogido() { + use llimphi_text::{Alignment, Typesetter}; + let mut ts = Typesetter::new(); + let clamp = |ts: &mut Typesetter, mw: Option| { + ts.layout_clamped( + "Learn more", + 16.0, + mw, + Alignment::Start, + 1.2, + false, + None, + 400.0, + None, + false, + false, + false, + 0.0, + 0.0, + false, + "", + "", + 100.0, + ) + }; + // Ancho natural (sin límite): una sola línea. + let nat = llimphi_text::measurement(&clamp(&mut ts, None)).width; + assert_eq!(clamp(&mut ts, None).lines().count(), 1, "natural = 1 línea"); + // Déficit dentro del rango de redondeo de taffy (0 < d ≤ 1.0). Casi 1.0 + // = peor caso: los redondeos de ambos bordes restan hasta 1px juntos. + let deficit = 0.999_f32; + let rounded_down = nat - deficit; + assert!( + clamp(&mut ts, Some(rounded_down)).lines().count() >= 2, + "sin slack, el ancho redondeado hacia abajo ({rounded_down}) parte el texto" + ); + assert_eq!( + clamp(&mut ts, Some(rounded_down + super::TEXT_WRAP_ROUNDING_SLACK)) + .lines() + .count(), + 1, + "con el slack {}, el texto vuelve a una línea", + super::TEXT_WRAP_ROUNDING_SLACK + ); + } + /// Un hijo clickeable de 100×100 anclado arriba-izquierda. Devuelve /// `(mounted, computed)` ya layouteados sobre un viewport 400×400. fn fixture( @@ -1845,6 +3916,66 @@ mod tests { assert_eq!(hit_test_click(&m_gpu, &c_gpu, 200.0, 200.0), Some(0), "GPU (gpu_paint_with+paint_over)"); } + /// Una capa `.over(true)` se pinta DESPUÉS de todo lo demás, así que el + /// usuario la ve arriba. El hit-test tiene que coincidir con eso aunque el + /// nodo base esté DESPUÉS en orden de árbol. + /// + /// Caso real que lo motivó (saga del ⚙ de media, 2026-07): la barra de + /// control de media es `.over(true)` y el lienzo de video que va detrás + /// aparece después en el árbol; con el criterio viejo («último nodo que + /// cumple, en orden de árbol») el lienzo le robaba los clicks a los + /// botones que el usuario veía encima. + #[test] + fn capa_over_le_gana_el_click_al_nodo_base_posterior() { + fn caja(msg: Msg) -> View { + View::::new(Style { + position: Position::Absolute, + inset: Rect { left: length(0.0), top: length(0.0), right: auto(), bottom: auto() }, + size: Size { width: length(200.0), height: length(200.0) }, + ..Default::default() + }) + .on_click(msg) + } + // Dos cajas superpuestas en el MISMO rect. La `over` va PRIMERO en el + // árbol (la posición desfavorable), la base va después. + let root = View::::new(Style::default()).children(vec![ + caja(1u8).over(true), // se pinta al final ⇒ está arriba + caja(2u8), // posterior en el árbol, pero debajo + ]); + let mut layout = LayoutTree::new(); + let m = mount(&mut layout, root); + let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); + // idx 1 = la caja over, idx 2 = la base. Gana la que se ve. + assert_eq!( + hit_test_click(&m, &c, 100.0, 100.0), + Some(1), + "el click debe ir a la capa over (la que el usuario ve encima), \ + no al nodo base posterior en orden de árbol" + ); + // Control: fuera de las cajas no pega nada. + assert_eq!(hit_test_click(&m, &c, 300.0, 300.0), None); + } + + /// El contrapeso del test anterior: sin capas over, el criterio de siempre + /// (último en orden de árbol = el que se pintó encima) queda intacto. + #[test] + fn sin_capas_over_gana_el_ultimo_del_arbol_como_siempre() { + fn caja(msg: Msg) -> View { + View::::new(Style { + position: Position::Absolute, + inset: Rect { left: length(0.0), top: length(0.0), right: auto(), bottom: auto() }, + size: Size { width: length(200.0), height: length(200.0) }, + ..Default::default() + }) + .on_click(msg) + } + let root = View::::new(Style::default()).children(vec![caja(1u8), caja(2u8)]); + 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, 100.0, 100.0), Some(2)); + } + #[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 @@ -2056,6 +4187,93 @@ mod tests { assert!(ch.is_empty()); } + #[test] + fn consume_scroll_chain_reparte_el_sobrante() { + use crate::{consume_scroll_chain, hit_test_scroll_chain}; + // Hijo `take` que sólo puede absorber 3 líneas; padre `take` sin tope. + // Un evento de 10 líneas debe producir DOS Msg: hijo 3 + padre 7. + #[derive(Clone, Debug, PartialEq)] + enum M { + Hijo(f32), + Padre(f32), + } + let hijo = View::::new(Style { + size: Size { width: length(100.0), height: length(100.0) }, + ..Default::default() + }) + .on_scroll_take(|dx, dy| { + let can = dy.min(3.0); + (Some(M::Hijo(can)), dx, dy - can) + }); + 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_take(|_dx, dy| (Some(M::Padre(dy)), 0.0, 0.0)) + .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"); + let chain = hit_test_scroll_chain(&m, &c, 50.0, 50.0); + let msgs = consume_scroll_chain(&m, &chain, 0.0, 10.0); + assert_eq!(msgs, vec![M::Hijo(3.0), M::Padre(7.0)]); + // Si el hijo consume todo, el padre no recibe nada. + let msgs = consume_scroll_chain(&m, &chain, 0.0, 2.0); + assert_eq!(msgs, vec![M::Hijo(2.0)]); + } + + #[test] + fn consume_scroll_chain_legado_y_defensas() { + use crate::{consume_scroll_chain, hit_test_scroll_chain}; + #[derive(Clone, Debug, PartialEq)] + enum M { + Padre(f32), + } + // Hijo LEGACY que devuelve None (topado) → el padre take recibe el + // delta completo (semántica histórica intacta). + 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_take(|_dx, dy| (Some(M::Padre(dy)), 0.0, 0.0)) + .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"); + let chain = hit_test_scroll_chain(&m, &c, 50.0, 50.0); + assert_eq!(consume_scroll_chain(&m, &chain, 0.0, 5.0), vec![M::Padre(5.0)]); + + // Defensa: un take mentiroso que "agranda" el sobrante queda acotado + // al delta recibido — el padre nunca ve más de lo que entró. + let tramposo = View::::new(Style { + size: Size { width: length(100.0), height: length(100.0) }, + ..Default::default() + }) + .on_scroll_take(|_dx, _dy| (None, 0.0, 999.0)); + let padre2 = 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_take(|_dx, dy| (Some(M::Padre(dy)), 0.0, 0.0)) + .children(vec![tramposo]); + let mut layout = LayoutTree::new(); + let m = mount(&mut layout, padre2); + let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); + let chain = hit_test_scroll_chain(&m, &c, 50.0, 50.0); + assert_eq!(consume_scroll_chain(&m, &chain, 0.0, 5.0), vec![M::Padre(5.0)]); + } + #[test] fn hit_test_double_tap_y_long_press() { use crate::{hit_test_double_tap, hit_test_long_press}; diff --git a/llimphi-compositor/src/ripple.rs b/llimphi-compositor/src/ripple.rs index 8fc1912..06d0a6f 100644 --- a/llimphi-compositor/src/ripple.rs +++ b/llimphi-compositor/src/ripple.rs @@ -133,13 +133,13 @@ impl RippleRegistry { computed: &ComputedLayout, now: Instant, ) -> bool { - // Descartá primero las agotadas (no dependen del nodo). + // Descarta primero las agotadas (no dependen del nodo). self.splashes.retain(|s| !s.done(now)); if self.splashes.is_empty() { return false; } for s in &self.splashes { - // Resolvé el nodo ripple de esta key (el primero que la declare). + // Resuelve el nodo ripple de esta key (el primero que la declare). let Some(node) = mounted.nodes.iter().find(|n| { n.ripple.map(|r| r.key) == Some(s.key) }) else { diff --git a/llimphi-compositor/src/semantics.rs b/llimphi-compositor/src/semantics.rs index c484d27..d350239 100644 --- a/llimphi-compositor/src/semantics.rs +++ b/llimphi-compositor/src/semantics.rs @@ -6,7 +6,7 @@ //! Este módulo es **pura data**: define los tipos sin acoplarse al crate //! `accesskit`. La conversión a `accesskit::Node` vive en `llimphi-ui::a11y` //! (iter 2 del plan), donde el cableado del adapter winit ya importa la -//! librería. Tener acá solo el modelo permite: +//! librería. Tener aquí solo el modelo permite: //! //! - Compilar el compositor con o sin la integración AccessKit habilitada. //! - Testear semántica a nivel "qué declaran los widgets" sin levantar un @@ -106,7 +106,7 @@ impl SemanticsFlags { /// un `accesskit::Node` cada frame. /// /// `label` es lo que el lector enuncia primero (el "nombre accesible"). Si el -/// nodo ya tiene un `text` visible y significativo, podés dejar `label = None` +/// nodo ya tiene un `text` visible y significativo, puedes dejar `label = None` /// y el runtime usará ese texto como nombre — pero declararlo explícito es más /// robusto (e.g. un botón con sólo un ícono necesita label porque no hay texto /// visible). @@ -115,7 +115,7 @@ impl SemanticsFlags { /// suele leer label + value juntos: "Volumen, 70". /// /// `description` es contexto adicional ("Disminuye el volumen del sistema"). -/// Los lectores lo leen tras una pausa o con un atajo distinto; usalo para +/// Los lectores lo leen tras una pausa o con un atajo distinto; úsalo para /// info que ayude PERO no sobreloadées (los usuarios de TTS perciben ruido /// más que falta de info). #[derive(Clone, Debug, Default, PartialEq)] diff --git a/llimphi-compositor/src/view.rs b/llimphi-compositor/src/view.rs index f6b350a..18b757a 100644 --- a/llimphi-compositor/src/view.rs +++ b/llimphi-compositor/src/view.rs @@ -20,13 +20,16 @@ impl View { painter: None, gpu_painter: None, over_painter: None, + over_layer: false, on_pointer_enter: None, on_pointer_leave: None, on_pointer_move_at: None, on_click: None, on_click_at: None, + on_click_screen: None, on_right_click: None, on_right_click_at: None, + on_right_click_screen: None, on_middle_click: None, drag: None, drag_at: None, @@ -41,6 +44,7 @@ impl View { clip_path_svg: None, clip_ref_inset: None, on_scroll: None, + on_scroll_take: None, on_scale: None, on_rotate: None, on_double_tap: None, @@ -57,6 +61,7 @@ impl View { transform: None, transform_rel: None, transform_origin: None, + sticky: None, tooltip: None, cursor: None, ripple: None, @@ -64,6 +69,8 @@ impl View { backdrop_blur: None, filter: Vec::new(), blend: None, + repaint_boundary: None, + memo: None, children: Vec::new(), } } @@ -122,6 +129,43 @@ impl View { self } + /// Marca este nodo como un **límite de repintado** (`RepaintBoundary`, + /// Bloque 23): el runtime puede cachear la rasterización de su subárbol y + /// reusarla entre frames mientras el contenido no cambie, en vez de + /// re-pintarlo. Sólo surte efecto si el subárbol es *paint-puro* y está en + /// una posición sin transform/clip/alpha ancestral (si no, se pinta fresco + /// — nunca produce un frame incorrecto, sólo se saltea la optimización). + /// `key` debe ser **estable entre rebuilds** (identifica la entrada del + /// caché). Pensalo para subárboles grandes y estáticos: una página de + /// documento, un chart, un panel de chrome que no cambia. Ver + /// [`crate::RepaintCache`]. + pub fn repaint_boundary(mut self, key: u64) -> Self { + self.repaint_boundary = Some(key); + self + } + + /// Marca este nodo como **memoizado** (`memo`, Bloque 25): su contenido lo + /// produce `builder`, y el runtime lo **reusa entre frames** mientras `deps` + /// no cambie — sin reconstruir el `View`, sin re-montar ni re-layoutear el + /// subárbol. `builder` devuelve un único `View` (el contenido que va dentro + /// de este nodo). El nodo debe llevar un `Style` con **tamaño definido** + /// (length/percent): el runtime lo trata como una hoja de ese tamaño y + /// computa el subárbol aislado con ese slot. `deps` debe resumir TODO lo que + /// el contenido depende (si te olvidas una dep, el subárbol queda stale). + /// Pensalo para chrome grande y estable: un panel, un sidebar, un formulario + /// que no cambia salvo un valor. Los `children` estáticos se ignoran. + pub fn memo(mut self, key: u64, deps: u64, builder: F) -> Self + where + F: Fn() -> View + Send + Sync + 'static, + { + self.memo = Some(crate::MemoNode { + key, + deps, + builder: Some(std::sync::Arc::new(builder)), + }); + self + } + /// Construye los hijos de este nodo **de forma diferida**, en función del /// tamaño del slot que el layout le asigne (Flutter `LayoutBuilder`). El /// runtime resuelve primero el rect del nodo (una pasada de layout con este @@ -150,7 +194,7 @@ impl View { /// punto presionado y se desvanece, recortado al contorno del nodo. `key` /// debe ser **estable** entre rebuilds del `View` (índice/hash del item), /// igual que la key de [`Self::animated`]. `color` es el tinte de la onda — - /// usá un color semitransparente (blanco a alpha ~0.25 sobre superficies + /// usa un color semitransparente (blanco a alpha ~0.25 sobre superficies /// oscuras, negro a alpha ~0.12 sobre claras); su alpha se atenúa con el /// fade. Es **aditivo**: convive con `on_click`/`drag` sin pisarlos. Duración /// por defecto 450 ms; para otra usar [`Self::ripple_styled`]. @@ -190,7 +234,7 @@ impl View { } /// Declara la **semántica accesible** completa del nodo de una vez. Usar - /// cuando ya tenés un [`SemanticsSpec`] armado (p. ej. construido por un + /// cuando ya tienes un [`SemanticsSpec`] armado (p. ej. construido por un /// widget); para los casos puntuales preferí los atajos /// [`Self::role`]/[`Self::aria_label`]/etc. pub fn semantics(mut self, spec: SemanticsSpec) -> Self { @@ -302,6 +346,21 @@ impl View { self } + /// Como [`Self::on_scroll`] pero con **reparto parcial** del delta + /// (scroll anidado real): el handler devuelve `(msg, rest_x, rest_y)` — + /// el `Msg` por lo que consumió y el sobrante en la misma unidad + /// (líneas), que el runtime entrega al siguiente scrollable de la cadena. + /// Así una lista casi topada absorbe los píxeles que le quedan Y el + /// ancestro scrollea el resto en el **mismo evento** (antes: consume + /// todo o nada por eje). Si el nodo tiene ambos handlers, gana éste. + pub fn on_scroll_take(mut self, handler: F) -> Self + where + F: Fn(f32, f32) -> (Option, f32, f32) + Send + Sync + 'static, + { + self.on_scroll_take = Some(Arc::new(handler)); + self + } + /// Registra un handler de **pinch-to-zoom** (gesto de escala). El runtime /// lo invoca cuando el cursor está sobre este nodo y el usuario hace un /// gesto de escala: **Ctrl + rueda** en cualquier desktop (camino @@ -395,7 +454,7 @@ impl View { /// editor: arrastrar sobre él resalta el rango y Ctrl/Cmd+C lo copia al /// portapapeles. `key` debe ser **estable** entre rebuilds del `View` /// (índice, hash del id) — la selección vive en el runtime anclada a esa - /// key, no al `NodeId` (que cambia cada frame). Pensá en labels, párrafos, + /// key, no al `NodeId` (que cambia cada frame). Piensa en labels, párrafos, /// celdas de tabla, salidas de consola: cualquier texto que el usuario /// querría copiar sin un editor. Sólo aplica a texto **uniforme** (el de /// `.text(...)`/`.text_aligned(...)`); en nodos con `runs`/`spans` no tiene @@ -456,6 +515,16 @@ impl View { self } + /// `position: sticky` (CSS): insets `[top, right, bottom, left]` en px + /// relativos al scrollport (ancestro que recorta). El nodo fluye normal + /// hasta el umbral y luego se pega a esa distancia del borde. Ver el campo + /// [`View::sticky`]. Ejemplo: un header `top:0` ⇒ `.sticky([Some(0.0), + /// None, None, None])`. + pub fn sticky(mut self, insets: [Option; 4]) -> Self { + self.sticky = Some(insets); + self + } + /// Punto de pivote de `transform` (CSS `transform-origin`). Sin setear ⇒ /// centro del rect (`50% 50%`). Ver [`TransformPivot`]. Sólo tiene efecto /// junto con `transform`/`transform_rel`. @@ -497,6 +566,10 @@ impl View { exit: false, enter_from_xf: None, switch: None, + exit_to_xf: None, + enter_fade: true, + exit_under: false, + exit_hold: false, }); self } @@ -552,6 +625,10 @@ impl View { exit: false, enter_from_xf: None, switch: None, + exit_to_xf: None, + enter_fade: true, + exit_under: false, + exit_hold: false, }); self } @@ -581,6 +658,10 @@ impl View { exit: false, enter_from_xf: Some(from_xf), switch: None, + exit_to_xf: None, + enter_fade: true, + exit_under: false, + exit_hold: false, }); self } @@ -606,6 +687,10 @@ impl View { exit: true, enter_from_xf: None, switch: None, + exit_to_xf: None, + enter_fade: true, + exit_under: false, + exit_hold: false, }); self } @@ -623,6 +708,10 @@ impl View { exit: true, enter_from_xf: None, switch: None, + exit_to_xf: None, + enter_fade: true, + exit_under: false, + exit_hold: false, }); self } @@ -645,6 +734,10 @@ impl View { exit: true, enter_from_xf: Some(from_xf), switch: None, + exit_to_xf: None, + enter_fade: true, + exit_under: false, + exit_hold: false, }); self } @@ -665,6 +758,10 @@ impl View { exit: false, enter_from_xf: None, switch: None, + exit_to_xf: None, + enter_fade: true, + exit_under: false, + exit_hold: false, }); self } @@ -695,6 +792,92 @@ impl View { exit: false, enter_from_xf: None, switch: Some(variant), + exit_to_xf: None, + enter_fade: true, + exit_under: false, + exit_hold: false, + }); + self + } + + /// Como [`Self::animated_switch`] pero **direccional**: al cambiar la + /// variante, el contenido nuevo entra desde `enter_from_xf` (además del + /// fade-in) y el viejo sale hacia `exit_to_xf` (además del fade-out), + /// ambas afines aplicadas alrededor del centro del rect. Es la base de las + /// transiciones de página del router: push = entrar desde la derecha / + /// salir hacia la izquierda; pop = lo inverso. `None` en cualquiera de las + /// dos degrada a fade puro en ese lado. + pub fn animated_switch_from( + self, + key: u64, + variant: u64, + duration: std::time::Duration, + enter_from_xf: Option, + exit_to_xf: Option, + ) -> Self { + self.animated_switch_styled( + key, + variant, + duration, + crate::SwitchStyle { + enter_from_xf, + exit_to_xf, + ..Default::default() + }, + ) + } + + /// La forma **completa** del switch direccional: [`crate::SwitchStyle`] + /// agrupa los cinco knobs — afín de entrada, fade de entrada, afín de + /// salida, placement del fantasma (encima vs **debajo** del contenido + /// nuevo, en su posición z real) y hold de opacidad (salida opaca, sin + /// desvanecer). Con `enter_fade: false` + `exit_under: true` sale el push + /// opaco estilo UIKit; el default reproduce `animated_switch`. + pub fn animated_switch_styled( + mut self, + key: u64, + variant: u64, + duration: std::time::Duration, + style: crate::SwitchStyle, + ) -> Self { + self.anim = Some(Anim { + key, + duration, + easing: ease_out_cubic, + enter: false, + exit: false, + enter_from_xf: style.enter_from_xf, + switch: Some(variant), + exit_to_xf: style.exit_to_xf, + enter_fade: style.enter_fade, + exit_under: style.exit_under, + exit_hold: style.exit_hold, + }); + self + } + + /// Como [`Self::animated_exit`] pero la salida además se **desliza** hacia + /// `to_xf` mientras se desvanece (p. ej. un toast que se va hacia abajo: + /// `Affine::translate((0.0, 40.0))`). La afín se aplica alrededor del + /// centro del rect del nodo. + pub fn animated_exit_to( + mut self, + key: u64, + duration: std::time::Duration, + to_xf: Affine, + ) -> Self { + self.anim = Some(Anim { + key, + duration, + easing: ease_out_cubic, + enter: false, + exit: true, + enter_from_xf: None, + switch: None, + exit_to_xf: Some(to_xf), + enter_fade: true, + exit_under: false, + exit_hold: false, }); self } @@ -808,7 +991,7 @@ impl View { /// Rellena el nodo con un **gradiente** en vez de un color sólido. El /// gradiente se autorea en el **cuadrado unidad** `[0,1]²` y el runtime - /// lo mapea al rect del nodo (así no necesitás saber el tamaño al + /// lo mapea al rect del nodo (así no necesitas saber el tamaño al /// construir el `View`) — igual que `Alignment` relativo de Flutter. /// /// ```ignore @@ -856,6 +1039,9 @@ impl View { spans: None, no_wrap: false, overflow_wrap: false, + font_features: String::new(), + font_variations: String::new(), + font_width: 100.0, }); self } @@ -886,6 +1072,9 @@ impl View { spans: None, no_wrap: false, overflow_wrap: false, + font_features: String::new(), + font_variations: String::new(), + font_width: 100.0, }); self } @@ -919,6 +1108,9 @@ impl View { spans: None, no_wrap: false, overflow_wrap: false, + font_features: String::new(), + font_variations: String::new(), + font_width: 100.0, }); self } @@ -954,6 +1146,9 @@ impl View { spans: None, no_wrap: false, overflow_wrap: false, + font_features: String::new(), + font_variations: String::new(), + font_width: 100.0, }); self } @@ -990,6 +1185,9 @@ impl View { spans: None, no_wrap: false, overflow_wrap: false, + font_features: String::new(), + font_variations: String::new(), + font_width: 100.0, }); self } @@ -1031,6 +1229,9 @@ impl View { spans: Some(spans), no_wrap: false, overflow_wrap: false, + font_features: String::new(), + font_variations: String::new(), + font_width: 100.0, }); self } @@ -1128,6 +1329,37 @@ impl View { self } + /// `font-feature-settings` (CSS): string crudo de features OpenType en + /// gramática CSS (`"liga" 1, "kern" 0`) — parley/swash lo parsean. Vacío = + /// default de la fuente. `font-kerning` se pliega aquí como el feature + /// `kern`. Afecta medida y pintado. No-op sin texto (o string vacío). + pub fn font_features(mut self, css: String) -> Self { + if let Some(t) = self.text.as_mut() { + t.font_features = css; + } + self + } + + /// `font-variation-settings` (CSS): string crudo de ejes de fuente variable + /// en gramática CSS (`"wght" 700, "wdth" 85`). Vacío = ejes en su default. + /// Mismo régimen que [`Self::font_features`]. No-op sin texto. + pub fn font_variations(mut self, css: String) -> Self { + if let Some(t) = self.text.as_mut() { + t.font_variations = css; + } + self + } + + /// `font-stretch`/`font-width` (CSS): porcentaje de ancho de la fuente + /// (100 = normal, <100 condensada, >100 expandida). Selecciona la face o el + /// eje `wdth` de una variable. Afecta medida y pintado. No-op sin texto. + pub fn font_width(mut self, pct: f32) -> Self { + if let Some(t) = self.text.as_mut() { + t.font_width = pct; + } + self + } + /// `white-space: nowrap`/`pre` (CSS): el texto **no envuelve** — se shapea /// en una sola línea (`break_all_lines(None)`) sin importar el ancho de la /// caja, y desborda (lo recorta `overflow: hidden` si lo hay). Afecta @@ -1241,6 +1473,34 @@ impl View { self } + /// Como [`Self::on_right_click_at`] pero el handler recibe + /// `(abs_x, abs_y, rect_w, rect_h)` con las coords **absolutas de ventana** + /// del click (no locales al nodo). Es lo que quiere un menú contextual: + /// aparecer en la posición del puntero en pantalla. Los backends (winit / + /// layer-shell) le pasan el cursor absoluto. Úsalo en vez de + /// `on_right_click_at` cuando el nodo va a abrir un popup anclado al cursor. + pub fn on_right_click_screen(mut self, handler: F) -> Self + where + F: Fn(f32, f32, f32, f32) -> Option + Send + Sync + 'static, + { + self.on_right_click_screen = Some(Arc::new(handler)); + self + } + + /// Como [`Self::on_click_at`] pero el handler recibe `(abs_x, abs_y, rect_w, + /// rect_h)` con las coords **absolutas de ventana** del click (no locales al + /// nodo) — el gemelo de izquierda de [`Self::on_right_click_screen`]. Es lo + /// que quiere un **select/dropdown**: al hacer click en el disparador, abrir + /// su menú anclado a la posición en pantalla (en la capa `view_overlay`, + /// encima de todo). Los backends le pasan el cursor absoluto. + pub fn on_click_screen(mut self, handler: F) -> Self + where + F: Fn(f32, f32, f32, f32) -> Option + Send + Sync + 'static, + { + self.on_click_screen = Some(Arc::new(handler)); + self + } + /// Declara el `Msg` a emitir cuando el usuario hace click con el /// botón del medio (rueda presionada). Usado típicamente para abrir /// links en pestaña nueva — igual que Ctrl+Click pero más rápido. @@ -1364,6 +1624,26 @@ impl View { self } + /// Marca este nodo y **todo su subárbol** como capa "over": se pinta + /// en la pasada vello FINAL, después del pase GPU directo del frame, + /// con toda la maquinaria normal de Views (fills, texto, imágenes, + /// `paint_with`, hover). Es el primitivo para componer **UI encima de + /// contenido `gpu_paint_with`** — la barra de controles flotante de un + /// reproductor de video, un HUD sobre un canvas GPU — sin reescribir + /// esos widgets como closures de [`Self::paint_over`]. + /// + /// El orden total del frame no cambia: `[vello base] → [gpu_paint] → + /// [vello over (paint_over + subárboles over)] → [overlay/menús]`. El + /// hit-test es el de siempre (usa el layout, no la pasada de pintura), + /// así que los handlers del subárbol siguen funcionando idéntico. + /// Limitación (como `paint_over`): la capa over se compone con alpha + /// sobre la intermedia — clips/alpha de ancestros fuera del subárbol + /// no la afectan. + pub fn over(mut self, enabled: bool) -> Self { + self.over_layer = enabled; + self + } + /// Recorta los hijos al rect de este nodo (paint y hit-test). Útil /// para paneles con contenido virtualizado que no debe sangrar a /// vecinos (listas, scrollers, viewers). @@ -1750,4 +2030,43 @@ mod semantics_tests { let sin = View::<()>::new(Style::default()).overflow_wrap(); assert!(sin.text.is_none(), "overflow_wrap sin texto no crea TextSpec"); } + + #[test] + fn font_features_y_variations_setean_campos_del_texto_fase_7_1256() { + // `.font_features()`/`.font_variations()` guardan el string CSS crudo en + // el TextSpec (parley/swash lo parsean al shapear). Ortogonales entre sí + // y al resto; default vacío. No-op si el nodo no tiene texto. + let v = View::<()>::new(Style::default()) + .text("hola", 14.0, Color::BLACK) + .font_features("\"liga\" 0, \"kern\" 0".to_string()) + .font_variations("\"wght\" 700".to_string()); + let t = v.text.as_ref().expect("text"); + assert_eq!(t.font_features, "\"liga\" 0, \"kern\" 0"); + assert_eq!(t.font_variations, "\"wght\" 700"); + // Default: sin override (strings vacíos). + let def = View::<()>::new(Style::default()).text("x", 14.0, Color::BLACK); + let dt = def.text.as_ref().unwrap(); + assert!(dt.font_features.is_empty(), "default features vacío"); + assert!(dt.font_variations.is_empty(), "default variations vacío"); + // No-op sin texto: no panickea ni inventa un TextSpec. + let sin = View::<()>::new(Style::default()).font_features("\"liga\" 1".to_string()); + assert!(sin.text.is_none(), "font_features sin texto no crea TextSpec"); + } + + #[test] + fn font_width_setea_campo_del_texto_fase_7_1257() { + // `.font_width()` guarda el porcentaje de ancho de la fuente en el + // TextSpec (100 = normal). Ortogonal al resto; default 100. No-op sin texto. + let v = View::<()>::new(Style::default()) + .text("hola", 14.0, Color::BLACK) + .font_width(75.0); + let t = v.text.as_ref().expect("text"); + assert_eq!(t.font_width, 75.0, "font_width=75 tras el builder"); + // Default: 100 (normal). + let def = View::<()>::new(Style::default()).text("x", 14.0, Color::BLACK); + assert_eq!(def.text.as_ref().unwrap().font_width, 100.0, "default 100 (normal)"); + // No-op sin texto. + let sin = View::<()>::new(Style::default()).font_width(50.0); + assert!(sin.text.is_none(), "font_width sin texto no crea TextSpec"); + } } diff --git a/llimphi-compositor/tests/text_measure.rs b/llimphi-compositor/tests/text_measure.rs index 4052dc6..50383fa 100644 --- a/llimphi-compositor/tests/text_measure.rs +++ b/llimphi-compositor/tests/text_measure.rs @@ -82,6 +82,9 @@ fn no_wrap_mide_una_sola_linea_fase_7_1253() { word_spacing: 0.0, no_wrap, overflow_wrap: false, + font_features: String::new(), + font_variations: String::new(), + font_width: 100.0, }; let mut ts = llimphi_text::Typesetter::new(); let known = TSize { width: None, height: None }; @@ -127,6 +130,9 @@ fn line_height_mayor_reserva_mas_alto() { word_spacing: 0.0, no_wrap: false, overflow_wrap: false, + font_features: String::new(), + font_variations: String::new(), + font_width: 100.0, }; let known = TSize { width: Some(180.0_f32), height: None }; let avail = TSize { @@ -168,6 +174,9 @@ fn overflow_wrap_parte_la_palabra_larga_fase_7_1254() { word_spacing: 0.0, no_wrap: false, overflow_wrap, + font_features: String::new(), + font_variations: String::new(), + font_width: 100.0, }; let mut ts = llimphi_text::Typesetter::new(); let known = TSize { width: None, height: None }; diff --git a/llimphi-gallery/Cargo.toml b/llimphi-gallery/Cargo.toml index af3a109..cc16e56 100644 --- a/llimphi-gallery/Cargo.toml +++ b/llimphi-gallery/Cargo.toml @@ -5,9 +5,9 @@ edition.workspace = true license.workspace = true authors.workspace = true publish.workspace = true -repository.workspace = true description = "llimphi-gallery — demo único que prueba el kit transversal de elegancia. Binario standalone; `cargo run -p llimphi-gallery --release`." +repository.workspace = true [[bin]] name = "llimphi-gallery" path = "src/main.rs" @@ -15,7 +15,6 @@ path = "src/main.rs" [dependencies] llimphi-ui = { workspace = true } llimphi-theme = { workspace = true } -llimphi-motion = { workspace = true } llimphi-icons = { workspace = true } llimphi-widget-wawa-mark = { workspace = true } llimphi-widget-tooltip = { workspace = true } diff --git a/llimphi-gallery/LEEME.md b/llimphi-gallery/LEEME.md new file mode 100644 index 0000000..4ec816e --- /dev/null +++ b/llimphi-gallery/LEEME.md @@ -0,0 +1,28 @@ +# llimphi-gallery + +*Read this in English: [README.md](README.md).* + +Demo único del kit transversal de elegancia. + +Una sola ventana que muestra cómo se ven los widgets del kit +juntos sobre el theme dark. Útil para verificar paleta, escala, +cinética y consistencia visual de un vistazo. + +`cargo run -p llimphi-gallery --release` + +Controles: +- Click en switches/segments/breadcrumb: dispatchea Msg +- Click en "Mostrar toast": apila un toast en bottom-right +- Click en "Abrir modal": muestra el modal +- `?`: abre/cierra el overlay de atajos +- Esc: cierra overlay activo + +## Uso + +```sh +cargo run --release -p llimphi-gallery +``` + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-gallery/README.md b/llimphi-gallery/README.md new file mode 100644 index 0000000..3db9307 --- /dev/null +++ b/llimphi-gallery/README.md @@ -0,0 +1,24 @@ +# llimphi-gallery + +The single demo of the cross-cutting elegance kit. + +One window showing how the kit's widgets look together on the dark theme. Useful +to check palette, scale, kinetics and visual consistency at a glance. + +## Use + +```sh +cargo run --release -p llimphi-gallery +``` + +Controls: + +- Click on switches/segments/breadcrumb: dispatches a `Msg`. +- Click on "show toast": stacks a toast in the bottom-right. +- Click on "open modal": shows the modal. +- `?`: opens/closes the shortcuts overlay. +- `Esc`: closes the active overlay. + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-gallery/src/main.rs b/llimphi-gallery/src/main.rs index 807f45e..f128bee 100644 --- a/llimphi-gallery/src/main.rs +++ b/llimphi-gallery/src/main.rs @@ -317,6 +317,7 @@ impl App for Gallery { viewport: model.viewport, on_dismiss: Msg::CloseModal, palette: ModalPalette::from_theme(&theme), + body_scroll: None, })); } if model.shortcuts_open { @@ -473,7 +474,7 @@ fn column_left(model: &Model, theme: &Theme) -> View { .children(vec![empty_view( Icon::Folder, "Sin documentos abiertos", - Some("Abrí uno con Ctrl+O o creá un nuevo lienzo para empezar."), + Some("Abrí uno con Ctrl+O o crea un nuevo lienzo para empezar."), &EmptyPalette::from_theme(theme), )]), ); diff --git a/llimphi-gpu-bench/Cargo.toml b/llimphi-gpu-bench/Cargo.toml index dda3faa..6e772e0 100644 --- a/llimphi-gpu-bench/Cargo.toml +++ b/llimphi-gpu-bench/Cargo.toml @@ -5,9 +5,9 @@ edition.workspace = true license.workspace = true authors.workspace = true publish.workspace = true -repository.workspace = true description = "Binario standalone que valida el SDD §'GPU directo wgpu' en una máquina con GPU real: imprime info del adapter, corre vello vs GPU directo a varios N, evalúa el criterio (≥5× a 500K, ≥60 fps @ 1M) y exporta PNGs de verificación." +repository.workspace = true [dependencies] llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" } llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" } diff --git a/llimphi-gpu-bench/LEEME.md b/llimphi-gpu-bench/LEEME.md new file mode 100644 index 0000000..ef32de1 --- /dev/null +++ b/llimphi-gpu-bench/LEEME.md @@ -0,0 +1,35 @@ +# llimphi-gpu-bench + +*Read this in English: [README.md](README.md).* + +Binario standalone para validar el SDD `02_ruway/llimphi/SDD.md` §"GPU directo wgpu" en una máquina con GPU real. + +Hace cuatro cosas en orden y lo imprime todo a stdout en formato +markdown / tabla copy-paste friendly: + +1. **Header del sistema** — versión, hora, OS, GPU detectado. +2. **Info del adapter wgpu** — backend (Vulkan/Metal/DX12/GL), + device name, vendor, limits relevantes. +3. **Spike vello vs GPU directo** — para N ∈ {25K, 50K, 100K, 200K, + 500K, 1M}. Mide ms/frame de cada uno y el factor. Evalúa el + criterio del SDD: ≥5× a 500K → PASA; < → ABORTAR. +4. **Escalado GPU directo solo** — para N ∈ {100K, 500K, 1M, 2M, + 5M, 10M}. Mide ms/frame, fps equivalente, Mprim/s. Evalúa el + objetivo de 60 fps @ 1M. +5. **PNGs de verificación visual** — exporta 2 archivos al cwd: + `bench_vello_100k.png` y `bench_directo_100k.png`. La forma del + cielo de puntos debe coincidir entre los dos (LCG determinista). + +Pegar el output completo en chat para la verificación. + +Corre con: `cargo run -p llimphi-gpu-bench --release`. + +## Uso + +```sh +cargo run --release -p llimphi-gpu-bench +``` + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-gpu-bench/README.md b/llimphi-gpu-bench/README.md new file mode 100644 index 0000000..38c0e04 --- /dev/null +++ b/llimphi-gpu-bench/README.md @@ -0,0 +1,30 @@ +# llimphi-gpu-bench + +Standalone binary to validate the "direct wgpu GPU" section of +`02_ruway/llimphi/SDD.md` on a machine with a real GPU. + +It does four things in order and prints everything to stdout in +copy-paste-friendly markdown/table form: + +1. **System header** — version, time, OS, detected GPU. +2. **wgpu adapter info** — backend (Vulkan/Metal/DX12/GL), device name, vendor, + relevant limits. +3. **vello vs direct GPU spike** — for N ∈ {25K, 50K, 100K, 200K, 500K, 1M}. It + measures ms/frame for each and the factor, and evaluates the SDD's criterion: + ≥5× at 500K → PASS; below → ABORT. +4. **Direct-GPU scaling alone** — for N ∈ {100K, 500K, 1M, 2M, 5M, 10M}. It + measures ms/frame, equivalent fps and Mprim/s, and evaluates the 60 fps @ 1M + goal. +5. **Visual verification PNGs** — it exports two files to the cwd: + `bench_vello_100k.png` and `bench_directo_100k.png`. The shape of the dot sky + must match between the two (deterministic LCG). + +## Use + +```sh +cargo run --release -p llimphi-gpu-bench +``` + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-gpu-bench/src/main.rs b/llimphi-gpu-bench/src/main.rs index d00247e..f920222 100644 --- a/llimphi-gpu-bench/src/main.rs +++ b/llimphi-gpu-bench/src/main.rs @@ -132,7 +132,7 @@ fn main() { println!("## Escalado GPU directo"); println!(); - println!("API real (`GpuPipelines` + `GpuBatch::add_rect`). Sólo se mide el lado GPU directo — vello no llega acá."); + println!("API real (`GpuPipelines` + `GpuBatch::add_rect`). Sólo se mide el lado GPU directo — vello no llega aquí."); println!(); println!("| N | ms / frame | fps (1000/ms) | Mprim/s |"); println!("|---:|---:|---:|---:|"); @@ -406,7 +406,7 @@ fn bench_vello( }, ) .expect("vello render"); - hal.device.poll(wgpu::Maintain::Wait); + hal.device.poll(wgpu::PollType::wait_indefinitely()); let dt = t0.elapsed().as_secs_f64() * 1000.0; if frame >= WARMUP { samples.push(dt); @@ -448,7 +448,7 @@ fn bench_directo( wgpu::LoadOp::Clear(wgpu::Color::BLACK), ); hal.queue.submit(std::iter::once(encoder.finish())); - hal.device.poll(wgpu::Maintain::Wait); + hal.device.poll(wgpu::PollType::wait_indefinitely()); let dt = t0.elapsed().as_secs_f64() * 1000.0; if frame >= WARMUP { samples.push(dt); @@ -501,7 +501,7 @@ fn bench_vello_persistent( }, ) .expect("vello render"); - hal.device.poll(wgpu::Maintain::Wait); + hal.device.poll(wgpu::PollType::wait_indefinitely()); let dt = t0.elapsed().as_secs_f64() * 1000.0; if frame >= WARMUP { samples.push(dt); @@ -569,7 +569,7 @@ fn bench_directo_persistent( // Asegurar que toda la escritura previa esté en la GPU antes de // empezar a medir frames — si no, el primer frame paga el upload. hal.queue.submit(std::iter::empty::()); - hal.device.poll(wgpu::Maintain::Wait); + hal.device.poll(wgpu::PollType::wait_indefinitely()); let mut samples: Vec = Vec::with_capacity(MEASURED); for frame in 0..(WARMUP + MEASURED) { @@ -585,6 +585,7 @@ fn bench_directo_persistent( color_attachments: &[Some(wgpu::RenderPassColorAttachment { view, resolve_target: None, + depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, @@ -600,7 +601,7 @@ fn bench_directo_persistent( pass.draw(0..6, 0..n); } hal.queue.submit(std::iter::once(encoder.finish())); - hal.device.poll(wgpu::Maintain::Wait); + hal.device.poll(wgpu::PollType::wait_indefinitely()); let dt = t0.elapsed().as_secs_f64() * 1000.0; if frame >= WARMUP { samples.push(dt); @@ -865,7 +866,7 @@ fn export_directo_png( wgpu::LoadOp::Clear(wgpu::Color::BLACK), ); hal.queue.submit(std::iter::once(encoder.finish())); - hal.device.poll(wgpu::Maintain::Wait); + hal.device.poll(wgpu::PollType::wait_indefinitely()); write_texture_png(hal, target, path) } @@ -916,7 +917,7 @@ fn write_texture_png(hal: &Hal, target: &wgpu::Texture, path: &str) -> Result<() slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); - hal.device.poll(wgpu::Maintain::Wait); + hal.device.poll(wgpu::PollType::wait_indefinitely()); rx.recv().map_err(|e| e.to_string())?.map_err(|e| e.to_string())?; let data = slice.get_mapped_range(); diff --git a/llimphi-hal/Cargo.toml b/llimphi-hal/Cargo.toml index 3a84ca8..40b5a13 100644 --- a/llimphi-hal/Cargo.toml +++ b/llimphi-hal/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "llimphi-hal" -description = "Surface/device HAL for the llimphi UI framework (wgpu) — one scene tree across Wayland/X11/Win32/Android/bare-metal." version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true publish.workspace = true -repository.workspace = true +description = "Surface/device HAL for the llimphi UI framework (wgpu) — one scene tree across Wayland/X11/Win32/Android/bare-metal." +repository.workspace = true [dependencies] wgpu = { workspace = true } raw-window-handle = { workspace = true } diff --git a/llimphi-hal/src/lib.rs b/llimphi-hal/src/lib.rs index b2285c7..a5b6585 100644 --- a/llimphi-hal/src/lib.rs +++ b/llimphi-hal/src/lib.rs @@ -20,6 +20,23 @@ pub enum SurfaceError { Outdated, OutOfMemory, Timeout, + /// La surface quedó **irrecuperable a nivel de dispositivo**: reconfigurar + /// y recrear la `wgpu::Surface` (reusando el mismo device/adapter) fallaron + /// ambos. El único remedio es reconstruir todo el stack GPU + /// (instance+adapter+device+queue+surface) y con él los recursos colgados + /// del device (renderer vello, compositores, texturas). El dueño del bucle + /// de render debe atrapar esta variante y rehacer su mundo GPU — ver + /// [`Hal::recreate_for_window`] (camino winit) y el runner de layer-shell de + /// `pata` (camino raw-surface). Distinta de [`SurfaceError::Lost`], que es + /// un hipo transitorio que la propia surface ya recuperó. + /// + /// Se dispara en el modo de falla de Iris Xe bajo mirada-DRM: al resetear + /// el output, la surface Vulkan cae con «Surface does not support the + /// adapter's queue family» y recrearla contra el mismo adapter no ayuda. + /// Antes esto tumbaba/loopeaba a los clientes (greeter/pata/shuma) o exigía + /// forzar el backend GL; la recuperación de device lo resuelve de forma + /// general (no atada a esta GPU). + DeviceLost, Other(String), } @@ -30,6 +47,7 @@ impl std::fmt::Display for SurfaceError { Self::Outdated => write!(f, "surface outdated"), Self::OutOfMemory => write!(f, "surface out of memory"), Self::Timeout => write!(f, "surface timeout"), + Self::DeviceLost => write!(f, "device lost (full GPU-stack rebuild required)"), Self::Other(s) => write!(f, "surface error: {s}"), } } @@ -84,7 +102,7 @@ pub struct Frame { intermediate_view: wgpu::TextureView, /// Textura secundaria para la capa de overlay (menús/paleta/modal) /// cuando hay contenido `gpu_paint` que la taparía. El overlay se - /// rasteriza acá con fondo transparente y luego se compone con + /// rasteriza aquí con fondo transparente y luego se compone con /// alpha SOBRE la intermedia (que ya tiene UI + video). Ver /// [`OverlayCompositor`] y el eventloop de `llimphi-ui`. overlay_view: wgpu::TextureView, @@ -117,16 +135,157 @@ pub struct Hal { pub queue: wgpu::Queue, } +/// Override del backend wgpu por la env `LLIMPHI_WGPU_BACKEND`. Valores: +/// `gl`/`gles`/`opengl`/`egl` → GL; `vulkan`/`vk` → Vulkan; `primary` → PRIMARY; +/// `all` → todos. Sin la env (o valor no reconocido) devuelve `None` y el caller +/// usa su default (PRIMARY con fallback a todos). +/// +/// **Para qué**: en Iris Xe bajo mirada-DRM, el backend Vulkan revienta al +/// reconfigurar la surface tras un reset de output («Surface does not support the +/// adapter's queue family»: falta `VK_EXT_physical_device_drm` y wgpu no puede +/// re-matchear el adapter a la surface recreada). Como recrear la surface reusa el +/// mismo device/adapter, el error persiste y los clientes layer (greeter/pata/ +/// shuma) entran en crash-loop al boot y en cada cambio de VT. Forzar GL +/// (`LLIMPHI_WGPU_BACKEND=gl`) esquiva el problema —GL no tiene el concepto de +/// queue family— al costo del bug de teardown de GL (SIGSEGV al cerrar, tolerable +/// frente a un crash-loop que impide la sesión). Los clientes lo heredan del env +/// del compositor. Ver memoria `pata-layer-closed-arje-drm`. +fn backend_override() -> Option { + let v = std::env::var("LLIMPHI_WGPU_BACKEND").ok()?; + match v.trim().to_ascii_lowercase().as_str() { + "gl" | "gles" | "opengl" | "egl" => Some(wgpu::Backends::GL), + "vulkan" | "vk" => Some(wgpu::Backends::VULKAN), + "primary" => Some(wgpu::Backends::PRIMARY), + "all" => Some(wgpu::Backends::all()), + other => { + eprintln!("llimphi-hal: LLIMPHI_WGPU_BACKEND=«{other}» no reconocido; uso el default (PRIMARY)"); + None + } + } +} + +/// Pista de memoria explícita por la env `LLIMPHI_WGPU_MEM`. `low`/`min`/`ram` +/// → [`MemoryHints::MemoryUsage`] (el asignador de wgpu minimiza la huella: +/// bloques de suballocación chicos, menos reserva especulativa). `perf` → +/// [`MemoryHints::Performance`] (pools grandes, prioriza velocidad). `None` si +/// la env no está o no se reconoce → el caller **autodetecta** (ver +/// [`resolver_memory_hint`]). El manager de `pacha` la fija por contexto para +/// equipos chicos; sin ella, la app decide sola. +fn memory_hint_env() -> Option { + match std::env::var("LLIMPHI_WGPU_MEM") + .map(|v| v.trim().to_ascii_lowercase()) + .as_deref() + { + Ok("low") | Ok("min") | Ok("ram") | Ok("memoria") => Some(wgpu::MemoryHints::MemoryUsage), + Ok("perf") | Ok("performance") | Ok("velocidad") => Some(wgpu::MemoryHints::Performance), + Ok(other) if !other.is_empty() => { + eprintln!("llimphi-hal: LLIMPHI_WGPU_MEM=«{other}» no reconocido; autodetecto"); + None + } + _ => None, + } +} + +/// Resuelve la pista de memoria del device: la env manda; sin ella, +/// **autoadapta** a la máquina. Un adapter de tipo `Cpu` (raster por software, +/// llvmpipe / VM sin GPU) no tiene VRAM y compone contra la RAM del sistema, así +/// que ahí `MemoryUsage` (mínima huella) es la elección correcta por defecto. Con +/// una GPU real se mantiene `Performance` salvo que el env/pacha pida lo +/// contrario. Es la contraparte a nivel de render de `pacha_core::Rendimiento`. +fn resolver_memory_hint(adapter: &wgpu::Adapter) -> wgpu::MemoryHints { + if let Some(h) = memory_hint_env() { + return h; + } + match adapter.get_info().device_type { + wgpu::DeviceType::Cpu => wgpu::MemoryHints::MemoryUsage, + _ => wgpu::MemoryHints::Performance, + } +} + +/// Preferencia de potencia por la env `LLIMPHI_WGPU_POWER`. `low`/`ahorro` → +/// [`PowerPreference::LowPower`] (elige la iGPU integrada en equipos híbridos: +/// menos consumo, menos RAM de driver, y de paso esquiva el problema de dmabuf +/// cruzando dispositivos en Optimus). Sin la env → `HighPerformance`, el default +/// de siempre. Una barra o un WM 2D nunca necesitan la dGPU discreta. +fn power_preference_override() -> wgpu::PowerPreference { + match std::env::var("LLIMPHI_WGPU_POWER") + .map(|v| v.trim().to_ascii_lowercase()) + .as_deref() + { + Ok("low") | Ok("ahorro") | Ok("integrada") => wgpu::PowerPreference::LowPower, + Ok("high") | Ok("alto") | Ok("discreta") => wgpu::PowerPreference::HighPerformance, + Ok(other) if !other.is_empty() => { + eprintln!("llimphi-hal: LLIMPHI_WGPU_POWER=«{other}» no reconocido; uso HighPerformance"); + wgpu::PowerPreference::HighPerformance + } + _ => wgpu::PowerPreference::HighPerformance, + } +} + +/// Gancho de verificación: fuerza un [`SurfaceError::DeviceLost`] sintético cada +/// N cuadros para poder ejercitar la recuperación de device SIN esperar un reset +/// de output real (que en Iris Xe sólo pasa bajo mirada-DRM, no bajo KDE/kwin). +/// +/// Activar con `LLIMPHI_SIMULATE_DEVICE_LOSS=N` (N≥1): cada N `acquire()` +/// exitosos, el N-ésimo devuelve `DeviceLost` en vez de un `Frame`. El dueño del +/// bucle debe reconstruir todo el stack GPU; si tras eso el render sigue vivo y +/// sin fugas, la recuperación quedó certificada como texto (no screenshot). Sin +/// la env, es un no-op de coste cero (un `load` atómico Relaxed). El contador es +/// global al proceso: con varias surfaces reparte los trips entre ellas, lo cual +/// es justo lo que queremos (probar que CUALQUIER surface dispara el rebuild). +fn simulate_device_loss_trip() -> bool { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::OnceLock; + static PERIOD: OnceLock> = OnceLock::new(); + let period = *PERIOD.get_or_init(|| { + std::env::var("LLIMPHI_SIMULATE_DEVICE_LOSS") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|n| *n >= 1) + }); + let Some(period) = period else { return false }; + static COUNT: AtomicU64 = AtomicU64::new(0); + // fetch_add devuelve el valor previo; el trip cae en el múltiplo de period. + (COUNT.fetch_add(1, Ordering::Relaxed) + 1) % period == 0 +} + impl Hal { /// Construye Hal pidiendo un adapter compatible con una surface dada /// (recomendado: pasar `Some(&surface)` para garantizar que el adapter /// elegido sabe presentar a esa surface). pub async fn new( compatible_surface: Option<&wgpu::Surface<'static>>, + ) -> Result { + Self::new_inner(compatible_surface, false).await + } + + /// Construye un `Hal` **headless** (sin surface) prefiriendo el adapter de + /// **software** (llvmpipe/lavapipe, `deviceType == CPU`) cuando + /// `prefer_software`. Es el modo de los tests golden de `llimphi-test`: el + /// rasterizador por software es DETERMINISTA e idéntico host-a-host y en CI, + /// así el golden no queda atado a la GPU de quien lo generó. Si la máquina no + /// expone un fallback adapter (no hay lavapipe instalado), cae al adapter + /// normal — en ese caso el golden se certifica con tolerancia perceptual, no + /// bit-exacta. Nunca pasa `compatible_surface`: renderiza a textura, no + /// presenta a ventana. + pub async fn new_headless(prefer_software: bool) -> Result { + if prefer_software { + if let Ok(hal) = Self::new_inner(None, true).await { + return Ok(hal); + } + // Sin fallback adapter disponible: seguimos con el adapter normal; la + // tolerancia perceptual del arnés absorbe la diferencia de vendor. + } + Self::new_inner(None, false).await + } + + async fn new_inner( + compatible_surface: Option<&wgpu::Surface<'static>>, + force_fallback_adapter: bool, ) -> Result { let opts = wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::HighPerformance, - force_fallback_adapter: false, + power_preference: power_preference_override(), + force_fallback_adapter, compatible_surface, }; // Preferimos backends PRIMARY (Vulkan/Metal/DX12). El backend GL de @@ -140,12 +299,16 @@ impl Hal { // camino de escritorio `compatible_surface` es `None` (la surface se // crea después contra esta misma instancia), así que cambiar de // instancia aquí es seguro. + // Override por env (`LLIMPHI_WGPU_BACKEND`) o PRIMARY por default. + let forced = backend_override(); let primary = wgpu::Instance::new(&wgpu::InstanceDescriptor { - backends: wgpu::Backends::PRIMARY, + backends: forced.unwrap_or(wgpu::Backends::PRIMARY), ..Default::default() }); let (instance, adapter) = match primary.request_adapter(&opts).await { Ok(a) => (primary, a), + // Con override explícito NO caemos a otros backends: el operador eligió. + Err(_) if forced.is_some() => return Err(HalError::NoAdapter), Err(_) => { let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default()); let a = all @@ -165,12 +328,13 @@ impl Hal { label: Some("llimphi-hal-device"), required_features: wgpu::Features::empty(), required_limits: limits, - memory_hints: wgpu::MemoryHints::Performance, + memory_hints: resolver_memory_hint(&adapter), experimental_features: wgpu::ExperimentalFeatures::default(), trace: wgpu::Trace::Off, }) .await .map_err(|e| HalError::RequestDevice(e.to_string()))?; + instalar_handler_errores(&device); Ok(Self { instance, adapter, @@ -208,28 +372,32 @@ impl Hal { ) -> Result<(Self, RawSurface), HalError> { // PRIMARY (Vulkan/Metal/DX12) primero; si no hay adaptador compatible, a // todos los backends recreando instancia y surface. + // Override por env (`LLIMPHI_WGPU_BACKEND`) o PRIMARY por default. + let forced = backend_override(); let primary = wgpu::Instance::new(&wgpu::InstanceDescriptor { - backends: wgpu::Backends::PRIMARY, + backends: forced.unwrap_or(wgpu::Backends::PRIMARY), ..Default::default() }); let prim_surface = unsafe { primary.create_surface_unsafe(make_target()) } .map_err(|e| HalError::CreateSurface(e.to_string()))?; let prim_adapter = primary .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::HighPerformance, + power_preference: power_preference_override(), force_fallback_adapter: false, compatible_surface: Some(&prim_surface), }) .await; let (instance, adapter, wgpu_surface) = match prim_adapter { Ok(a) => (primary, a, prim_surface), + // Con override explícito NO caemos a otros backends: el operador eligió. + Err(_) if forced.is_some() => return Err(HalError::NoAdapter), Err(_) => { let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default()); let surface = unsafe { all.create_surface_unsafe(make_target()) } .map_err(|e| HalError::CreateSurface(e.to_string()))?; let a = all .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::HighPerformance, + power_preference: power_preference_override(), force_fallback_adapter: false, compatible_surface: Some(&surface), }) @@ -244,19 +412,104 @@ impl Hal { label: Some("llimphi-hal-device"), required_features: wgpu::Features::empty(), required_limits: limits, - memory_hints: wgpu::MemoryHints::Performance, + memory_hints: resolver_memory_hint(&adapter), experimental_features: wgpu::ExperimentalFeatures::default(), trace: wgpu::Trace::Off, }) .await .map_err(|e| HalError::RequestDevice(e.to_string()))?; + instalar_handler_errores(&device); let hal = Self { instance, adapter, device, queue, }; - let surface = RawSurface::from_surface(&hal, wgpu_surface, width, height)?; + // Extraemos los raw handles del target para que la `RawSurface` pueda + // recrearse ante una pérdida (los `RawHandle` son `Copy`). + let (raw_display, raw_window) = match make_target() { + wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle, + raw_window_handle, + } => (raw_display_handle, raw_window_handle), + _ => { + return Err(HalError::CreateSurface( + "new_for_raw_surface requiere SurfaceTargetUnsafe::RawHandle".into(), + )) + } + }; + let surface = + RawSurface::from_surface(&hal, wgpu_surface, raw_display, raw_window, width, height)?; + Ok((hal, surface)) + } + + /// Reconstruye **todo el stack GPU** (instance+adapter+device+queue) y una + /// [`WinitSurface`] nueva sobre la MISMA ventana, eligiendo el adapter + /// compatible con la surface recreada. Es la recuperación real ante + /// [`SurfaceError::DeviceLost`] en el camino winit (apps `llimphi_ui::run`: + /// greeter/shuma/nada/cosmos…): reconfigurar o recrear sólo la surface no + /// alcanza cuando el adapter dejó de matchear (Iris Xe «queue family»); hay + /// que rehacer el device entero. Tras esto el caller debe reconstruir lo que + /// cuelga del device (renderer vello, compositores, texturas de la app) + /// contra el `Hal` devuelto. + /// + /// Es **surface-first** (crea la surface antes de pedir el adapter con + /// `compatible_surface`), lo correcto para garantizar el match adapter↔surface + /// tras la pérdida. Bloquea internamente (`pollster`) porque la recuperación + /// ocurre dentro de un handler de redraw síncrono. Respeta el override de + /// backend (`LLIMPHI_WGPU_BACKEND`) igual que [`Hal::new`]. + pub fn recreate_for_window( + window: Arc, + ) -> Result<(Self, WinitSurface), HalError> { + let forced = backend_override(); + let make_instance = |backends: wgpu::Backends| { + wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends, + ..Default::default() + }) + }; + let request_adapter = |inst: &wgpu::Instance, surf: &wgpu::Surface<'static>| { + pollster::block_on(inst.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: power_preference_override(), + force_fallback_adapter: false, + compatible_surface: Some(surf), + })) + }; + let primary = make_instance(forced.unwrap_or(wgpu::Backends::PRIMARY)); + let prim_surface = primary + .create_surface(window.clone()) + .map_err(|e| HalError::CreateSurface(e.to_string()))?; + let (instance, adapter, surface) = match request_adapter(&primary, &prim_surface) { + Ok(a) => (primary, a, prim_surface), + // Con override explícito NO caemos a otros backends: el operador eligió. + Err(_) if forced.is_some() => return Err(HalError::NoAdapter), + Err(_) => { + let all = make_instance(wgpu::Backends::all()); + let surface = all + .create_surface(window.clone()) + .map_err(|e| HalError::CreateSurface(e.to_string()))?; + let a = request_adapter(&all, &surface).map_err(|_| HalError::NoAdapter)?; + (all, a, surface) + } + }; + let limits = wgpu::Limits::default().using_resolution(adapter.limits()); + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("llimphi-hal-device"), + required_features: wgpu::Features::empty(), + required_limits: limits, + memory_hints: resolver_memory_hint(&adapter), + experimental_features: wgpu::ExperimentalFeatures::default(), + trace: wgpu::Trace::Off, + })) + .map_err(|e| HalError::RequestDevice(e.to_string()))?; + instalar_handler_errores(&device); + let hal = Self { + instance, + adapter, + device, + queue, + }; + let surface = WinitSurface::from_surface(&hal, window, surface)?; Ok((hal, surface)) } } @@ -265,7 +518,7 @@ impl Hal { /// intermedia `Rgba8Unorm` con storage binding (donde pinta vello) y /// un `TextureBlitter` que la copia al swapchain al presentar. pub struct WinitSurface { - _window: Arc, + window: Arc, surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration, device: wgpu::Device, @@ -275,6 +528,10 @@ pub struct WinitSurface { overlay: wgpu::Texture, overlay_view: wgpu::TextureView, blitter: wgpu::util::TextureBlitter, + /// La instancia con que se creó la surface: guardada para poder RECREARLA + /// desde la ventana ante un hipo del compositor/GPU (ver + /// [`WinitSurface::recreate`]) antes de escalar a un rebuild de device. + instance: wgpu::Instance, } const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; @@ -334,14 +591,14 @@ impl WinitSurface { alpha_mode: caps.alpha_modes[0], view_formats: vec![], }; - surface.configure(&hal.device, &config); + configure_checked(&hal.device, &surface, &config).map_err(HalError::CreateSurface)?; let (intermediate, intermediate_view) = create_intermediate(&hal.device, config.width, config.height); let (overlay, overlay_view) = create_intermediate(&hal.device, config.width, config.height); let blitter = wgpu::util::TextureBlitter::new(&hal.device, format); Ok(Self { - _window: window, + window, surface, config, device: hal.device.clone(), @@ -350,12 +607,30 @@ impl WinitSurface { overlay, overlay_view, blitter, + instance: hal.instance.clone(), }) } pub fn format(&self) -> wgpu::TextureFormat { self.config.format } + + /// Recrea la `wgpu::Surface` desde la MISMA ventana winit y la reconfigura. + /// Paso intermedio de recuperación: barato frente a rehacer el device. + /// Lo llama [`WinitSurface::acquire`] cuando `get_current_texture` devuelve + /// `Outdated`/`Lost` y reconfigurar no bastó. Si tras esto la surface sigue + /// rota (el adapter dejó de matchear — Iris Xe «queue family»), `acquire` + /// escala a [`SurfaceError::DeviceLost`] y el dueño del bucle reconstruye el + /// `Hal` entero con [`Hal::recreate_for_window`]. + fn recreate(&mut self) -> Result<(), String> { + let surface = self + .instance + .create_surface(self.window.clone()) + .map_err(|e| format!("create_surface: {e}"))?; + configure_checked(&self.device, &surface, &self.config)?; + self.surface = surface; + Ok(()) + } } /// Surface sobre una `wgpu::Surface` creada desde **handles raw** (sin @@ -374,13 +649,25 @@ pub struct RawSurface { overlay: wgpu::Texture, overlay_view: wgpu::TextureView, blitter: wgpu::util::TextureBlitter, + /// La instancia + los raw handles del `wl_surface`/`wl_display`, guardados para + /// **recrear** la `wgpu::Surface` cuando queda irrecuperable (ver + /// [`RawSurface::recreate`]). Los `RawHandle` son `Copy` y apuntan a objetos + /// que el caller mantiene vivos toda la vida de la `RawSurface`. + instance: wgpu::Instance, + raw_display: raw_window_handle::RawDisplayHandle, + raw_window: raw_window_handle::RawWindowHandle, } impl RawSurface { - /// Envuelve una `wgpu::Surface` ya creada, con el tamaño físico inicial. + /// Envuelve una `wgpu::Surface` ya creada, con el tamaño físico inicial. Los + /// `raw_display`/`raw_window` son los handles con que se creó la surface: se + /// guardan para poder RECREARLA si el compositor la invalida (Iris Xe), ya que + /// reconfigurar la misma surface no siempre recupera. pub fn from_surface( hal: &Hal, surface: wgpu::Surface<'static>, + raw_display: raw_window_handle::RawDisplayHandle, + raw_window: raw_window_handle::RawWindowHandle, width: u32, height: u32, ) -> Result { @@ -439,7 +726,7 @@ impl RawSurface { alpha_mode, view_formats: vec![], }; - surface.configure(&hal.device, &config); + configure_checked(&hal.device, &surface, &config).map_err(HalError::CreateSurface)?; let (intermediate, intermediate_view) = create_intermediate(&hal.device, config.width, config.height); let (overlay, overlay_view) = @@ -454,12 +741,36 @@ impl RawSurface { overlay, overlay_view, blitter, + instance: hal.instance.clone(), + raw_display, + raw_window, }) } pub fn format(&self) -> wgpu::TextureFormat { self.config.format } + + /// Recrea la `wgpu::Surface` desde el raw handle del `wl_surface` y la + /// reconfigura. Es la recuperación REAL cuando la surface quedó irrecuperable + /// —p. ej. `configure` falla con «Surface does not support the adapter's queue + /// family» tras un reset del compositor en Iris Xe—: reconfigurar la MISMA + /// surface no basta, hay que crear una nueva. Devuelve el error si falla (el + /// caller salta el frame y reintenta). Sin esto, la barra quedaba en un bucle + /// de «surface perdida» hasta que se caía la conexión (Broken pipe). + fn recreate(&mut self) -> Result<(), String> { + let target = wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: self.raw_display, + raw_window_handle: self.raw_window, + }; + // SAFETY: los handles apuntan a objetos Wayland que el caller mantiene + // vivos toda la vida de la `RawSurface` (mismo contrato que `from_surface`). + let surface = unsafe { self.instance.create_surface_unsafe(target) } + .map_err(|e| format!("create_surface_unsafe: {e}"))?; + configure_checked(&self.device, &surface, &self.config)?; + self.surface = surface; + Ok(()) + } } impl Surface for RawSurface { @@ -480,7 +791,16 @@ impl Surface for RawSurface { } self.config.width = w; self.config.height = h; - self.surface.configure(&self.device, &self.config); + if let Err(e) = configure_checked(&self.device, &self.surface, &self.config) { + // Igual que en `acquire`: si reconfigurar no recupera, recreamos la + // surface desde el raw handle antes de rendirnos. + match self.recreate() { + Ok(()) => eprintln!("llimphi-hal: surface RECREADA en resize (reconfigure falló: {e})"), + Err(e2) => { + eprintln!("llimphi-hal: configure en resize falló ({e}); recrear también falló ({e2})") + } + } + } let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height); self.intermediate = tex; self.intermediate_view = view; @@ -491,6 +811,11 @@ impl Surface for RawSurface { } fn acquire(&mut self) -> Result { + // Gancho de verificación: DeviceLost sintético cada N cuadros + // (`LLIMPHI_SIMULATE_DEVICE_LOSS`). No-op sin la env. + if simulate_device_loss_trip() { + return Err(SurfaceError::DeviceLost); + } let texture = match self.surface.get_current_texture() { Ok(t) => t, // El backend layer-shell no tiene un evento de resize que reconfigure @@ -498,11 +823,32 @@ impl Surface for RawSurface { // y reintentamos una vez. Sin esto el panel quedaría en negro para // siempre tras el primer `Outdated`. Err(e @ (wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost)) => { - self.surface.configure(&self.device, &self.config); - self.surface.get_current_texture().map_err(|_| match e { - wgpu::SurfaceError::Lost => SurfaceError::Lost, - _ => SurfaceError::Outdated, - })? + // Reconfigurar CAPTURANDO: si la surface está realmente perdida, + // `configure` emite un error de validación que, sin captura, + // mataría el proceso (era el crash de pata). Aquí lo devolvemos + // como `DeviceLost` y el caller reconstruye el stack GPU. + if let Err(msg) = configure_checked(&self.device, &self.surface, &self.config) { + // Reconfigurar no recuperó (surface realmente perdida): RECREAR + // desde el raw handle contra el MISMO device. Si eso también + // falla, el device quedó irrecuperable (el adapter dejó de + // matchear — Iris Xe «queue family») → DeviceLost: el runner + // de pata rehace `Hal` + todas las surfaces/renderers. + match self.recreate() { + Ok(()) => eprintln!( + "llimphi-hal: surface RECREADA tras {e:?} (reconfigure falló: {msg})" + ), + Err(msg2) => { + eprintln!( + "llimphi-hal: reconfigurar tras {e:?} falló ({msg}); recrear también falló ({msg2}) → DeviceLost" + ); + return Err(SurfaceError::DeviceLost); + } + } + } + // Sigue rota tras reconfigurar+recrear → device irrecuperable. + self.surface + .get_current_texture() + .map_err(|_| SurfaceError::DeviceLost)? } Err(wgpu::SurfaceError::OutOfMemory) => return Err(SurfaceError::OutOfMemory), Err(wgpu::SurfaceError::Timeout) => return Err(SurfaceError::Timeout), @@ -574,6 +920,41 @@ fn choose_present_mode(caps: &wgpu::SurfaceCapabilities) -> wgpu::PresentMode { } } +/// Instala en el device un handler de errores wgpu **no capturados** que LOGUEA +/// en vez de dejar que el default de wgpu («Handling wgpu errors as fatal by +/// default») paniquee el proceso entero. Un glitch de device —p. ej. la pérdida +/// transitoria en un flap de VT bajo arje— NO debe tumbar la app (greeter, pata…): +/// la recuperación de surface (`Outdated`/`Lost`) rearma en el próximo frame. Los +/// errores de validación puntuales se siguen capturando con error-scopes aparte. +fn instalar_handler_errores(device: &wgpu::Device) { + device.on_uncaptured_error(std::sync::Arc::new(|e| { + eprintln!("llimphi-hal: error wgpu no capturado (no fatal, ignorado): {e}"); + })); +} + +/// Configura el swapchain **capturando** los errores de validación de wgpu, en +/// vez de dejar que el handler por defecto («wgpu errors as fatal») paniquee el +/// proceso entero. Devuelve `Err(mensaje)` si la configuración falló — casi +/// siempre porque la surface se perdió (`SURFACE_LOST`, un hipo transitorio del +/// compositor/GPU): el caller decide (saltar el frame, marcar perdida) en vez de +/// morir. Robustez: una app no debe caerse por un glitch del surface (lo que +/// tumbaba a `pata` con «Surface does not support the adapter's queue family»). +/// +/// Los errores de validación de wgpu son CPU-side: el scope los captura durante +/// la llamada, así que `pop_error_scope` resuelve sin bloquear contra la GPU. +fn configure_checked( + device: &wgpu::Device, + surface: &wgpu::Surface<'static>, + config: &wgpu::SurfaceConfiguration, +) -> Result<(), String> { + device.push_error_scope(wgpu::ErrorFilter::Validation); + surface.configure(device, config); + match pollster::block_on(device.pop_error_scope()) { + Some(e) => Err(e.to_string()), + None => Ok(()), + } +} + fn create_intermediate( device: &wgpu::Device, width: u32, @@ -593,7 +974,7 @@ fn create_intermediate( // STORAGE_BINDING: vello escribe via compute shader. // TEXTURE_BINDING: el blitter la lee como sampler source. // RENDER_ATTACHMENT: render passes con clear-only (sin vello) - // también escriben acá — desktop drivers lo tolerían sin este + // también escriben aquí — desktop drivers lo tolerían sin este // flag, Adreno con validación estricta rechaza el frame. usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING @@ -1525,7 +1906,9 @@ impl Surface for WinitSurface { fn resize(&mut self, width: u32, height: u32) { self.config.width = width.max(1); self.config.height = height.max(1); - self.surface.configure(&self.device, &self.config); + if let Err(e) = configure_checked(&self.device, &self.surface, &self.config) { + eprintln!("llimphi-hal: configure en resize falló (surface perdida?): {e}"); + } let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height); self.intermediate = tex; self.intermediate_view = view; @@ -1536,13 +1919,47 @@ impl Surface for WinitSurface { } fn acquire(&mut self) -> Result { - let texture = self.surface.get_current_texture().map_err(|e| match e { - wgpu::SurfaceError::Lost => SurfaceError::Lost, - wgpu::SurfaceError::Outdated => SurfaceError::Outdated, - wgpu::SurfaceError::OutOfMemory => SurfaceError::OutOfMemory, - wgpu::SurfaceError::Timeout => SurfaceError::Timeout, - other => SurfaceError::Other(format!("{other:?}")), - })?; + // Gancho de verificación: fuerza un DeviceLost sintético cada N cuadros + // (env `LLIMPHI_SIMULATE_DEVICE_LOSS`) para ejercitar el rebuild sin un + // reset de output real. No-op sin la env. + if simulate_device_loss_trip() { + return Err(SurfaceError::DeviceLost); + } + let texture = match self.surface.get_current_texture() { + Ok(t) => t, + // El camino winit tampoco tenía recuperación: un `Outdated`/`Lost` + // (hipo del compositor, cambio de VT) se propagaba y la app saltaba + // el frame para siempre en el peor caso. Reconfiguramos; si no basta, + // recreamos la surface desde la ventana; si ESO falla, el device + // quedó irrecuperable → DeviceLost (rebuild del stack GPU completo). + Err(e @ (wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost)) => { + if let Err(msg) = configure_checked(&self.device, &self.surface, &self.config) { + match self.recreate() { + Ok(()) => eprintln!( + "llimphi-hal: WinitSurface RECREADA tras {e:?} (reconfigure falló: {msg})" + ), + Err(msg2) => { + eprintln!( + "llimphi-hal: reconfigurar tras {e:?} falló ({msg}); recrear también falló ({msg2}) → DeviceLost" + ); + return Err(SurfaceError::DeviceLost); + } + } + } + self.surface.get_current_texture().map_err(|e2| match e2 { + // Sigue rota tras reconfigurar+recrear → device irrecuperable. + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated => { + SurfaceError::DeviceLost + } + wgpu::SurfaceError::OutOfMemory => SurfaceError::OutOfMemory, + wgpu::SurfaceError::Timeout => SurfaceError::Timeout, + other => SurfaceError::Other(format!("{other:?}")), + })? + } + Err(wgpu::SurfaceError::OutOfMemory) => return Err(SurfaceError::OutOfMemory), + Err(wgpu::SurfaceError::Timeout) => return Err(SurfaceError::Timeout), + Err(other) => return Err(SurfaceError::Other(format!("{other:?}"))), + }; let surface_view = texture .texture .create_view(&wgpu::TextureViewDescriptor::default()); diff --git a/llimphi-icons/Cargo.toml b/llimphi-icons/Cargo.toml index 31d3abb..274cfc9 100644 --- a/llimphi-icons/Cargo.toml +++ b/llimphi-icons/Cargo.toml @@ -5,8 +5,8 @@ edition.workspace = true license.workspace = true authors.workspace = true publish.workspace = true -repository.workspace = true description = "llimphi-icons — set mínimo de iconos vectoriales (BezPath en grid 24×24) renderizables vía paint_with. Stroke-based, escalables. Cubre las acciones canónicas de cualquier UI tawasuyu." +repository.workspace = true [dependencies] llimphi-ui = { workspace = true } diff --git a/llimphi-icons/LEEME.md b/llimphi-icons/LEEME.md new file mode 100644 index 0000000..2797850 --- /dev/null +++ b/llimphi-icons/LEEME.md @@ -0,0 +1,54 @@ +# llimphi-icons + +*Read this in English: [README.md](README.md).* + +Set canónico de iconos vectoriales para apps tawasuyu. + +Cada icono es una función pura que devuelve un `BezPath` definido en +un grid lógico de **24×24 unidades**. El renderer escala al rect que +reciba, así un mismo icono sirve para 12px (en una fila de lista) y +para 64px (en una hero card) sin pérdida de nitidez — es vector, +no bitmap. + +## Diseño + +- **Stroke-based, no fill**: los iconos son trazos de ancho 2 (en + unidades del grid) con joins/caps suaves. El stroke se renderiza + con el color que la app elija (típicamente `theme.fg_text` o + `theme.accent`). +- **Geometría minimal, no marca**: glifos genéricos universales, + no "marca registrada". Cada uno debe ser reconocible al primer + vistazo aún en 12×12. +- **Set acotado**: suficientes para cubrir el grueso de acciones y + tipos que aparecen en cualquier UI tawasuyu. Si una app necesita uno + más, lo agrega aquí (no en su propio crate) — la consistencia + visual importa más que el aislamiento. + +## Catálogo + +| Categoría | Iconos | +|--------------|-----------------------------------------------------| +| Documento | `file`, `folder`, `folder_open`, `save`, `open` | +| Edición | `plus`, `minus`, `x`, `check`, `edit`, `trash` | +| Navegación | `chevron_up`, `chevron_down`, `chevron_left`, `chevron_right`, `home`, `search` | +| Estado | `info`, `warning`, `error`, `bell` | +| Sistema | `settings`, `more` | +| Multimedia | `play`, `pause`, `stop`, `skip_*`, `volume*`, `repeat`, `shuffle`, `record`, `equalizer`, `camera`, `gauge` | +| Archivos | `image`, `music`, `film`, `archive`, `code`, `file_text`, `link`, `font` | + +## Uso +```ignore +use llimphi_icons::{Icon, icon_view}; + +// Botón con icono "save": +let btn = View::new(style) + .fill(palette.bg_button) + .children(vec![icon_view(Icon::Save, palette.fg_text, 1.6)]); +``` +El parámetro `stroke_width` (3er arg de `icon_view`) está en unidades +del grid (24×24). `1.6` es el default armonioso; `2.0` para énfasis; +`1.2` para iconos en tipografías pequeñas. + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-icons/README.md b/llimphi-icons/README.md new file mode 100644 index 0000000..268f76b --- /dev/null +++ b/llimphi-icons/README.md @@ -0,0 +1,50 @@ +# llimphi-icons + +The canonical set of vector icons for tawasuyu apps. + +Every icon is a pure function returning a `BezPath` defined on a logical grid of +**24×24 units**. The renderer scales it to whatever rect it gets, so the same +icon serves at 12px (in a list row) and at 64px (in a hero card) with no loss of +sharpness — it is vector, not bitmap. + +## Design + +- **Stroke-based, not filled**: icons are strokes of width 2 (in grid units) with + soft joins and caps. The stroke is rendered in whatever colour the app chooses + (typically `theme.fg_text` or `theme.accent`). +- **Minimal geometry, not a logo**: universal generic glyphs, not "trademarks". + Each one must be recognizable at a glance even at 12×12. +- **A bounded set**: enough to cover the bulk of actions and types appearing in + any tawasuyu UI. If an app needs one more, it adds it here (not in its own + crate) — visual consistency matters more than isolation. + +## Catalog + +| Category | Icons | +|------------|-----------------------------------------------------| +| Document | `file`, `folder`, `folder_open`, `save`, `open` | +| Editing | `plus`, `minus`, `x`, `check`, `edit`, `trash` | +| Navigation | `chevron_up`, `chevron_down`, `chevron_left`, `chevron_right`, `home`, `search` | +| Status | `info`, `warning`, `error`, `bell` | +| System | `settings`, `more` | +| Multimedia | `play`, `pause`, `stop`, `skip_*`, `volume*`, `repeat`, `shuffle`, `record`, `equalizer`, `camera`, `gauge` | +| Files | `image`, `music`, `film`, `archive`, `code`, `file_text`, `link`, `font` | + +## Use + +```ignore +use llimphi_icons::{Icon, icon_view}; + +// A button with the "save" icon: +let btn = View::new(style) + .fill(palette.bg_button) + .children(vec![icon_view(Icon::Save, palette.fg_text, 1.6)]); +``` + +The `stroke_width` parameter (3rd argument of `icon_view`) is in grid units +(24×24). `1.6` is the harmonious default; `2.0` for emphasis; `1.2` for icons +next to small type. + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-icons/src/app_icons.rs b/llimphi-icons/src/app_icons.rs index ebae925..ac83f0f 100644 --- a/llimphi-icons/src/app_icons.rs +++ b/llimphi-icons/src/app_icons.rs @@ -1,7 +1,7 @@ //! `app_icons` — iconos de marca, uno por dominio/app de tawasuyu. //! //! A diferencia del set canónico de [`crate::Icon`] (glifos genéricos de -//! acción: file, save, search…), acá vive **un glifo distintivo por app**. +//! acción: file, save, search…), aquí vive **un glifo distintivo por app**. //! Cada app tiene su símbolo y su **color de marca** propios, pero todos //! comparten el mismo lenguaje visual: //! @@ -49,7 +49,7 @@ pub enum AppIcon { Nakui, Tinkuy, // --- 02_ruway · HACER --- - Ayni, + Suilad, Cards, Chasqui, Llimphi, @@ -62,6 +62,8 @@ pub enum AppIcon { Takiy, Tullpu, Wawa, + Hapiy, + Raymi, // --- 03_ukupacha · RAÍZ --- Agora, Arje, @@ -71,7 +73,7 @@ pub enum AppIcon { } /// Las 29 apps, en orden de cuadrante. Útil para iterar (galerías, tests). -pub const ALL: [AppIcon; 29] = [ +pub const ALL: [AppIcon; 31] = [ AppIcon::Chaka, AppIcon::Khipu, AppIcon::Pineal, @@ -83,7 +85,7 @@ pub const ALL: [AppIcon; 29] = [ AppIcon::Iniy, AppIcon::Nakui, AppIcon::Tinkuy, - AppIcon::Ayni, + AppIcon::Suilad, AppIcon::Cards, AppIcon::Chasqui, AppIcon::Llimphi, @@ -96,6 +98,8 @@ pub const ALL: [AppIcon; 29] = [ AppIcon::Takiy, AppIcon::Tullpu, AppIcon::Wawa, + AppIcon::Hapiy, + AppIcon::Raymi, AppIcon::Agora, AppIcon::Arje, AppIcon::Minga, @@ -118,7 +122,7 @@ impl AppIcon { AppIcon::Iniy => "iniy", AppIcon::Nakui => "nakui", AppIcon::Tinkuy => "tinkuy", - AppIcon::Ayni => "ayni", + AppIcon::Suilad => "suilad", AppIcon::Cards => "cards", AppIcon::Chasqui => "chasqui", AppIcon::Llimphi => "llimphi", @@ -131,6 +135,8 @@ impl AppIcon { AppIcon::Takiy => "takiy", AppIcon::Tullpu => "tullpu", AppIcon::Wawa => "wawa", + AppIcon::Hapiy => "hapiy", + AppIcon::Raymi => "raymi", AppIcon::Agora => "agora", AppIcon::Arje => "arje", AppIcon::Minga => "minga", @@ -162,7 +168,7 @@ impl AppIcon { AppIcon::Iniy => (124, 179, 66), AppIcon::Nakui => (194, 84, 157), AppIcon::Tinkuy => (217, 83, 79), - AppIcon::Ayni => (42, 168, 196), + AppIcon::Suilad => (42, 168, 196), AppIcon::Cards => (142, 99, 206), AppIcon::Chasqui => (52, 179, 106), AppIcon::Llimphi => (229, 91, 122), @@ -175,6 +181,8 @@ impl AppIcon { AppIcon::Takiy => (229, 99, 155), AppIcon::Tullpu => (224, 96, 58), AppIcon::Wawa => (91, 141, 239), + AppIcon::Hapiy => (38, 184, 156), + AppIcon::Raymi => (216, 88, 120), AppIcon::Agora => (47, 158, 143), AppIcon::Arje => (176, 141, 87), AppIcon::Minga => (224, 123, 57), @@ -198,7 +206,7 @@ impl AppIcon { AppIcon::Iniy => path_iniy(), AppIcon::Nakui => path_nakui(), AppIcon::Tinkuy => path_tinkuy(), - AppIcon::Ayni => path_ayni(), + AppIcon::Suilad => path_suilad(), AppIcon::Cards => path_cards(), AppIcon::Chasqui => path_chasqui(), AppIcon::Llimphi => path_llimphi(), @@ -211,6 +219,8 @@ impl AppIcon { AppIcon::Takiy => path_takiy(), AppIcon::Tullpu => path_tullpu(), AppIcon::Wawa => path_wawa(), + AppIcon::Hapiy => path_hapiy(), + AppIcon::Raymi => path_raymi(), AppIcon::Agora => path_agora(), AppIcon::Arje => path_arje(), AppIcon::Minga => path_minga(), @@ -248,6 +258,31 @@ pub fn app_icon_view_colored( }) } +/// Exporta el icono de app como **SVG** (string), en su color de marca. +/// Mismo dibujo que [`app_icon_view`] pero en un formato de archivo: sirve para +/// los `.desktop` (freedesktop `scalable/apps/.svg`), la web, o cualquier +/// consumidor que no renderice con Llimphi. El path es stroke-only con remates +/// redondos, igual que el pintor vectorial. `viewBox 0 0 24 24` (la grilla). +pub fn app_icon_svg(icon: AppIcon, stroke_width: f32) -> String { + let d = icon.path().to_svg(); + let [r, g, b, _] = icon.brand().components; + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let hex = format!("#{:02x}{:02x}{:02x}", q(r), q(g), q(b)); + format!( + "\ +\n", + sw = fmt_num(stroke_width), + ) +} + +/// Formatea un f32 sin ceros de cola (`1.8` no `1.8000`). +fn fmt_num(v: f32) -> String { + let s = format!("{v:.3}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + s.to_string() +} + /// Pintor crudo — para stampear varios iconos de app dentro del mismo /// `paint_with` (una grilla de launcher, por ejemplo). pub fn paint_app_icon( @@ -506,8 +541,8 @@ fn path_tinkuy() -> BezPath { // --- 02_ruway · HACER --- -fn path_ayni() -> BezPath { - // ayni = reciprocidad: dos flechas curvas en ciclo. +fn path_suilad() -> BezPath { + // suilad = saludo mutuo (élfico); ciclo de reciprocidad: dos flechas curvas en ciclo. let mut p = BezPath::new(); // Arco superior, flecha hacia la derecha-abajo. p.move_to((6.0, 8.0)); @@ -774,6 +809,42 @@ fn path_sandokan() -> BezPath { p } +fn path_hapiy() -> BezPath { + // hapiy = asir/capturar: marco de cuatro esquinas (encuadre de captura). + let mut p = BezPath::new(); + p.move_to((4.0, 8.0)); + p.line_to((4.0, 4.0)); + p.line_to((8.0, 4.0)); // sup-izq + p.move_to((16.0, 4.0)); + p.line_to((20.0, 4.0)); + p.line_to((20.0, 8.0)); // sup-der + p.move_to((20.0, 16.0)); + p.line_to((20.0, 20.0)); + p.line_to((16.0, 20.0)); // inf-der + p.move_to((8.0, 20.0)); + p.line_to((4.0, 20.0)); + p.line_to((4.0, 16.0)); // inf-izq + p +} + +fn path_raymi() -> BezPath { + // raymi = fiesta/calendario: hoja con anillas, cabecera y día marcado. + let mut p = BezPath::new(); + p.move_to((4.0, 6.0)); + p.line_to((20.0, 6.0)); + p.line_to((20.0, 20.0)); + p.line_to((4.0, 20.0)); + p.close_path(); + p.move_to((4.0, 10.0)); + p.line_to((20.0, 10.0)); // cabecera + p.move_to((8.0, 4.0)); + p.line_to((8.0, 7.5)); // anilla izq + p.move_to((16.0, 4.0)); + p.line_to((16.0, 7.5)); // anilla der + push_all(&mut p, circle(12.0, 15.0, 1.8, 12)); // día marcado + p +} + fn path_wawa_explorer() -> BezPath { // wawa-explorer = launchpad: grilla 2×2. let mut p = BezPath::new(); @@ -803,6 +874,17 @@ mod tests { } } + #[test] + fn svg_export_es_valido() { + for icon in ALL { + let svg = app_icon_svg(icon, 1.8); + assert!(svg.starts_with(""), "{} svg sin cierre", icon.name()); + } + } + #[test] fn app_names_are_unique() { let mut names: Vec<&str> = ALL.iter().map(|i| i.name()).collect(); diff --git a/llimphi-icons/src/lib.rs b/llimphi-icons/src/lib.rs index 5767476..75b531e 100644 --- a/llimphi-icons/src/lib.rs +++ b/llimphi-icons/src/lib.rs @@ -124,6 +124,48 @@ pub enum Icon { Table, /// Dos paneles verticales lado a lado (modo dual). Columns, + // --- Dominio: creador de mundos (llimphi-voxel-studio) --- + /// Globo terráqueo (Mundos). + Globe, + /// Cadena montañosa (Biomas). + Mountain, + /// Hoja con nervadura (Materiales). + Leaf, + /// Silueta de persona (Seres). + User, + /// Gota de agua (Leyes / físicas). + Droplet, + // --- Sistema / settings panels (sweep glifo→vector 2026-06-30) --- + /// Reloj con manecillas (hora, contextos, tiempo). + Clock, + /// Símbolo de encendido/apagado. + Power, + /// Sobre de correo. + Mail, + /// Teclado (atajos). + Keyboard, + /// Paleta de pintor (themes/apariencia). + Palette, + /// Candado (seguridad/privacidad). + Lock, + /// Llave. + Key, + /// Monitor/pantalla (sistema, acerca del equipo). + Monitor, + /// Destello de cuatro puntas (animaciones, efectos). + Sparkle, + /// Micrófono (voz, audio in). + Mic, + /// Ratón. + Mouse, + /// Nube (red/online). + Cloud, + /// Luna (modo oscuro, noche). + Moon, + /// Flecha circular (recargar/refrescar). + Refresh, + /// Pieza de rompecabezas (plugins/módulos). + Puzzle, } impl Icon { @@ -182,6 +224,26 @@ impl Icon { Icon::Rows => "rows", Icon::Table => "table", Icon::Columns => "columns", + Icon::Globe => "globe", + Icon::Mountain => "mountain", + Icon::Leaf => "leaf", + Icon::User => "user", + Icon::Droplet => "droplet", + Icon::Clock => "clock", + Icon::Power => "power", + Icon::Mail => "mail", + Icon::Keyboard => "keyboard", + Icon::Palette => "palette", + Icon::Lock => "lock", + Icon::Key => "key", + Icon::Monitor => "monitor", + Icon::Sparkle => "sparkle", + Icon::Mic => "mic", + Icon::Mouse => "mouse", + Icon::Cloud => "cloud", + Icon::Moon => "moon", + Icon::Refresh => "refresh", + Icon::Puzzle => "puzzle", } } @@ -240,8 +302,122 @@ impl Icon { Icon::Rows => path_rows(), Icon::Table => path_table(), Icon::Columns => path_columns(), + Icon::Globe => path_globe(), + Icon::Mountain => path_mountain(), + Icon::Leaf => path_leaf(), + Icon::User => path_user(), + Icon::Droplet => path_droplet(), + Icon::Clock => path_clock(), + Icon::Power => path_power(), + Icon::Mail => path_mail(), + Icon::Keyboard => path_keyboard(), + Icon::Palette => path_palette(), + Icon::Lock => path_lock(), + Icon::Key => path_key(), + Icon::Monitor => path_monitor(), + Icon::Sparkle => path_sparkle(), + Icon::Mic => path_mic(), + Icon::Mouse => path_mouse(), + Icon::Cloud => path_cloud(), + Icon::Moon => path_moon(), + Icon::Refresh => path_refresh(), + Icon::Puzzle => path_puzzle(), } } + + /// Mapea un **glifo unicode** (el que muchas apps usaban como ícono de + /// texto) al `Icon` vectorial equivalente. Es el corazón del barrido + /// glifo→vector: un sitio de render hace `from_glyph(s)` y, si hay match, + /// pinta el vector (determinista en toda máquina) en vez del glifo de fuente + /// (que en hardware sin esa fuente sale notdef/tofu). `None` ⇒ el caller cae + /// a texto. Acepta el glifo con o sin variation-selector (`\u{FE0F}`). + pub fn from_glyph(glifo: &str) -> Option { + let g = glifo.trim().trim_end_matches('\u{FE0F}'); + let mut chars = g.chars(); + let c = chars.next()?; + if chars.next().is_some() { + // Más de un code-point (sin el VS16): no es un glifo-ícono simple. + return None; + } + Some(match c { + // Multimedia / transporte + '▶' | '►' | '⏵' => Icon::Play, + '⏸' => Icon::Pause, + '⏹' | '■' => Icon::Stop, + '⏺' | '●' | '◉' | '⚫' => Icon::Record, + '⏮' | '⏪' => Icon::SkipBack, + '⏭' | '⏩' => Icon::SkipForward, + '🔀' => Icon::Shuffle, + '🔁' | '🔂' => Icon::Repeat, + '🔊' | '🔉' | '🔈' => Icon::Volume, + '🔇' => Icon::VolumeMute, + '♪' | '♫' | '🎵' | '🎶' => Icon::Music, + '🎙' | '🎤' => Icon::Mic, + '🎛' | '🎚' => Icon::Equalizer, + '📷' | '📸' => Icon::Camera, + '🎞' | '🎬' | '📽' => Icon::Film, + '🖼' => Icon::Image, + // Navegación / chevrons / flechas + '▲' | '△' => Icon::ChevronUp, + '▼' | '▽' => Icon::ChevronDown, + '◀' | '◁' | '‹' => Icon::ChevronLeft, + '▷' | '›' | '❯' | '❭' | '⟩' => Icon::ChevronRight, + '⟳' | '🔃' | '🔄' | '↻' | '⥁' => Icon::Refresh, + // Sistema / settings + '⚙' | '🛠' | '🔧' => Icon::Settings, + '☰' | '≡' | '≣' | '𝍢' => Icon::Rows, + '▦' | '▤' | '⊞' | '𐩕' => Icon::Grid, + '⏻' | '⏼' | '⭘' => Icon::Power, + '🎨' => Icon::Palette, + '⌨' => Icon::Keyboard, + '🖥' | '💻' | '🖳' => Icon::Monitor, + '🖱' => Icon::Mouse, + '🔐' | '🔒' | '🔏' => Icon::Lock, + '🔑' | '🗝' => Icon::Key, + '✉' | '📧' | '📨' | '📩' => Icon::Mail, + '🧩' => Icon::Puzzle, + '☁' => Icon::Cloud, + '🌐' | '🌍' | '🌎' | '🌏' => Icon::Globe, + '🌙' | '🌚' | '☾' | '◐' => Icon::Moon, + '✨' | '✦' | '✶' | '✷' | '❇' | '❋' | '★' | '☆' | '✩' => Icon::Sparkle, + '◴' | '◷' | '◵' | '◶' | '🕐' | '🕒' | '⏰' | '⌚' => Icon::Clock, + // Documento / contenido + '📄' | '📃' | '📝' => Icon::FileText, + '📁' | '📂' => Icon::Folder, + '💾' => Icon::Save, + '🔍' | '🔎' => Icon::Search, + '🔔' => Icon::Bell, + '🗑' => Icon::Trash, + '👤' | '🧑' | '🙍' => Icon::User, + 'ℹ' | 'ⓘ' => Icon::Info, + '⚠' => Icon::Warning, + '➕' | '+' => Icon::Plus, + '✕' | '✖' | '×' | '✗' => Icon::X, + '✓' | '✔' => Icon::Check, + _ => return None, + }) + } +} + +/// Vista de un glifo como **ícono vectorial** si [`Icon::from_glyph`] lo conoce, +/// o como texto (su fallback histórico) si no. Es el reemplazo directo de +/// `.text_aligned(glifo, …)` en los sitios de render que usaban íconos-glifo: +/// el resultado es determinista en toda máquina cuando hay match. +pub fn glyph_or_text_view( + glifo: &str, + size: f32, + color: Color, + stroke_width: f32, +) -> View { + match Icon::from_glyph(glifo) { + Some(icon) => icon_view(icon, color, stroke_width), + None => View::new(Style { + position: Position::Absolute, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .text_aligned(glifo.to_string(), size, color, llimphi_ui::llimphi_text::Alignment::Center), + } } /// Construye un `View` que pinta el icono ocupando todo el rect del @@ -635,6 +811,72 @@ fn path_circle(cx: f64, cy: f64, r: f64, segments: usize) -> BezPath { p } +// --------------------------------------------------------------------- +// Dominio — creador de mundos (llimphi-voxel-studio) +// --------------------------------------------------------------------- + +fn path_globe() -> BezPath { + // Esfera + un meridiano (elipse angosta) + dos paralelos. + let mut p = path_circle(12.0, 12.0, 9.0, 32); + p.move_to((12.0, 3.0)); + p.curve_to((6.5, 7.0), (6.5, 17.0), (12.0, 21.0)); + p.move_to((12.0, 3.0)); + p.curve_to((17.5, 7.0), (17.5, 17.0), (12.0, 21.0)); + p.move_to((3.6, 9.3)); + p.line_to((20.4, 9.3)); + p.move_to((3.6, 14.7)); + p.line_to((20.4, 14.7)); + p +} + +fn path_mountain() -> BezPath { + // Cordillera de dos picos + nieve en el mayor. + let mut p = BezPath::new(); + p.move_to((2.0, 20.0)); + p.line_to((8.5, 8.0)); + p.line_to((13.0, 15.0)); + p.line_to((16.0, 11.0)); + p.line_to((22.0, 20.0)); + p.close_path(); + // Nieve del pico mayor. + p.move_to((6.6, 11.5)); + p.line_to((8.5, 8.0)); + p.line_to((10.3, 11.0)); + p +} + +fn path_leaf() -> BezPath { + // Hoja: contorno por dos curvas + nervadura central. + let mut p = BezPath::new(); + p.move_to((5.0, 19.0)); + p.curve_to((5.0, 9.0), (12.0, 4.0), (19.0, 5.0)); + p.curve_to((20.0, 12.0), (15.0, 19.0), (5.0, 19.0)); + p.close_path(); + p.move_to((7.5, 16.5)); + p.line_to((16.5, 7.5)); + p +} + +fn path_user() -> BezPath { + // Persona: cabeza (círculo) + hombros (arco). + let mut p = path_circle(12.0, 8.0, 4.0, 24); + p.move_to((4.5, 20.0)); + p.curve_to((4.5, 14.5), (19.5, 14.5), (19.5, 20.0)); + p +} + +fn path_droplet() -> BezPath { + // Gota: punta arriba, panza redonda abajo. + let mut p = BezPath::new(); + p.move_to((12.0, 3.0)); + p.curve_to((12.0, 3.0), (18.5, 11.0), (18.5, 15.5)); + p.curve_to((18.5, 19.5), (15.5, 21.5), (12.0, 21.5)); + p.curve_to((8.5, 21.5), (5.5, 19.5), (5.5, 15.5)); + p.curve_to((5.5, 11.0), (12.0, 3.0), (12.0, 3.0)); + p.close_path(); + p +} + // --------------------------------------------------------------------- // Multimedia — transporte de reproductor (media-app y demás) // --------------------------------------------------------------------- @@ -1025,6 +1267,214 @@ fn path_columns() -> BezPath { p } +// ---- Sweep glifo→vector: íconos de sistema/settings ----------------------- + +fn path_clock() -> BezPath { + let mut p = path_circle(12.0, 12.0, 8.5, 28); + p.move_to((12.0, 12.0)); + p.line_to((12.0, 6.5)); // manecilla de hora + p.move_to((12.0, 12.0)); + p.line_to((16.0, 13.5)); // minutero + p +} + +fn path_power() -> BezPath { + use std::f64::consts::{FRAC_PI_2, TAU}; + let mut p = BezPath::new(); + let (cx, cy, r) = (12.0, 13.0, 7.0); + let segs = 28; + // Anillo con un hueco arriba (clásico símbolo de encendido). + let start = -FRAC_PI_2 + 0.55; + let end = -FRAC_PI_2 - 0.55 + TAU; + for i in 0..=segs { + let t = start + (end - start) * (i as f64) / (segs as f64); + let (x, y) = (cx + r * t.cos(), cy + r * t.sin()); + if i == 0 { + p.move_to((x, y)); + } else { + p.line_to((x, y)); + } + } + p.move_to((12.0, 4.0)); + p.line_to((12.0, 12.0)); // barra vertical + p +} + +fn path_mail() -> BezPath { + let mut p = BezPath::new(); + p.move_to((3.5, 6.0)); + p.line_to((20.5, 6.0)); + p.line_to((20.5, 18.0)); + p.line_to((3.5, 18.0)); + p.close_path(); + p.move_to((3.5, 6.5)); + p.line_to((12.0, 13.0)); + p.line_to((20.5, 6.5)); // solapa + p +} + +fn path_keyboard() -> BezPath { + let mut p = BezPath::new(); + p.move_to((2.5, 7.0)); + p.line_to((21.5, 7.0)); + p.line_to((21.5, 17.0)); + p.line_to((2.5, 17.0)); + p.close_path(); + for (x, y) in [(5.5, 10.0), (9.0, 10.0), (12.5, 10.0), (16.0, 10.0)] { + p.move_to((x, y)); + p.line_to((x + 1.6, y)); + } + p.move_to((8.0, 13.8)); + p.line_to((16.0, 13.8)); // barra espaciadora + p +} + +fn path_palette() -> BezPath { + let mut p = path_circle(12.0, 12.0, 8.5, 28); + let hueco = path_circle(12.0, 16.5, 1.8, 12); // agujero del pulgar + for el in hueco.elements() { + p.push(*el); + } + for (cx, cy) in [(8.3, 9.0), (12.0, 7.4), (15.7, 9.4)] { + let muestra = path_circle(cx, cy, 1.0, 10); + for el in muestra.elements() { + p.push(*el); + } + } + p +} + +fn path_lock() -> BezPath { + let mut p = BezPath::new(); + p.move_to((6.0, 11.0)); + p.line_to((18.0, 11.0)); + p.line_to((18.0, 20.0)); + p.line_to((6.0, 20.0)); + p.close_path(); + p.move_to((8.5, 11.0)); + p.line_to((8.5, 8.0)); + p.curve_to((8.5, 5.0), (15.5, 5.0), (15.5, 8.0)); + p.line_to((15.5, 11.0)); // arco + p +} + +fn path_key() -> BezPath { + let mut p = path_circle(8.0, 8.0, 3.5, 18); // ojo + p.move_to((10.5, 10.5)); + p.line_to((19.0, 19.0)); // caña + p.move_to((16.5, 16.5)); + p.line_to((19.0, 14.0)); // diente + p +} + +fn path_monitor() -> BezPath { + let mut p = BezPath::new(); + p.move_to((3.0, 5.0)); + p.line_to((21.0, 5.0)); + p.line_to((21.0, 16.0)); + p.line_to((3.0, 16.0)); + p.close_path(); + p.move_to((9.5, 19.0)); + p.line_to((14.5, 19.0)); // base + p.move_to((12.0, 16.0)); + p.line_to((12.0, 19.0)); // cuello + p +} + +fn path_sparkle() -> BezPath { + let mut p = BezPath::new(); + p.move_to((12.0, 3.0)); + p.line_to((13.7, 10.3)); + p.line_to((21.0, 12.0)); + p.line_to((13.7, 13.7)); + p.line_to((12.0, 21.0)); + p.line_to((10.3, 13.7)); + p.line_to((3.0, 12.0)); + p.line_to((10.3, 10.3)); + p.close_path(); + p +} + +fn path_mic() -> BezPath { + let mut p = BezPath::new(); + p.move_to((9.0, 6.0)); + p.curve_to((9.0, 3.5), (15.0, 3.5), (15.0, 6.0)); + p.line_to((15.0, 11.0)); + p.curve_to((15.0, 13.5), (9.0, 13.5), (9.0, 11.0)); + p.close_path(); + p.move_to((6.5, 11.0)); + p.curve_to((6.5, 16.0), (17.5, 16.0), (17.5, 11.0)); // soporte + p.move_to((12.0, 16.0)); + p.line_to((12.0, 20.0)); + p.move_to((8.5, 20.0)); + p.line_to((15.5, 20.0)); + p +} + +fn path_mouse() -> BezPath { + let mut p = BezPath::new(); + p.move_to((7.0, 8.0)); + p.curve_to((7.0, 4.0), (17.0, 4.0), (17.0, 8.0)); + p.line_to((17.0, 16.0)); + p.curve_to((17.0, 20.0), (7.0, 20.0), (7.0, 16.0)); + p.close_path(); + p.move_to((12.0, 5.0)); + p.line_to((12.0, 9.0)); // rueda + p +} + +fn path_cloud() -> BezPath { + let mut p = BezPath::new(); + p.move_to((7.0, 17.0)); + p.curve_to((3.5, 17.0), (3.5, 12.0), (7.0, 12.0)); + p.curve_to((7.0, 7.5), (13.5, 7.0), (14.5, 11.0)); + p.curve_to((18.5, 10.5), (20.5, 16.0), (17.0, 17.0)); + p.close_path(); + p +} + +fn path_moon() -> BezPath { + let mut p = BezPath::new(); + p.move_to((15.0, 4.5)); + p.curve_to((9.0, 6.0), (9.0, 18.0), (15.0, 19.5)); + p.curve_to((10.0, 17.0), (10.0, 7.0), (15.0, 4.5)); + p.close_path(); + p +} + +fn path_refresh() -> BezPath { + let mut p = BezPath::new(); + let (cx, cy, r) = (12.0, 12.0, 7.0); + let segs = 24; + let (start, end) = (-2.2_f64, 2.2_f64); + for i in 0..=segs { + let t = start + (end - start) * (i as f64) / (segs as f64); + let (x, y) = (cx + r * t.cos(), cy + r * t.sin()); + if i == 0 { + p.move_to((x, y)); + } else { + p.line_to((x, y)); + } + } + let (ex, ey) = (cx + r * end.cos(), cy + r * end.sin()); + p.move_to((ex - 1.6, ey - 1.9)); + p.line_to((ex, ey)); + p.line_to((ex + 2.2, ey - 0.7)); // cabeza de flecha + p +} + +fn path_puzzle() -> BezPath { + let mut p = BezPath::new(); + p.move_to((5.0, 9.0)); + p.line_to((9.5, 9.0)); + p.curve_to((9.5, 6.0), (14.5, 6.0), (14.5, 9.0)); + p.line_to((19.0, 9.0)); + p.line_to((19.0, 19.0)); + p.line_to((5.0, 19.0)); + p.close_path(); + p +} + #[cfg(test)] mod tests { use super::*; @@ -1075,4 +1525,34 @@ mod tests { names.dedup(); assert_eq!(names.len(), n, "nombres duplicados en Icon::name()"); } + + #[test] + fn iconos_nuevos_tienen_path() { + let nuevos = [ + Icon::Clock, Icon::Power, Icon::Mail, Icon::Keyboard, Icon::Palette, + Icon::Lock, Icon::Key, Icon::Monitor, Icon::Sparkle, Icon::Mic, + Icon::Mouse, Icon::Cloud, Icon::Moon, Icon::Refresh, Icon::Puzzle, + ]; + for icon in nuevos { + assert!(!icon.path().elements().is_empty(), "{} sin path", icon.name()); + } + } + + #[test] + fn from_glyph_mapea_los_comunes() { + // Casos que cubrían el bug del wawa-panel y de los menús. + assert_eq!(Icon::from_glyph("⚙"), Some(Icon::Settings)); + assert_eq!(Icon::from_glyph("🎨"), Some(Icon::Palette)); + assert_eq!(Icon::from_glyph("⌨"), Some(Icon::Keyboard)); + assert_eq!(Icon::from_glyph("⏻"), Some(Icon::Power)); + assert_eq!(Icon::from_glyph("✉"), Some(Icon::Mail)); + assert_eq!(Icon::from_glyph("🔊"), Some(Icon::Volume)); + assert_eq!(Icon::from_glyph("≡"), Some(Icon::Rows)); + assert_eq!(Icon::from_glyph("▶"), Some(Icon::Play)); + // Variation selector (emoji presentation) se tolera. + assert_eq!(Icon::from_glyph("🖥\u{FE0F}"), Some(Icon::Monitor)); + // Texto real (no un glifo-ícono) no matchea → caería a texto. + assert_eq!(Icon::from_glyph("Archivo"), None); + assert_eq!(Icon::from_glyph(""), None); + } } diff --git a/llimphi-image/Cargo.toml b/llimphi-image/Cargo.toml new file mode 100644 index 0000000..90ebdf7 --- /dev/null +++ b/llimphi-image/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "llimphi-image" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-image — helper para decodificar bytes/path a `peniko::Image` listo para `View::image()`. Reemplaza el patrón duplicado (image::ImageReader + to_rgba8 + Blob + Image::new) que hoy aparece en cada app que muestra imágenes." + +[features] +# Descarga + caché de imágenes por URL (ureq síncrono). Opt-in para que el +# crate base siga siendo sólo-decode, sin dependencia de red. +net = ["dep:ureq"] + +[dependencies] +llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" } +image = { workspace = true } +ureq = { workspace = true, optional = true } diff --git a/llimphi-image/LEEME.md b/llimphi-image/LEEME.md new file mode 100644 index 0000000..2df5241 --- /dev/null +++ b/llimphi-image/LEEME.md @@ -0,0 +1,41 @@ +# llimphi-image + +*Read this in English: [README.md](README.md).* + +Decode pipeline para `peniko::Image`. + +Hoy el caller que quiere mostrar una imagen con `View::image()` hace: +```ignore +let reader = ImageReader::open(path)?.with_guessed_format()?; +let img = reader.decode()?; +let rgba = img.to_rgba8(); +let (w, h) = (rgba.width(), rgba.height()); +let blob = Blob::from(rgba.into_raw()); +let pen = Image::new(blob, ImageFormat::Rgba8, w, h); +``` +Cinco líneas + dos llamadas a `?` por cada caller (nahual viewer, +mirada wallpaper, viewer de gallería, etc.). Este crate las +encapsula en dos helpers: + +- `decode_bytes` — toma `&[u8]` y devuelve `Result`. +- `load_path` — toma `&Path` + `max_bytes` cap y devuelve + `Result`, con guardia de tamaño en disco (las apps + no quieren leer un .iso de 4 GB pensando que es una imagen). + +Con la feature `net` (opt-in, ureq síncrono) se suman descarga + caché por +URL: `fetch_bytes`/`load_url` (bloqueantes, para correr en un worker) y +`ImageCache` (`Clone + Send + Sync`) que la `view` consulta en el hilo UI +mientras un `Handle::spawn` la puebla. El crate base queda sin dep de red. + +`image` (del crate `image`) se construye con `to_rgba8` siempre — la +conversión necesaria para `peniko::ImageFormat::Rgba8`. Para imagen +ya en `rgba8` en memoria (sin necesidad de decodificación), ver +`from_rgba8`. + +Formatos: los que active la feature del crate `image` upstream (en +el workspace, hoy: PNG, JPEG, WEBP). Otros se pueden habilitar +agregando la feature; el helper no lo limita. + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-image/README.md b/llimphi-image/README.md new file mode 100644 index 0000000..ebd4f98 --- /dev/null +++ b/llimphi-image/README.md @@ -0,0 +1,39 @@ +# llimphi-image + +Decode pipeline for `peniko::Image`. + +Today a caller wanting to show an image with `View::image()` writes: + +```ignore +let reader = ImageReader::open(path)?.with_guessed_format()?; +let img = reader.decode()?; +let rgba = img.to_rgba8(); +let (w, h) = (rgba.width(), rgba.height()); +let blob = Blob::from(rgba.into_raw()); +let pen = Image::new(blob, ImageFormat::Rgba8, w, h); +``` + +Five lines plus two `?` per caller (the nahual viewer, the mirada wallpaper, the +gallery viewer, and so on). This crate encapsulates them in two helpers: + +- `decode_bytes` — takes `&[u8]` and returns `Result`. +- `load_path` — takes a `&Path` plus a `max_bytes` cap and returns + `Result`, with an on-disk size guard (apps do not want to read a + 4 GB `.iso` thinking it is an image). + +With the `net` feature (opt-in, synchronous ureq) it adds download plus per-URL +caching: `fetch_bytes`/`load_url` (blocking, to run in a worker) and `ImageCache` +(`Clone + Send + Sync`) that the `view` consults on the UI thread while a +`Handle::spawn` populates it. The base crate keeps no network dependency. + +The `image` (from the `image` crate) is always built with `to_rgba8` — the +conversion `peniko::ImageFormat::Rgba8` requires. For an image already in +`rgba8` in memory (no decoding needed) see `from_rgba8`. + +Formats: whatever the upstream `image` crate's features enable (in this +workspace, today: PNG, JPEG, WEBP). Others can be turned on by adding the +feature; the helper does not limit them. + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-image/src/lib.rs b/llimphi-image/src/lib.rs new file mode 100644 index 0000000..bf9eb4c --- /dev/null +++ b/llimphi-image/src/lib.rs @@ -0,0 +1,449 @@ +//! `llimphi-image` — decode pipeline para `peniko::Image`. +//! +//! Hoy el caller que quiere mostrar una imagen con `View::image()` hace: +//! +//! ```ignore +//! let reader = ImageReader::open(path)?.with_guessed_format()?; +//! let img = reader.decode()?; +//! let rgba = img.to_rgba8(); +//! let (w, h) = (rgba.width(), rgba.height()); +//! let blob = Blob::from(rgba.into_raw()); +//! let pen = Image::new(blob, ImageFormat::Rgba8, w, h); +//! ``` +//! +//! Cinco líneas + dos llamadas a `?` por cada caller (nahual viewer, +//! mirada wallpaper, viewer de gallería, etc.). Este crate las +//! encapsula en dos helpers: +//! +//! - [`decode_bytes`] — toma `&[u8]` y devuelve `Result`. +//! - [`load_path`] — toma `&Path` + `max_bytes` cap y devuelve +//! `Result`, con guardia de tamaño en disco (las apps +//! no quieren leer un .iso de 4 GB pensando que es una imagen). +//! +//! Con la feature `net` (opt-in, ureq síncrono) se suman descarga + caché por +//! URL: [`fetch_bytes`]/[`load_url`] (bloqueantes, para correr en un worker) y +//! [`ImageCache`] (`Clone + Send + Sync`) que la `view` consulta en el hilo UI +//! mientras un `Handle::spawn` la puebla. El crate base queda sin dep de red. +//! +//! `image` (del crate `image`) se construye con `to_rgba8` siempre — la +//! conversión necesaria para `peniko::ImageFormat::Rgba8`. Para imagen +//! ya en `rgba8` en memoria (sin necesidad de decodificación), ver +//! [`from_rgba8`]. +//! +//! Formatos: los que active la feature del crate `image` upstream (en +//! el workspace, hoy: PNG, JPEG, WEBP). Otros se pueden habilitar +//! agregando la feature; el helper no lo limita. + +#![forbid(unsafe_code)] + +use std::path::Path; +use std::sync::Arc; + +pub use llimphi_raster::peniko::{Blob, ImageAlphaType, ImageBrush as Image, ImageData, ImageFormat}; + +/// Errores que puede devolver el decode pipeline. Mantenemos el detalle +/// upstream (`String` con el mensaje del crate `image` o de IO) para no +/// perder información al cruzar el seam, pero clasificado para que el +/// caller decida si mostrar diferenciado (`TooBig` ≠ `Decode`). +#[derive(Debug)] +pub enum DecodeError { + /// IO error leyendo el path (no existe, sin permisos, etc.). + Io(std::io::Error), + /// El archivo supera el cap `max_bytes` pasado a [`load_path`]. + TooBig { + size_bytes: u64, + max_bytes: u64, + }, + /// El reader no reconoce el formato (extensión + magic bytes no + /// matchean ninguno activo). + UnsupportedFormat, + /// El decoder falló (archivo corrupto, formato malo). + Decode(String), +} + +impl std::fmt::Display for DecodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DecodeError::Io(e) => write!(f, "IO: {e}"), + DecodeError::TooBig { size_bytes, max_bytes } => { + write!(f, "archivo demasiado grande: {size_bytes} bytes (cap: {max_bytes})") + } + DecodeError::UnsupportedFormat => f.write_str("formato no soportado"), + DecodeError::Decode(s) => write!(f, "decode: {s}"), + } + } +} + +impl std::error::Error for DecodeError {} + +impl From for DecodeError { + fn from(e: std::io::Error) -> Self { + DecodeError::Io(e) + } +} + +/// Construye un `peniko::Image` directamente desde bytes RGBA8 ya +/// decodificados. No invoca el crate `image`. Útil para imágenes +/// sintéticas o pre-decodificadas. +/// +/// El `rgba` debe ser exactamente `w * h * 4` bytes (4 canales). Si +/// no, el render mostrará basura — el helper no valida porque el +/// constructor de `peniko::Image` tampoco; queda al caller. +pub fn from_rgba8(rgba: Vec, w: u32, h: u32) -> Image { + let blob = Blob::new(Arc::new(rgba)); + Image::new(ImageData { + data: blob, + format: ImageFormat::Rgba8, + alpha_type: ImageAlphaType::Alpha, + width: w, + height: h, + }) +} + +/// Decodifica bytes a un `peniko::Image` listo para `View::image()`. +/// El crate `image` adivina el formato por magic bytes (no por +/// extensión) — apto para data URIs, descargas, blobs de DB. +pub fn decode_bytes(bytes: &[u8]) -> Result { + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .map_err(DecodeError::Io)?; + if reader.format().is_none() { + return Err(DecodeError::UnsupportedFormat); + } + let img = reader + .decode() + .map_err(|e| DecodeError::Decode(e.to_string()))?; + let rgba = img.to_rgba8(); + let (w, h) = (rgba.width(), rgba.height()); + Ok(from_rgba8(rgba.into_raw(), w, h)) +} + +/// Lee un path, valida tamaño en disco, decodifica. El cap se compara +/// contra el tamaño del archivo (NO la imagen decodificada, que en +/// RGBA8 puede ser mucho mayor — un PNG 4K decomprimido ocupa ~64 MB). +/// `max_bytes = 0` deshabilita el cap. +pub fn load_path(path: &Path, max_bytes: u64) -> Result { + if max_bytes > 0 { + let meta = std::fs::metadata(path).map_err(DecodeError::Io)?; + if meta.len() > max_bytes { + return Err(DecodeError::TooBig { + size_bytes: meta.len(), + max_bytes, + }); + } + } + let reader = image::ImageReader::open(path) + .map_err(DecodeError::Io)? + .with_guessed_format() + .map_err(DecodeError::Io)?; + if reader.format().is_none() { + return Err(DecodeError::UnsupportedFormat); + } + let img = reader + .decode() + .map_err(|e| DecodeError::Decode(e.to_string()))?; + let rgba = img.to_rgba8(); + let (w, h) = (rgba.width(), rgba.height()); + Ok(from_rgba8(rgba.into_raw(), w, h)) +} + +/// Descarga + caché de imágenes por URL (feature `net`). Síncrono (ureq): +/// la idea es que la app lo llame desde un worker (`Handle::spawn` / +/// `std::thread`), NO en el hilo de UI, y despache un `Msg` con la imagen +/// decodificada. La [`ImageCache`] es `Clone + Send + Sync` (interna +/// `Arc>`) para compartirse entre el hilo UI (lecturas) y los +/// workers (descargas). +#[cfg(feature = "net")] +mod net { + use super::{decode_bytes, DecodeError, Image}; + use std::collections::HashMap; + use std::io::Read; + use std::sync::{Arc, Mutex}; + + /// Errores de [`fetch_bytes`]/[`load_url`]. + #[derive(Debug)] + pub enum FetchError { + /// La capa de red/HTTP falló (DNS, TLS, status no-2xx, timeout…). + Network(String), + /// El cuerpo descargado superó `max_bytes`. + TooBig { max_bytes: u64 }, + /// Se descargó pero no se pudo decodificar como imagen. + Decode(DecodeError), + } + + impl std::fmt::Display for FetchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FetchError::Network(s) => write!(f, "red: {s}"), + FetchError::TooBig { max_bytes } => { + write!(f, "descarga supera el cap de {max_bytes} bytes") + } + FetchError::Decode(e) => write!(f, "{e}"), + } + } + } + + impl std::error::Error for FetchError {} + + impl From for FetchError { + fn from(e: DecodeError) -> Self { + FetchError::Decode(e) + } + } + + /// Descarga los bytes crudos de una URL (bloqueante). `max_bytes = 0` + /// deshabilita el cap; si no, corta la lectura apenas se excede (no + /// bufferiza un cuerpo gigante antes de rechazarlo). + pub fn fetch_bytes(url: &str, max_bytes: u64) -> Result, FetchError> { + let resp = ureq::get(url) + .call() + .map_err(|e| FetchError::Network(e.to_string()))?; + let mut reader = resp.into_reader(); + let mut buf = Vec::new(); + if max_bytes > 0 { + // Leemos hasta max_bytes+1: si llega a +1 sabemos que se pasó. + reader + .by_ref() + .take(max_bytes + 1) + .read_to_end(&mut buf) + .map_err(|e| FetchError::Network(e.to_string()))?; + if buf.len() as u64 > max_bytes { + return Err(FetchError::TooBig { max_bytes }); + } + } else { + reader + .read_to_end(&mut buf) + .map_err(|e| FetchError::Network(e.to_string()))?; + } + Ok(buf) + } + + /// Descarga + decodifica una URL a `peniko::Image` (bloqueante). Sin + /// caché — para eso usar [`ImageCache::get_or_fetch`]. + pub fn load_url(url: &str, max_bytes: u64) -> Result { + let bytes = fetch_bytes(url, max_bytes)?; + Ok(decode_bytes(&bytes)?) + } + + /// Techo de imágenes vivas en la caché. Sin cota, `get_or_fetch` acumulaba + /// toda `Image` descargada durante la sesión (p. ej. navegar resultados de + /// media-tube) sin evictar nunca — fuga monótona de RAM. `get` re-descarga + /// si una URL fue evictada, así que evictar la más vieja es transparente. + pub(crate) const CACHE_CAP: usize = 256; + + /// Caché de imágenes por URL, compartible entre hilos. La `peniko::Image` + /// es barata de clonar (su `Blob` es `Arc`-backed), así que `get` devuelve + /// una copia lista para `View::image()`. Evicción FIFO al superar + /// `CACHE_CAP`: `order` lleva el orden de inserción de claves nuevas, en + /// paso con `inner`. + #[derive(Clone, Default)] + pub struct ImageCache { + inner: Arc>>, + order: Arc>>, + } + + impl ImageCache { + pub fn new() -> Self { + Self::default() + } + + /// Imagen cacheada para esa URL, si ya se descargó. Barato — esto es + /// lo que la `view` consulta en el hilo UI cada frame. + pub fn get(&self, url: &str) -> Option { + self.inner.lock().ok()?.get(url).cloned() + } + + /// `true` si la URL ya está en caché (sin clonar la imagen). + pub fn contains(&self, url: &str) -> bool { + self.inner + .lock() + .map(|m| m.contains_key(url)) + .unwrap_or(false) + } + + /// Inserta (o reemplaza) la imagen de una URL. Lo llama el worker tras + /// decodificar, o la app si ya tiene la imagen por otra vía. Evicta la + /// más vieja (FIFO) al superar `CACHE_CAP`. + pub fn insert(&self, url: impl Into, img: Image) { + let url = url.into(); + if let Ok(mut m) = self.inner.lock() { + let es_nueva = !m.contains_key(&url); + m.insert(url.clone(), img); + if es_nueva { + if let Ok(mut order) = self.order.lock() { + order.push_back(url); + while m.len() > CACHE_CAP { + match order.pop_front() { + Some(vieja) => { + m.remove(&vieja); + } + None => break, + } + } + } + } + } + } + + /// Vacía la caché (p. ej. al cambiar de sesión/usuario). + pub fn clear(&self) { + if let Ok(mut m) = self.inner.lock() { + m.clear(); + } + if let Ok(mut order) = self.order.lock() { + order.clear(); + } + } + + /// Cantidad de imágenes cacheadas. + pub fn len(&self) -> usize { + self.inner.lock().map(|m| m.len()).unwrap_or(0) + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Devuelve la imagen cacheada o, si falta, la descarga+decodifica y la + /// cachea (bloqueante). **Llamar desde un worker**, no en el hilo UI. + /// El patrón típico: la `view` hace `cache.get(url)`; si es `None`, + /// dispara `Handle::spawn` que llama `get_or_fetch` y al volver + /// despacha un `Msg` para repintar. + pub fn get_or_fetch(&self, url: &str, max_bytes: u64) -> Result { + if let Some(img) = self.get(url) { + return Ok(img); + } + let img = load_url(url, max_bytes)?; + self.insert(url.to_string(), img.clone()); + Ok(img) + } + } +} + +#[cfg(feature = "net")] +pub use net::{fetch_bytes, load_url, FetchError, ImageCache}; + +#[cfg(test)] +mod tests { + use super::*; + + /// Bytes PNG válido de 2×2 píxeles: rojo, verde, azul, blanco. + /// Generado con `image::RgbaImage` + encode a PNG. + fn png_2x2_bytes() -> Vec { + let mut buf = Vec::new(); + let mut rgba = image::RgbaImage::new(2, 2); + rgba.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); + rgba.put_pixel(1, 0, image::Rgba([0, 255, 0, 255])); + rgba.put_pixel(0, 1, image::Rgba([0, 0, 255, 255])); + rgba.put_pixel(1, 1, image::Rgba([255, 255, 255, 255])); + image::DynamicImage::ImageRgba8(rgba) + .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) + .expect("encode png"); + buf + } + + #[cfg(feature = "net")] + #[test] + fn image_cache_evicta_fifo_al_superar_el_cap() { + // Inserta más del cap: las más viejas se evictan, las últimas viven. + let cache = ImageCache::new(); + let total = net::CACHE_CAP + 10; + for i in 0..total { + cache.insert(format!("u{i}"), from_rgba8(vec![0u8; 4], 1, 1)); + } + assert_eq!(cache.len(), net::CACHE_CAP, "la caché queda acotada al cap"); + // Las primeras 10 (las más viejas) fueron evictadas. + assert!(cache.get("u0").is_none()); + assert!(cache.get("u9").is_none()); + // Las últimas siguen presentes. + assert!(cache.get(&format!("u{}", total - 1)).is_some()); + // Reinsertar una clave existente no reordena ni evicta de más. + cache.insert(format!("u{}", total - 1), from_rgba8(vec![0u8; 4], 1, 1)); + assert_eq!(cache.len(), net::CACHE_CAP); + } + + #[test] + fn from_rgba8_arma_image_con_dimensiones_correctas() { + let img = from_rgba8(vec![0u8; 16], 2, 2); // 2x2x4 = 16 bytes + assert_eq!(img.image.width, 2); + assert_eq!(img.image.height, 2); + assert!(matches!(img.image.format, ImageFormat::Rgba8)); + } + + #[test] + fn decode_bytes_png_basico() { + let bytes = png_2x2_bytes(); + let img = decode_bytes(&bytes).expect("decode ok"); + assert_eq!(img.image.width, 2); + assert_eq!(img.image.height, 2); + } + + #[test] + fn decode_bytes_invalido_devuelve_error() { + let bad = vec![0u8, 1, 2, 3, 4, 5]; + let r = decode_bytes(&bad); + assert!(r.is_err()); + // Magic no matchea ningún formato conocido → UnsupportedFormat. + match r.unwrap_err() { + DecodeError::UnsupportedFormat => {} + other => panic!("esperaba UnsupportedFormat, recibí: {other:?}"), + } + } + + #[test] + fn load_path_respeta_cap_de_tamano() { + // Escribimos un PNG válido y lo cargamos con cap muy bajo. + let dir = std::env::temp_dir(); + let path = dir.join("llimphi_image_test_cap.png"); + let bytes = png_2x2_bytes(); + std::fs::write(&path, &bytes).expect("write tmp png"); + // Cap de 1 byte → demasiado grande. + let r = load_path(&path, 1); + match r { + Err(DecodeError::TooBig { size_bytes, max_bytes }) => { + assert!(size_bytes > 1); + assert_eq!(max_bytes, 1); + } + other => panic!("esperaba TooBig, recibí: {other:?}"), + } + // Cap 0 → deshabilitado, decodifica OK. + let img = load_path(&path, 0).expect("decode ok sin cap"); + assert_eq!(img.image.width, 2); + assert_eq!(img.image.height, 2); + std::fs::remove_file(&path).ok(); + } + + #[test] + fn load_path_io_error_cuando_no_existe() { + let r = load_path(std::path::Path::new("/no/existe/probablemente.png"), 0); + match r { + Err(DecodeError::Io(_)) => {} + other => panic!("esperaba Io, recibí: {other:?}"), + } + } + + /// La lógica de caché (sin red): insert / get / contains / clear sobre una + /// imagen sintética. La descarga real no se testea (requiere red). + #[cfg(feature = "net")] + #[test] + fn image_cache_insert_get_contains_clear() { + let cache = ImageCache::new(); + assert!(cache.is_empty()); + assert!(!cache.contains("http://x/a.png")); + assert!(cache.get("http://x/a.png").is_none()); + + let img = from_rgba8(vec![0u8; 16], 2, 2); + cache.insert("http://x/a.png", img); + assert!(cache.contains("http://x/a.png")); + assert_eq!(cache.len(), 1); + let got = cache.get("http://x/a.png").expect("cacheada"); + assert_eq!(got.image.width, 2); + + // Una segunda URL no colisiona. + cache.insert("http://x/b.png".to_string(), from_rgba8(vec![0u8; 4], 1, 1)); + assert_eq!(cache.len(), 2); + + cache.clear(); + assert!(cache.is_empty()); + } +} diff --git a/llimphi-layer/Cargo.toml b/llimphi-layer/Cargo.toml new file mode 100644 index 0000000..f36f344 --- /dev/null +++ b/llimphi-layer/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "llimphi-layer" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Runner wlr-layer-shell para Llimphi: corre cualquier `App` como una superficie de capa anclada a un borde (barra/panel), no como ventana. Extrae la plumbing sctk + wgpu que pata probó en producción, parametrizada sobre el trait `App`. Sirve para que apps (p. ej. shuma) se dockeen al borde de un compositor wlroots como una barra estilo eww/waybar." + +[lib] +path = "src/lib.rs" + +[dependencies] +llimphi-ui = { workspace = true } +smithay-client-toolkit = "0.19" +wayland-client = "0.31" +raw-window-handle = { workspace = true } +pollster = { workspace = true } diff --git a/llimphi-layer/LEEME.md b/llimphi-layer/LEEME.md new file mode 100644 index 0000000..154f1ea --- /dev/null +++ b/llimphi-layer/LEEME.md @@ -0,0 +1,27 @@ +# llimphi-layer + +*Read this in English: [README.md](README.md).* + +Corre un [`App`] de Llimphi como una **superficie wlr-layer-shell** (una barra anclada a un borde), no como ventana. + +Hace que una app se siente al nivel de eww/waybar en cualquier compositor wlroots (mirada, Hyprland, Sway, river…): se ancla a un borde, declara una *exclusive zone* opcional —el compositor le reserva la franja y tesela el resto— y se pinta reusando el pipeline de Llimphi (`mount → compute → paint → render`). + +Es la plumbing `smithay-client-toolkit` + `wgpu` que `pata` probó en +producción, extraída a un runner **genérico** sobre el trait `App`. A +diferencia de pata (N superficies + tray/dock/sidebar/…), este corre **una** +superficie y nada de dominio: la app provee `view`/`update`/`on_key` y este +crate le presta el borde. Útil, p. ej., para que `shuma` se dockee como barra. +```ignore +struct MiBarra; +impl llimphi_ui::App for MiBarra { /* … */ } +llimphi_layer::run::(llimphi_layer::LayerConfig { + edge: llimphi_layer::Edge::Bottom, + thickness: 40, + keyboard: llimphi_layer::Keyboard::OnDemand, + ..Default::default() +})?; +``` + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-layer/README.md b/llimphi-layer/README.md new file mode 100644 index 0000000..22de639 --- /dev/null +++ b/llimphi-layer/README.md @@ -0,0 +1,30 @@ +# llimphi-layer + +Runs a Llimphi `App` as a **wlr-layer-shell surface** (a bar anchored to an edge) +rather than as a window. + +It lets an app sit at the level of eww/waybar on any wlroots compositor (mirada, +Hyprland, Sway, river…): it anchors to an edge, declares an optional *exclusive +zone* — the compositor reserves the strip and tiles the rest — and paints itself +reusing Llimphi's pipeline (`mount → compute → paint → render`). + +It is the `smithay-client-toolkit` + `wgpu` plumbing that `pata` proved in +production, extracted into a **generic** runner over the `App` trait. Unlike pata +(N surfaces + tray/dock/sidebar/…), this runs **one** surface and no domain +logic: the app provides `view`/`update`/`on_key` and this crate lends it the +edge. Useful, for instance, to dock `shuma` as a bar. + +```ignore +struct MyBar; +impl llimphi_ui::App for MyBar { /* … */ } +llimphi_layer::run::(llimphi_layer::LayerConfig { + edge: llimphi_layer::Edge::Bottom, + thickness: 40, + keyboard: llimphi_layer::Keyboard::OnDemand, + ..Default::default() +})?; +``` + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-layer/src/lib.rs b/llimphi-layer/src/lib.rs new file mode 100644 index 0000000..b1fd933 --- /dev/null +++ b/llimphi-layer/src/lib.rs @@ -0,0 +1,1180 @@ +//! `llimphi-layer` — corre un [`App`] de Llimphi como una **superficie +//! wlr-layer-shell** (una barra anclada a un borde), no como ventana. Hace que +//! una app se siente al nivel de eww/waybar en cualquier compositor wlroots +//! (mirada, Hyprland, Sway, river…): se ancla a un borde, declara una *exclusive +//! zone* opcional —el compositor le reserva la franja y tesela el resto— y se +//! pinta reusando el pipeline de Llimphi (`mount → compute → paint → render`). +//! +//! Es la plumbing `smithay-client-toolkit` + `wgpu` que `pata` probó en +//! producción, extraída a un runner **genérico** sobre el trait `App`. A +//! diferencia de pata (N superficies + tray/dock/sidebar/…), este corre **una** +//! superficie y nada de dominio: la app provee `view`/`update`/`on_key` y este +//! crate le presta el borde. Útil, p. ej., para que `shuma` se dockee como barra. +//! +//! ```ignore +//! struct MiBarra; +//! impl llimphi_ui::App for MiBarra { /* … */ } +//! llimphi_layer::run::(llimphi_layer::LayerConfig { +//! edge: llimphi_layer::Edge::Bottom, +//! thickness: 40, +//! keyboard: llimphi_layer::Keyboard::OnDemand, +//! ..Default::default() +//! })?; +//! ``` + +use std::error::Error; +use std::ffi::c_void; +use std::ptr::NonNull; +use std::sync::mpsc::{channel, Receiver}; +use std::sync::Mutex; + +use raw_window_handle::{ + RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, +}; +use smithay_client_toolkit::{ + compositor::{CompositorHandler, CompositorState}, + delegate_compositor, delegate_keyboard, delegate_layer, delegate_output, delegate_pointer, + delegate_registry, delegate_seat, delegate_shm, + output::{OutputHandler, OutputState}, + registry::{ProvidesRegistryState, RegistryState}, + registry_handlers, + seat::{ + keyboard::{KeyboardHandler, Keysym, Modifiers}, + pointer::{ + CursorIcon, PointerEvent, PointerEventKind, PointerHandler, ThemeSpec, ThemedPointer, + BTN_LEFT, BTN_RIGHT, + }, + Capability, SeatHandler, SeatState, + }, + shm::{Shm, ShmHandler}, + shell::{ + wlr_layer::{ + Anchor as LayerAnchor, KeyboardInteractivity, Layer, LayerShell, LayerShellHandler, + LayerSurface, LayerSurfaceConfigure, + }, + WaylandSurface, + }, +}; +use wayland_client::{ + globals::registry_queue_init, + protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface}, + Connection, Proxy, QueueHandle, +}; + +use llimphi_ui::llimphi_compositor::{ + hit_test_click, hit_test_hover, hit_test_scroll, measure_text_node, mount, paint, DragFn, + DragPhase, Mounted, +}; +use llimphi_ui::llimphi_hal::{wgpu, Hal, RawSurface, Surface as _}; +use llimphi_ui::llimphi_layout::{taffy, ComputedLayout, LayoutTree}; +use llimphi_ui::llimphi_raster::{peniko::color::palette, vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; +use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, Modifiers as LMods, NamedKey}; + +// ── API pública ───────────────────────────────────────────────────────────── + +/// El borde al que se ancla la barra. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Edge { + Top, + Bottom, + Left, + Right, +} + +/// La esquina a la que se ancla una **caja** de tamaño fijo (no una barra de +/// borde completo). La usa, p. ej., un daemon de notificaciones: una caja en +/// la esquina, no una franja que cruza la pantalla. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Corner { + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +/// Márgenes (px) respecto de los bordes a los que la superficie se ancla. +/// El compositor los respeta como separación entre la caja y el borde. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Margins { + pub top: i32, + pub right: i32, + pub bottom: i32, + pub left: i32, +} + +impl Margins { + /// El mismo margen en los cuatro lados. + pub fn all(px: i32) -> Self { + Self { top: px, right: px, bottom: px, left: px } + } +} + +/// La capa de composición (orden en Z respecto de las ventanas). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LayerKind { + /// Detrás de las ventanas (fondo de escritorio). + Background, + /// Bajo las ventanas normales. + Bottom, + /// Sobre las ventanas normales (lo típico para una barra). + Top, + /// Por encima de todo (popups, lock screens). + Overlay, +} + +/// Cuánto teclado acepta la superficie. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Keyboard { + /// No recibe teclado. + None, + /// Lo recibe al ser clickeada/enfocada (lo razonable para una barra que a + /// veces toma texto, p. ej. un input). + OnDemand, + /// Acapara el teclado mientras exista (drawers/popups modales). + Exclusive, +} + +/// Configuración de la superficie de capa. +/// +/// Dos modos de anclaje, según `corner`: +/// - **Barra de borde** (`corner: None`, el default): se ancla a `edge` y el +/// compositor estira el eje paralelo a toda la salida; `thickness` fija el +/// perpendicular. Es el modo waybar/eww. +/// - **Caja de esquina** (`corner: Some(_)`): se ancla a una esquina con +/// `size` explícito (ancho, alto) y `margins`. Ignora `edge`/`thickness` y +/// nunca reserva zona exclusiva (flota). Es el modo de un toast/OSD/popover. +#[derive(Debug, Clone)] +pub struct LayerConfig { + pub edge: Edge, + /// Grosor (px) en el eje perpendicular al borde (alto para Top/Bottom, ancho + /// para Left/Right). El otro eje lo estira el compositor al tamaño de salida. + /// Solo aplica en modo barra de borde (`corner: None`). + pub thickness: u32, + pub layer: LayerKind, + /// Si `true`, reserva la franja (las ventanas no la tapan); si `false`, flota. + /// En modo caja de esquina siempre flota, independientemente de este valor. + pub exclusive: bool, + pub keyboard: Keyboard, + /// El namespace que ve el compositor (para reglas por-superficie). + pub namespace: String, + /// Si es `Some`, ancla una **caja** a esa esquina en vez de una barra de + /// borde. Requiere `size`; usa `margins`. + pub corner: Option, + /// Tamaño explícito `(ancho, alto)` en px para el modo caja de esquina. + /// Ignorado en modo barra de borde. Si falta en modo esquina, se usa un + /// default razonable. + pub size: Option<(u32, u32)>, + /// Separación (px) respecto de los bordes anclados, en modo caja de esquina. + pub margins: Margins, + /// Si `true`, la **input-region** de la surface se recorta cada frame a la + /// unión de los rects de los nodos interactivos (clicks/drag/hover). Las + /// zonas transparentes u ociosas quedan **click-through** hacia la ventana de + /// atrás. Pensado para overlays flotantes tipo toast/OSD/tooltip que tienen + /// una caja fija grande pero sólo pintan contenido en parte de ella: sin esto, + /// la caja entera (aun vacía) se traga los clicks de la ventana debajo. Los + /// modales que SÍ quieren tragar todo su rect (scrim de logout/lock/polkit) + /// lo dejan en `false` (default = comportamiento histórico: acepta todo). + pub input_from_content: bool, +} + +impl Default for LayerConfig { + fn default() -> Self { + Self { + edge: Edge::Bottom, + thickness: 40, + layer: LayerKind::Top, + exclusive: true, + keyboard: Keyboard::OnDemand, + namespace: "llimphi-layer".to_string(), + corner: None, + size: None, + margins: Margins::default(), + input_from_content: false, + } + } +} + +/// Levanta el backend layer-shell y corre `A` hasta que la superficie se cierra. +/// Devuelve error si no hay sesión Wayland o el compositor no expone +/// `wlr-layer-shell`. +pub fn run(cfg: LayerConfig) -> Result<(), Box> { + let conn = Connection::connect_to_env()?; + let (globals, mut event_queue) = registry_queue_init(&conn)?; + let qh: QueueHandle> = event_queue.handle(); + + let compositor = CompositorState::bind(&globals, &qh)?; + let layer_shell = LayerShell::bind(&globals, &qh)?; + // `wl_shm` para cargar los pixmaps del tema de cursores (el `ThemedPointer` + // los necesita). El render de la app va por wgpu; esto es sólo para el cursor. + let shm = Shm::bind(&globals, &qh)?; + + // Handle channel-backed: el `update`/efectos de la app usan su propio `Msg`; + // cada dispatch/spawn/spawn_periodic cae en `rx`, que `draw` drena por frame. + // El truco (igual que en pata): el `for_test().lift(f)` ejecuta `f` —el + // `send`— antes del dispatch no-op del handle de test, así el canal recibe. + let (tx, rx) = channel::(); + let tx = Mutex::new(tx); + let handle: Handle = Handle::<()>::for_test().lift(move |m: A::Msg| { + let _ = tx.lock().expect("tx layer").send(m); + }); + let model = A::init(&handle); + + let resolved = resolve_anchor(&cfg); + let wl_surface = compositor.create_surface(&qh); + let layer = layer_shell.create_layer_surface( + &qh, + wl_surface, + cfg.layer.into(), + Some(cfg.namespace.clone()), + None, + ); + layer.set_anchor(resolved.anchor); + layer.set_size(resolved.size.0, resolved.size.1); + layer.set_exclusive_zone(resolved.exclusive_zone); + if let Some(m) = resolved.margins { + layer.set_margin(m.top, m.right, m.bottom, m.left); + } + layer.set_keyboard_interactivity(cfg.keyboard.into()); + layer.commit(); + let size = resolved.size; + + let mut runner = Runner:: { + registry_state: RegistryState::new(&globals), + output_state: OutputState::new(&globals, &qh), + seat_state: SeatState::new(&globals, &qh), + conn, + hal: None, + keyboard: None, + pointer: None, + themed_pointer: None, + shm, + cursor_surface: None, + seat: None, + layer, + compositor, + layer_shell, + cfg, + width: size.0.max(1), + height: size.1.max(1), + gpu: None, + cache: None, + hover_idx: None, + dirty: true, + frame_pending: false, + drag: None, + mods: Modifiers::default(), + model: Some(model), + handle, + rx, + recreate_count: 0, + exit: false, + }; + + while !runner.exit { + event_queue.blocking_dispatch(&mut runner)?; + } + Ok(()) +} + +// ── Interno ───────────────────────────────────────────────────────────────── + +/// Tamaño por defecto de una caja de esquina cuando `cfg.size` es `None`. +const DEFAULT_CORNER_SIZE: (u32, u32) = (360, 280); + +/// El anclaje resuelto para la superficie: anclas sctk, tamaño pedido, zona +/// exclusiva y márgenes opcionales. Unifica los dos modos (barra/caja). +struct Resolved { + anchor: LayerAnchor, + size: (u32, u32), + exclusive_zone: i32, + margins: Option, +} + +/// Resuelve el anclaje según el modo de `cfg`: caja de esquina si `corner` es +/// `Some`, barra de borde en caso contrario. +fn resolve_anchor(cfg: &LayerConfig) -> Resolved { + match cfg.corner { + Some(corner) => { + let size = cfg.size.unwrap_or(DEFAULT_CORNER_SIZE); + let anchor = match corner { + Corner::TopLeft => LayerAnchor::TOP | LayerAnchor::LEFT, + Corner::TopRight => LayerAnchor::TOP | LayerAnchor::RIGHT, + Corner::BottomLeft => LayerAnchor::BOTTOM | LayerAnchor::LEFT, + Corner::BottomRight => LayerAnchor::BOTTOM | LayerAnchor::RIGHT, + }; + // Una caja flota siempre: no tiene sentido reservar zona exclusiva. + Resolved { anchor, size, exclusive_zone: 0, margins: Some(cfg.margins) } + } + None => { + let (anchor, size) = anchor_and_size(cfg.edge, cfg.thickness); + let exclusive_zone = if cfg.exclusive { cfg.thickness as i32 } else { 0 }; + Resolved { anchor, size, exclusive_zone, margins: None } + } + } +} + +/// El anclaje sctk + el tamaño `(w, h)` pedido para un borde y grosor. El eje +/// paralelo al borde va en 0 → el compositor lo estira a la salida completa. +fn anchor_and_size(edge: Edge, thickness: u32) -> (LayerAnchor, (u32, u32)) { + match edge { + Edge::Top => ( + LayerAnchor::TOP | LayerAnchor::LEFT | LayerAnchor::RIGHT, + (0, thickness), + ), + Edge::Bottom => ( + LayerAnchor::BOTTOM | LayerAnchor::LEFT | LayerAnchor::RIGHT, + (0, thickness), + ), + Edge::Left => ( + LayerAnchor::LEFT | LayerAnchor::TOP | LayerAnchor::BOTTOM, + (thickness, 0), + ), + Edge::Right => ( + LayerAnchor::RIGHT | LayerAnchor::TOP | LayerAnchor::BOTTOM, + (thickness, 0), + ), + } +} + +impl From for Layer { + fn from(k: LayerKind) -> Self { + match k { + LayerKind::Background => Layer::Background, + LayerKind::Bottom => Layer::Bottom, + LayerKind::Top => Layer::Top, + LayerKind::Overlay => Layer::Overlay, + } + } +} + +impl From for KeyboardInteractivity { + fn from(k: Keyboard) -> Self { + match k { + Keyboard::None => KeyboardInteractivity::None, + Keyboard::OnDemand => KeyboardInteractivity::OnDemand, + Keyboard::Exclusive => KeyboardInteractivity::Exclusive, + } + } +} + +/// El estado wgpu de la superficie. +struct PanelGpu { + surface: RawSurface, + renderer: Renderer, + typesetter: Typesetter, + scene: vello::Scene, + layout: LayoutTree, +} + +/// El árbol pintado en el último frame, para hit-test entre frames. +struct RenderCache { + mounted: Mounted, + computed: ComputedLayout, +} + +/// El cliente Wayland que corre un `App` como una sola layer surface. +struct Runner { + registry_state: RegistryState, + output_state: OutputState, + seat_state: SeatState, + conn: Connection, + hal: Option, + keyboard: Option, + pointer: Option, + /// Puntero con tema de cursores: traduce el `Cursor` del nodo bajo el + /// puntero a una forma de cursor del sistema (I-beam sobre texto, mano sobre + /// links, etc). El backend winit ya lo hacía; el layer-shell no tenía NADA. + themed_pointer: Option, + /// `wl_shm` retenido para el tema de cursores. + shm: Shm, + /// Superficie dedicada donde el compositor pinta el cursor (la usa el + /// `ThemedPointer`). + cursor_surface: Option, + seat: Option, + layer: LayerSurface, + /// Compositor + shell + config retenidos para **re-crear** la layer-surface + /// si el compositor nos manda `closed` (reset/quita del output) en vez de + /// morir. Ver [`Runner::recreate_surface`] y el handler `closed`. + compositor: CompositorState, + layer_shell: LayerShell, + cfg: LayerConfig, + width: u32, + height: u32, + gpu: Option, + cache: Option>, + hover_idx: Option, + /// Hay algo nuevo que pintar. Arranca en `true` (nunca se pintó nada) y lo + /// levantan `apply`, los cambios de hover/drag, `configure` y la re-creación + /// de la surface; lo baja `draw` al presentar. Ver [`Runner::draw`]. + dirty: bool, + /// Ya hay un frame-callback pedido y sin responder. Evita encadenar uno por + /// cada `draw`: `configure` también llama a `draw`, así que sin esto cada + /// configure suma un latido paralelo y quedan N en vuelo — el commit-storm + /// que le clava un core al compositor. Es la misma guarda que pata tiene + /// por panel (`frame_pending`); `llimphi-layer` se extrajo de pata pero se + /// quedó sin ella. Se limpia al llegar el callback. + frame_pending: bool, + /// Arrastre en curso: handler del nodo + última posición del puntero. + drag: Option<(DragFn, (f32, f32))>, + mods: Modifiers, + model: Option, + handle: Handle, + rx: Receiver, + /// Cierres consecutivos sin lograr pintar un frame. Resetea al presentar; si + /// pasa el tope, nos rendimos (`exit`) para que el supervisor reinicie con + /// estado limpio (output realmente ausente, no un reset transitorio). + recreate_count: u32, + exit: bool, +} + +impl Runner { + /// Aplica un `Msg` a la app (transición pura del modelo) y marca para + /// repintar invalidando la caché de hit-test. + fn apply(&mut self, msg: A::Msg) { + if let Some(model) = self.model.take() { + self.model = Some(A::update(model, msg, &self.handle)); + } + self.cache = None; + self.dirty = true; + } + + /// Traduce un evento de teclado sctk al `KeyEvent` de Llimphi. + fn keysym_to_keyevent( + &self, + event: &smithay_client_toolkit::seat::keyboard::KeyEvent, + ) -> Option { + use smithay_client_toolkit::seat::keyboard::Keysym as K; + let named = match event.keysym { + K::Return | K::KP_Enter => Some(NamedKey::Enter), + K::BackSpace => Some(NamedKey::Backspace), + K::Tab | K::ISO_Left_Tab => Some(NamedKey::Tab), + K::Escape => Some(NamedKey::Escape), + K::Up => Some(NamedKey::ArrowUp), + K::Down => Some(NamedKey::ArrowDown), + K::Right => Some(NamedKey::ArrowRight), + K::Left => Some(NamedKey::ArrowLeft), + K::Home => Some(NamedKey::Home), + K::End => Some(NamedKey::End), + K::Page_Up => Some(NamedKey::PageUp), + K::Page_Down => Some(NamedKey::PageDown), + K::Delete => Some(NamedKey::Delete), + K::Insert => Some(NamedKey::Insert), + K::F1 => Some(NamedKey::F1), + K::F2 => Some(NamedKey::F2), + K::F3 => Some(NamedKey::F3), + K::F4 => Some(NamedKey::F4), + K::F5 => Some(NamedKey::F5), + K::F6 => Some(NamedKey::F6), + K::F7 => Some(NamedKey::F7), + K::F8 => Some(NamedKey::F8), + K::F9 => Some(NamedKey::F9), + K::F10 => Some(NamedKey::F10), + K::F11 => Some(NamedKey::F11), + K::F12 => Some(NamedKey::F12), + _ => None, + }; + let modifiers = LMods { + shift: self.mods.shift, + ctrl: self.mods.ctrl, + alt: self.mods.alt, + meta: self.mods.logo, + }; + let (key, text) = if let Some(n) = named { + (Key::Named(n), None) + } else { + let txt = match event.utf8.as_deref() { + Some(s) if !s.is_empty() && !s.chars().all(char::is_control) => s.to_string(), + _ => event.keysym.key_char()?.to_string(), + }; + (Key::Character(txt.as_str().into()), Some(txt)) + }; + Some(KeyEvent { + key, + state: KeyState::Pressed, + text, + modifiers, + repeat: false, + }) + } + + /// Crea el estado wgpu de la superficie (idempotente). + fn ensure_gpu(&mut self) { + if self.gpu.is_some() { + return; + } + let display_ptr = self.conn.backend().display_ptr() as *mut c_void; + let surface_ptr = self.layer.wl_surface().id().as_ptr() as *mut c_void; + let (w, h) = (self.width, self.height); + let display_handle = RawDisplayHandle::Wayland(WaylandDisplayHandle::new( + NonNull::new(display_ptr).expect("wl_display ptr"), + )); + let window_handle = RawWindowHandle::Wayland(WaylandWindowHandle::new( + NonNull::new(surface_ptr).expect("wl_surface ptr"), + )); + // SAFETY: los handles apuntan a objetos Wayland que `self` mantiene vivos. + let make_target = || wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: display_handle, + raw_window_handle: window_handle, + }; + + let surface = if self.hal.is_none() { + match pollster::block_on(unsafe { Hal::new_for_raw_surface(make_target, w, h) }) { + Ok((hal, surface)) => { + self.hal = Some(hal); + surface + } + Err(e) => { + eprintln!("llimphi-layer · sin gpu: {e}"); + return; + } + } + } else { + let hal = self.hal.as_ref().expect("hal"); + let wgpu_surface = match unsafe { hal.instance.create_surface_unsafe(make_target()) } { + Ok(s) => s, + Err(e) => { + eprintln!("llimphi-layer · sin gpu: {e}"); + return; + } + }; + match RawSurface::from_surface(hal, wgpu_surface, display_handle, window_handle, w, h) { + Ok(s) => s, + Err(e) => { + eprintln!("llimphi-layer · sin gpu: {e}"); + return; + } + } + }; + let hal = self.hal.as_ref().expect("hal"); + let renderer = Renderer::new(hal).expect("renderer"); + self.gpu = Some(PanelGpu { + surface, + renderer, + typesetter: Typesetter::new(), + scene: vello::Scene::new(), + layout: LayoutTree::new(), + }); + } + + /// Re-crea la layer-surface tras un `closed` del compositor (reset/quita del + /// output: churn DRM, DPMS, hotplug). Suelta la surface GPU vieja —atada al + /// `wl_surface` ya muerto; el `hal`/device **sobrevive**— y crea una nueva + /// con la misma config. El próximo `configure` que mande el compositor + /// reconstruye la GPU vía [`ensure_gpu`](Self::ensure_gpu) y `draw` re-arma + /// el latido. Así la barra sobrevive a un reset de output sin morir. + fn recreate_surface(&mut self, qh: &QueueHandle) { + self.gpu = None; + self.cache = None; + self.dirty = true; + // La surface vieja murió: su callback no va a llegar nunca. Si no + // liberamos el slot, el latido queda trabado para siempre. + self.frame_pending = false; + let resolved = resolve_anchor(&self.cfg); + let wl_surface = self.compositor.create_surface(qh); + let layer = self.layer_shell.create_layer_surface( + qh, + wl_surface, + self.cfg.layer.into(), + Some(self.cfg.namespace.clone()), + None, + ); + layer.set_anchor(resolved.anchor); + layer.set_size(resolved.size.0, resolved.size.1); + layer.set_exclusive_zone(resolved.exclusive_zone); + if let Some(m) = resolved.margins { + layer.set_margin(m.top, m.right, m.bottom, m.left); + } + layer.set_keyboard_interactivity(self.cfg.keyboard.into()); + layer.commit(); + self.layer = layer; // dropea la LayerSurface cerrada + } + + /// Mantiene vivo el latido: pide el siguiente frame-callback. El loop de + /// frames se auto-sostiene así (cada `frame` re-pide el próximo), lo que + /// permite drenar el canal de la app por frame sin un timer aparte. + fn latido(&mut self, qh: &QueueHandle) { + if self.frame_pending { + return; // ya hay uno pedido; encadenar otro es el commit-storm + } + self.frame_pending = true; + let surface = self.layer.wl_surface(); + surface.frame(qh, surface.clone()); + surface.commit(); + } + + /// Avanza un frame: drena el canal, construye la vista, layout, pinta y + /// presenta. Cachea el árbol para el hit-test del próximo evento. + /// + /// **Sale temprano si nada cambió.** El latido de frame-callbacks se + /// mantiene siempre (es lo que nos despierta para drenar el canal), pero + /// construir vista + layout + pintar + presentar sólo se paga cuando hay + /// algo nuevo: un `Msg` aplicado, un cambio de hover/drag, un `configure` o + /// una re-creación de la surface. Antes se re-renderizaba en CADA frame + /// aunque la pantalla fuera idéntica — medido en metal 2026-07-21, + /// `pata-notify` quemaba 2.9% de un core sin un solo toast en pantalla. + /// Es además el criterio que ya usa el backend de ventanas (`llimphi-ui` + /// duerme en `ControlFlow::Wait` y pide `request_redraw` a demanda). + /// + /// Las apps que animan ya empujan `Msg` por [`Handle::spawn_periodic`] + /// (pata: reloj 1 s, cava y dientes 100 ms), así que siguen andando igual. + /// Lo que NO se sostiene solo es una `view()` que dependa del reloj sin + /// emitir un `Msg`: eso hay que despertarlo con un tick, como corresponde. + fn draw(&mut self, qh: &QueueHandle) { + // Drenar los Msg que la app empujó al canal (ticks/async/follow-ups). + // `apply` marca sucio, así que esto decide el early-return de abajo. + while let Ok(m) = self.rx.try_recv() { + self.apply(m); + } + // Nada nuevo y ya hay un frame presentado: mantené el latido y volvé. + if !self.dirty && self.cache.is_some() { + self.latido(qh); + return; + } + self.ensure_gpu(); + + let (w, h) = (self.width, self.height); + let view = A::view(self.model.as_ref().expect("model")); + let hover_idx = self.hover_idx; + let hal = match self.hal.as_ref() { + Some(h) => h, + None => { + self.latido(qh); + return; + } + }; + let gpu = match self.gpu.as_mut() { + Some(g) => g, + None => { + self.latido(qh); + return; + } + }; + gpu.surface.resize(w, h); + let frame = match gpu.surface.acquire() { + Ok(f) => f, + Err(_) => { + self.latido(qh); + return; + } + }; + gpu.layout.clear(); + let mounted = mount(&mut gpu.layout, view); + let computed = { + let ts = &mut gpu.typesetter; + let tmap = &mounted.text_measures; + gpu.layout + .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + // Input-region recortada al contenido interactivo (opt-in): la caja fija + // del overlay (un toast de 352×420, p. ej.) se traga todos los clicks de + // la ventana debajo aunque esté vacía o transparente. Recortando la + // región a la unión de los rects de nodos con handler (o VACÍA si no hay + // ninguno), las zonas ociosas quedan click-through. Se aplica antes del + // `present` (que commitea la surface) para que entre en el mismo frame. + if self.cfg.input_from_content { + use smithay_client_toolkit::compositor::Region; + if let Ok(region) = Region::new(&self.compositor) { + for n in &mounted.nodes { + let interactivo = n.on_click.is_some() + || n.on_click_at.is_some() + || n.on_click_screen.is_some() + || n.drag.is_some() + || n.drag_at.is_some() + || n.drag_velocity.is_some() + || n.on_right_click.is_some() + || n.on_right_click_at.is_some() + || n.on_middle_click.is_some() + || n.hover_fill.is_some(); + if !interactivo { + continue; + } + if let Some(r) = computed.get(n.id) { + let (x, y, w, h) = + (r.x as i32, r.y as i32, r.w.ceil() as i32, r.h.ceil() as i32); + if w > 0 && h > 0 { + region.add(x, y, w, h); + } + } + } + // Región (posiblemente vacía = todo click-through) → la surface. + self.layer + .wl_surface() + .set_input_region(Some(region.wl_region())); + } + } + gpu.scene.reset(); + paint(&mut gpu.scene, &mounted, &computed, &mut gpu.typesetter, hover_idx, None); + if let Err(e) = gpu.renderer.render(hal, &gpu.scene, &frame, palette::css::TRANSPARENT) { + eprintln!("llimphi-layer · render: {e}"); + } + gpu.surface.present(frame, hal); + + // Pintamos un frame con éxito: el ciclo de re-creación (si lo hubo) se + // recuperó del todo. Resetea el backoff de `closed`. + self.recreate_count = 0; + self.cache = Some(RenderCache { mounted, computed }); + // Frame presentado: hasta que algo vuelva a cambiar, los próximos + // callbacks sólo re-arman el latido. + self.dirty = false; + self.latido(qh); + } +} + +// ── Handlers sctk ─────────────────────────────────────────────────────────── + +impl CompositorHandler for Runner { + fn scale_factor_changed( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: i32, + ) { + } + fn transform_changed( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: wl_output::Transform, + ) { + } + fn frame( + &mut self, + _: &Connection, + qh: &QueueHandle, + _: &wl_surface::WlSurface, + _: u32, + ) { + // Llegó el callback que pidió `latido`: liberamos el slot para que el + // próximo `latido` pueda pedir el siguiente. + self.frame_pending = false; + self.draw(qh); + } + fn surface_enter( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: &wl_output::WlOutput, + ) { + } + fn surface_leave( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: &wl_output::WlOutput, + ) { + } +} + +impl LayerShellHandler for Runner { + fn closed(&mut self, _: &Connection, qh: &QueueHandle, _: &LayerSurface) { + // El compositor cerró nuestra superficie — típicamente al resetear o + // quitar el output al que estábamos anclados (churn DRM, DPMS, hotplug). + // Antes esto mataba el proceso (`exit = true`) y la barra quedaba muerta + // hasta un respawn externo. Ahora re-creamos la layer-surface sobre el + // output vigente y seguimos vivos. Con tope: si nos cierran repetido sin + // lograr pintar (output realmente ausente, no un reset transitorio), nos + // rendimos y salimos para que el supervisor reinicie con estado limpio. + const MAX_RECREATES: u32 = 16; + self.recreate_count += 1; + if self.recreate_count > MAX_RECREATES { + eprintln!( + "llimphi-layer · cerrados {} veces sin recuperar; salgo (que reinicie el supervisor).", + self.recreate_count + ); + self.exit = true; + return; + } + eprintln!( + "llimphi-layer · el compositor cerró la superficie; re-creo (intento {}).", + self.recreate_count + ); + self.recreate_surface(qh); + } + fn configure( + &mut self, + _: &Connection, + qh: &QueueHandle, + _: &LayerSurface, + configure: LayerSurfaceConfigure, + _: u32, + ) { + let (cw, ch) = configure.new_size; + const MAX_DIM: u32 = 16384; + if (1..=MAX_DIM).contains(&cw) { + self.width = cw; + } + if (1..=MAX_DIM).contains(&ch) { + self.height = ch; + } + // Un configure siempre exige repintar: cambió el tamaño, o el compositor + // nos re-mapeó y el frame anterior ya no vale. + self.dirty = true; + // La app puede necesitar su tamaño (p. ej. para calcular el viewport de + // un scroll). El compositor estira el eje paralelo al borde, así que es + // aquí —no en `run`— donde se conoce el alto/ancho real. + let resize_msg = self + .model + .as_ref() + .and_then(|m| A::on_resize(m, self.width, self.height)); + if let Some(msg) = resize_msg { + self.apply(msg); + } + self.draw(qh); + } +} + +impl OutputHandler for Runner { + fn output_state(&mut self) -> &mut OutputState { + &mut self.output_state + } + fn new_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} + fn update_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} + fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} +} + +impl SeatHandler for Runner { + fn seat_state(&mut self) -> &mut SeatState { + &mut self.seat_state + } + fn new_seat(&mut self, _: &Connection, _: &QueueHandle, seat: wl_seat::WlSeat) { + if self.seat.is_none() { + self.seat = Some(seat); + } + } + fn new_capability( + &mut self, + _: &Connection, + qh: &QueueHandle, + seat: wl_seat::WlSeat, + capability: Capability, + ) { + match capability { + Capability::Keyboard if self.keyboard.is_none() => { + if let Ok(kbd) = self.seat_state.get_keyboard(qh, &seat, None) { + self.keyboard = Some(kbd); + } + } + Capability::Pointer if self.pointer.is_none() => { + // Puntero CON tema de cursores: una superficie dedicada para el + // cursor + el tema del sistema. Si algo falla (raro), caemos al + // puntero pelado (sin cambio de forma, como estaba antes). + let surface = self.compositor.create_surface(qh); + match self.seat_state.get_pointer_with_theme( + qh, + &seat, + self.shm.wl_shm(), + surface.clone(), + ThemeSpec::default(), + ) { + Ok(themed) => { + self.pointer = Some(themed.pointer().clone()); + self.cursor_surface = Some(surface); + self.themed_pointer = Some(themed); + } + Err(_) => { + if let Ok(ptr) = self.seat_state.get_pointer(qh, &seat) { + self.pointer = Some(ptr); + } + } + } + } + _ => {} + } + } + fn remove_capability( + &mut self, + _: &Connection, + _: &QueueHandle, + _: wl_seat::WlSeat, + capability: Capability, + ) { + match capability { + Capability::Keyboard => { + if let Some(k) = self.keyboard.take() { + k.release(); + } + } + Capability::Pointer => { + self.themed_pointer = None; + self.cursor_surface = None; + if let Some(p) = self.pointer.take() { + p.release(); + } + } + _ => {} + } + } + fn remove_seat(&mut self, _: &Connection, _: &QueueHandle, _: wl_seat::WlSeat) {} +} + +impl KeyboardHandler for Runner { + fn enter( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: &wl_surface::WlSurface, + _: u32, + _: &[u32], + _: &[Keysym], + ) { + } + fn leave( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: &wl_surface::WlSurface, + _: u32, + ) { + } + fn press_key( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + event: smithay_client_toolkit::seat::keyboard::KeyEvent, + ) { + if let Some(ke) = self.keysym_to_keyevent(&event) { + let msg = A::on_key(self.model.as_ref().expect("model"), &ke); + if let Some(msg) = msg { + self.apply(msg); + } + } + } + fn release_key( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + _: smithay_client_toolkit::seat::keyboard::KeyEvent, + ) { + } + fn update_modifiers( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + modifiers: Modifiers, + _: u32, + ) { + self.mods = modifiers; + } +} + +/// Traduce el [`Cursor`](llimphi_ui::llimphi_compositor::Cursor) del nodo bajo +/// el puntero a la forma de cursor del sistema. Espeja `to_winit_cursor` del +/// backend winit — la misma cobertura de variantes. `None` → flecha default. +fn cursor_to_icon(c: Option) -> CursorIcon { + use llimphi_ui::llimphi_compositor::Cursor as C; + match c { + None | Some(C::Default) => CursorIcon::Default, + Some(C::Pointer) => CursorIcon::Pointer, + Some(C::Text) => CursorIcon::Text, + Some(C::Crosshair) => CursorIcon::Crosshair, + Some(C::Move) => CursorIcon::Move, + Some(C::Grab) => CursorIcon::Grab, + Some(C::Grabbing) => CursorIcon::Grabbing, + Some(C::NotAllowed) => CursorIcon::NotAllowed, + Some(C::Wait) => CursorIcon::Wait, + Some(C::Progress) => CursorIcon::Progress, + Some(C::Help) => CursorIcon::Help, + Some(C::ColResize) => CursorIcon::ColResize, + Some(C::RowResize) => CursorIcon::RowResize, + Some(C::EwResize) => CursorIcon::EwResize, + Some(C::NsResize) => CursorIcon::NsResize, + Some(C::NeswResize) => CursorIcon::NeswResize, + Some(C::NwseResize) => CursorIcon::NwseResize, + Some(C::ZoomIn) => CursorIcon::ZoomIn, + Some(C::ZoomOut) => CursorIcon::ZoomOut, + } +} + +impl Runner { + /// Aplica al puntero la forma de cursor del nodo `hover_idx` actual. No-op si + /// no hay `ThemedPointer` (el compositor no dio la capacidad temada). + fn aplicar_cursor(&mut self, conn: &Connection) { + let Some(themed) = self.themed_pointer.as_ref() else { return }; + let cur = self + .hover_idx + .and_then(|i| self.cache.as_ref()?.mounted.nodes.get(i)) + .and_then(|n| n.cursor); + let _ = themed.set_cursor(conn, cursor_to_icon(cur)); + } +} + +impl PointerHandler for Runner { + fn pointer_frame( + &mut self, + conn: &Connection, + _: &QueueHandle, + _: &wl_pointer::WlPointer, + events: &[PointerEvent], + ) { + for e in events { + match e.kind { + PointerEventKind::Enter { .. } => { + // Al entrar, fijar el cursor según el nodo bajo el puntero + // (con el serial de Enter que el ThemedPointer ya rastrea). + let (px, py) = (e.position.0 as f32, e.position.1 as f32); + let nuevo = self + .cache + .as_ref() + .and_then(|c| hit_test_hover(&c.mounted, &c.computed, px, py)); + if self.hover_idx != nuevo { + self.hover_idx = nuevo; + self.dirty = true; // el hover se PINTA (hover_fill) + } + self.aplicar_cursor(conn); + continue; + } + PointerEventKind::Motion { .. } => { + let (px, py) = (e.position.0 as f32, e.position.1 as f32); + // Drag en curso: el delta va al handler del nodo. + if let Some((handler, last)) = self.drag.as_ref().map(|(h, l)| (h.clone(), *l)) { + if let Some(d) = self.drag.as_mut() { + d.1 = (px, py); + } + if let Some(msg) = (handler)(DragPhase::Move, px - last.0, py - last.1) { + self.apply(msg); + } + continue; + } + let nuevo = self + .cache + .as_ref() + .and_then(|c| hit_test_hover(&c.mounted, &c.computed, px, py)); + if self.hover_idx != nuevo { + self.hover_idx = nuevo; + self.dirty = true; // el hover se PINTA (hover_fill) + // Cambió el nodo bajo el puntero → actualizar la forma. + self.aplicar_cursor(conn); + } + continue; + } + PointerEventKind::Leave { .. } => { + if self.hover_idx.is_some() { + self.hover_idx = None; + self.dirty = true; // se va el resaltado + } + continue; + } + _ => {} + } + if let PointerEventKind::Axis { vertical, .. } = e.kind { + let dy = if vertical.discrete != 0 { + vertical.discrete as f32 + } else { + vertical.absolute as f32 / 20.0 + }; + if dy != 0.0 { + let (px, py) = (e.position.0 as f32, e.position.1 as f32); + let msg = self.cache.as_ref().and_then(|c| { + hit_test_scroll(&c.mounted, &c.computed, px, py) + .and_then(|i| c.mounted.nodes.get(i)) + .and_then(|n| n.on_scroll.as_ref().and_then(|h| h(0.0, dy))) + }); + if let Some(msg) = msg { + self.apply(msg); + } + } + continue; + } + if let PointerEventKind::Release { button, .. } = e.kind { + if button == BTN_LEFT { + if let Some((handler, _)) = self.drag.take() { + if let Some(msg) = (handler)(DragPhase::End, 0.0, 0.0) { + self.apply(msg); + } + } + } + continue; + } + if let PointerEventKind::Press { button, .. } = e.kind { + if button != BTN_LEFT && button != BTN_RIGHT { + continue; + } + let (px, py) = (e.position.0 as f32, e.position.1 as f32); + let derecho = button == BTN_RIGHT; + // Nodo arrastrable bajo el press izquierdo: arranca un drag. + if !derecho { + let handler = self.cache.as_ref().and_then(|c| { + let i = hit_test_click(&c.mounted, &c.computed, px, py)?; + c.mounted.nodes.get(i)?.drag.clone() + }); + if let Some(handler) = handler { + self.drag = Some((handler, (px, py))); + continue; + } + } + let msg = self.cache.as_ref().and_then(|c| { + let i = hit_test_click(&c.mounted, &c.computed, px, py)?; + let n = c.mounted.nodes.get(i)?; + if derecho { + // `on_right_click_screen` (coords absolutas, para menús al + // puntero) gana; luego `on_right_click_at` (local, grillas/ + // canvas); luego el `on_right_click` plano. + if let Some(screen) = n.on_right_click_screen.as_ref() { + let r = c.computed.get(n.id)?; + if let Some(m) = screen(px, py, r.w, r.h) { + return Some(m); + } + } + if let Some(m) = n.on_right_click.clone() { + return Some(m); + } + let at = n.on_right_click_at.as_ref()?; + let r = c.computed.get(n.id)?; + at(px - r.x, py - r.y, r.w, r.h) + } else { + if let Some(m) = n.on_click.clone() { + return Some(m); + } + let at = n.on_click_at.as_ref()?; + let r = c.computed.get(n.id)?; + at(px - r.x, py - r.y, r.w, r.h) + } + }); + if let Some(msg) = msg { + self.apply(msg); + } + } + } + } +} + +impl ProvidesRegistryState for Runner { + fn registry(&mut self) -> &mut RegistryState { + &mut self.registry_state + } + registry_handlers![OutputState, SeatState]; +} + +delegate_compositor!(@ Runner); +delegate_output!(@ Runner); +delegate_layer!(@ Runner); +delegate_seat!(@ Runner); +delegate_keyboard!(@ Runner); +delegate_pointer!(@ Runner); +delegate_shm!(@ Runner); +delegate_registry!(@ Runner); + +impl ShmHandler for Runner { + fn shm_state(&mut self) -> &mut Shm { + &mut self.shm + } +} diff --git a/llimphi-layout/Cargo.toml b/llimphi-layout/Cargo.toml index 6857628..f610ba8 100644 --- a/llimphi-layout/Cargo.toml +++ b/llimphi-layout/Cargo.toml @@ -1,19 +1,19 @@ [package] name = "llimphi-layout" -description = "Layout engine for llimphi (Flexbox + CSS Grid via taffy)." version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true publish.workspace = true -repository.workspace = true +description = "Layout engine for llimphi (Flexbox + CSS Grid via taffy)." +repository.workspace = true [dependencies] taffy = { workspace = true } [dev-dependencies] -llimphi-hal = { path = "../llimphi-hal" } -llimphi-raster = { path = "../llimphi-raster" } +llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" } +llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" } pollster = { workspace = true } [[example]] diff --git a/llimphi-layout/src/lib.rs b/llimphi-layout/src/lib.rs index b6932e5..9991808 100644 --- a/llimphi-layout/src/lib.rs +++ b/llimphi-layout/src/lib.rs @@ -153,6 +153,69 @@ impl LayoutTree { } } +#[cfg(test)] +mod cache_probe { + //! Bloque 24 (probe): ¿taffy saltea subárboles limpios al recomputar sobre + //! un árbol persistente (sin `clear()`)? De la respuesta depende todo el + //! enfoque de layout retenido. Se mide contando llamadas a `measure`. + use super::*; + use std::cell::Cell; + + #[test] + fn taffy_cachea_entre_computes() { + let mut lt = LayoutTree::new(); + let mut leaves = Vec::new(); + for _ in 0..100 { + leaves.push( + lt.leaf(Style { + size: Size { width: auto(), height: auto() }, + ..Default::default() + }) + .unwrap(), + ); + } + let root = lt + .node( + Style { + flex_direction: FlexDirection::Column, + size: Size { width: length(200.0), height: length(4000.0) }, + ..Default::default() + }, + &leaves, + ) + .unwrap(); + + let count = Cell::new(0u32); + let mut measure = |_nid: NodeId, + _k: taffy::Size>, + _a: taffy::Size| { + count.set(count.get() + 1); + taffy::Size { width: 10.0, height: 10.0 } + }; + + lt.compute_with_measure(root, (200.0, 4000.0), &mut measure).unwrap(); + let first = count.get(); + + // 2ª pasada SIN mutar ni clear. + count.set(0); + lt.compute_with_measure(root, (200.0, 4000.0), &mut measure).unwrap(); + let second = count.get(); + + // 3ª: una sola hoja sucia. + lt.inner_mut().mark_dirty(leaves[0]).unwrap(); + count.set(0); + lt.compute_with_measure(root, (200.0, 4000.0), &mut measure).unwrap(); + let third = count.get(); + + println!("[probe] measures: 1ª={first} 2ª(sin mutar)={second} 3ª(1 hoja sucia)={third}"); + assert!(first >= 100, "la 1ª pasada mide las hojas"); + // Premisa del Bloque 24: la 2ª pasada cachea (mide << first) y una hoja + // sucia sólo re-mide su cadena (third << first). + assert!(second < first, "2ª pasada debe cachear (second={second} < first={first})"); + assert!(third < first, "1 hoja sucia re-mide poco (third={third} < first={first})"); + } +} + fn flatten( tree: &TaffyTree<()>, node: NodeId, diff --git a/llimphi-lottie/Cargo.toml b/llimphi-lottie/Cargo.toml new file mode 100644 index 0000000..ca88242 --- /dev/null +++ b/llimphi-lottie/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "llimphi-lottie" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-lottie — puente fino velato (Lottie) → Llimphi. Carga un .json de Lottie una vez (LottieAsset) y lo stampea en cualquier View vía paint_with a un frame/instante dado, escalado al rect. Gemelo de llimphi-svg + eje de tiempo." + +[dependencies] +llimphi-ui = { workspace = true } +# Fork vendorizado de velato (no paniquea ante features no soportadas). +foreign-lottie = { workspace = true } +# Máquina de estados de animación (clip-agnóstica) que este crate renderiza. +llimphi-anim = { workspace = true } diff --git a/llimphi-lottie/LEEME.md b/llimphi-lottie/LEEME.md new file mode 100644 index 0000000..ed4c167 --- /dev/null +++ b/llimphi-lottie/LEEME.md @@ -0,0 +1,57 @@ +# llimphi-lottie + +*Read this in English: [README.md](README.md).* + +Puente fino entre `velato` (reproductor de Lottie de Linebender) y Llimphi. + +`llimphi-svg` cubre el vector *estático* arbitrario (íconos `.desktop`, +logos). `llimphi-motion` cubre el movimiento *generado por código* (tweens +sobre el bucle Elm). Este crate cubre el hueco del medio: **animación +vectorial autorada por fuera** — los `.json` de Lottie que exportan los +diseñadores desde After Effects / lottiefiles (íconos animados, +ilustraciones, estados vacíos, onboarding). + +Es el gemelo de `llimphi-svg`, con un eje de tiempo: `velato` parsea el JSON +a una `Composition` y, para un `frame` dado, emite a la **misma** +`vello::Scene` que usa `llimphi-raster`. + +## Uso +```ignore +use llimphi_lottie::LottieAsset; +use std::time::Duration; + +// Parsea UNA vez (al cargar la app): +let anim = LottieAsset::from_str(include_str!("spinner.json")).expect("lottie válido"); + +// En el estado de la app guardas el tiempo transcurrido y lo avanzas con el +// bucle Elm — exactamente el patrón de los workers de simulación: +// handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick); +// y en update: self.t += 1.0 / 60.0; + +// Pintalo a su instante actual (escala + centra al rect, en loop): +View::new(style).children(vec![anim.view_at_time::(self.t)]) +``` +El parse cuesta (corre el importador serde de Lottie); el `paint`/`view` no. +`LottieAsset` es `Clone` barato (`Arc` internamente) — el mismo asset en +varios nodos no replica memoria. + +## Por qué un `Renderer` nuevo por paint + +`velato::Renderer` es `Default` y sólo guarda Vecs de scratch que se limpian +en cada `append`. No tiene recursos de GPU (eso es trabajo de `llimphi-hal`). +Crear uno por frame es despreciable frente a empujar la geometría — que se +hace igual — y nos deja un `LottieAsset` `Send + Sync` sin `Mutex`. + +## Cobertura + +`velato` 0.9 implementa shapes, transforms animados, gradientes, trim paths, +máscaras y precomposiciones — el grueso de los Lottie reales de íconos e +ilustración. **No** implementa todavía capas de **texto**, **effects** +(blur/drop-shadow) ni **expresiones** (declarado en su propio +`schema/mod.rs`). Un asset que use esas features se pinta omitiéndolas, no +rompe. Cuando un `.json` concreto las necesite, el camino es vendorizar +`velato` como `shared/foreign-lottie` y completar el `todo` puntual. + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-lottie/README.md b/llimphi-lottie/README.md new file mode 100644 index 0000000..f820ea4 --- /dev/null +++ b/llimphi-lottie/README.md @@ -0,0 +1,31 @@ +# llimphi-lottie + +A thin bridge between `velato` (Linebender's Lottie player) and Llimphi. + +`llimphi-svg` covers arbitrary *static* vector art (`.desktop` icons, logos). +`llimphi-motion` covers motion *generated by code* (tweens over the Elm loop). +This crate covers the gap in between: **vector animation authored elsewhere** — +the Lottie `.json` files designers export from After Effects / lottiefiles +(animated icons, illustrations, empty states, onboarding). + +It is the twin of `llimphi-svg` with a time axis: `velato` parses the JSON into a +`Composition` and, for a given `frame`, emits into the **same** `vello::Scene` +that `llimphi-raster` uses. + +## Use + +```ignore +use llimphi_lottie::LottieAsset; +use std::time::Duration; + +// Parse ONCE (when loading the app): +let anim = LottieAsset::from_str(include_str!("spinner.json")).expect("valid lottie"); + +// The app keeps the elapsed time in its state and advances it with the Elm +// loop — exactly the pattern of the simulation workers: +// handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick); +``` + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-lottie/examples/pointer_listeners_demo.rs b/llimphi-lottie/examples/pointer_listeners_demo.rs new file mode 100644 index 0000000..6f51a22 --- /dev/null +++ b/llimphi-lottie/examples/pointer_listeners_demo.rs @@ -0,0 +1,202 @@ +//! Demo del Tier 3: **listeners de puntero** en la máquina de estados. +//! +//! El hit-testing vive **en el motor** (`llimphi-anim`), no en la app: la app +//! sólo reenvía el puntero crudo (`pointer_move`), y los `Listener` del estado +//! deciden qué input tocar. Aquí, dos listeners sobre toda la animación — +//! `Enter → moving=true`, `Exit → moving=false` — hacen que **pasar el puntero +//! por encima** dispare el crossfade idle→walk, y sacarlo lo revierta. Sin +//! teclado: el puntero es la interacción. +//! +//! Corre con: +//! `cargo run -p llimphi-lottie --example pointer_listeners_demo --release` + +use std::time::Duration; + +use llimphi_anim::{Action, Area, Condition, PointerTrigger, StateMachine}; +use llimphi_lottie::{state_machine_view, LottieAsset}; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{App, Handle, View}; + +const IDLE: u32 = 0; +const WALK: u32 = 1; + +/// idle: círculo azul cuya opacidad late (100→35→100) en 2 s. +const IDLE_LOTTIE: &str = r#"{ + "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100, + "layers":[{"ty":4,"ip":0,"op":60,"st":0,"sr":1, + "ks":{"o":{"a":1,"k":[ + {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[100]}, + {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":30,"s":[35]}, + {"t":60,"s":[100]}]}, + "r":{"a":0,"k":0},"p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}}, + "shapes":[{"ty":"gr","it":[ + {"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[72,72]}}, + {"ty":"fl","c":{"a":0,"k":[0.30,0.55,0.95]},"o":{"a":0,"k":100}}, + {"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}]}]}"#; + +/// walk: cuadrado naranja redondeado que gira 360° en 2 s. +const WALK_LOTTIE: &str = r#"{ + "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100, + "layers":[{"ty":4,"ip":0,"op":60,"st":0,"sr":1, + "ks":{"o":{"a":0,"k":100}, + "r":{"a":1,"k":[ + {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[0]}, + {"t":60,"s":[360]}]}, + "p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}}, + "shapes":[{"ty":"gr","it":[ + {"ty":"rc","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[60,60]},"r":{"a":0,"k":10}}, + {"ty":"fl","c":{"a":0,"k":[0.95,0.55,0.15]},"o":{"a":0,"k":100}}, + {"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}]}]}"#; + +#[derive(Clone)] +enum Msg { + Tick, + /// Puntero sobre la animación, en coords normalizadas `0..1` (`None` = salió). + Pointer(Option<(f64, f64)>), +} + +struct Model { + inst: llimphi_anim::Instance, + clips: Vec, +} + +struct Demo; + +const TICK: Duration = Duration::from_millis(16); + +impl App for Demo { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · pointer listeners" + } + + fn initial_size() -> (u32, u32) { + (420, 500) + } + + fn init(handle: &Handle) -> Self::Model { + let idle = LottieAsset::from_str(IDLE_LOTTIE).expect("idle lottie"); + let walk = LottieAsset::from_str(WALK_LOTTIE).expect("walk lottie"); + + let mut sm = StateMachine::new(); + let s_idle = sm.add_state("idle", IDLE, 1.0, true); + let s_walk = sm.add_state("walk", WALK, 1.0, true); + sm.set_entry(s_idle); + sm.transition(s_idle, s_walk, vec![Condition::bool("moving", true)], 0.3); + sm.transition(s_walk, s_idle, vec![Condition::bool("moving", false)], 0.3); + + // Tier 3: el hover sobre toda la animación maneja el input `moving`. + sm.listener(Area::All, PointerTrigger::Enter, Action::set_bool("moving", true)); + sm.listener(Area::All, PointerTrigger::Exit, Action::set_bool("moving", false)); + + handle.spawn_periodic(TICK, || Msg::Tick); + + Model { + inst: sm.instance(), + clips: vec![idle, walk], + } + } + + fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle) -> Self::Model { + match msg { + Msg::Tick => model.inst.advance(TICK.as_secs_f64()), + // La app sólo reenvía el puntero crudo; los listeners del motor + // deciden qué input tocar. + Msg::Pointer(p) => model.inst.pointer_move(p), + } + model + } + + fn view(model: &Self::Model) -> View { + let stage = View::new(Style { + size: Size { + width: length(280.0_f32), + height: length(280.0_f32), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .radius(16.0) + .fill(Color::from_rgba8(26, 30, 40, 255)) + .children(vec![state_machine_view::( + model.inst.render_frame(), + model.clips.clone(), + )]) + // Cada movimiento del cursor sobre el stage → posición normalizada. + .on_pointer_move_at(|lx, ly, w, h| { + if w > 0.0 && h > 0.0 { + Some(Msg::Pointer(Some((lx as f64 / w as f64, ly as f64 / h as f64)))) + } else { + None + } + }) + // El cursor salió del stage → fuera de toda área. + .on_pointer_leave(Msg::Pointer(None)); + + let estado = if model.inst.is_transitioning() { + "· · · crossfade · · ·".to_string() + } else { + format!("estado: {}", model.inst.current_state()) + }; + let status = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(26.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .text(estado, 17.0, Color::from_rgba8(180, 200, 230, 255)); + + let hint = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(22.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .text( + "pasa el puntero por encima".to_string(), + 14.0, + Color::from_rgba8(120, 135, 160, 255), + ); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(16.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + padding: Rect { + left: length(24.0_f32), + right: length(24.0_f32), + top: length(24.0_f32), + bottom: length(24.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(18, 22, 30, 255)) + .children(vec![stage, status, hint]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-lottie/examples/state_machine_demo.rs b/llimphi-lottie/examples/state_machine_demo.rs new file mode 100644 index 0000000..5764bf7 --- /dev/null +++ b/llimphi-lottie/examples/state_machine_demo.rs @@ -0,0 +1,216 @@ +//! Demo interactiva del Tier 1+2: máquina de estados de animación (`llimphi-anim`) +//! manejando dos clips Lottie con **crossfade**, manejada por un input vivo. +//! +//! Dos estados: `idle` (círculo azul que late) ⇄ `walk` (cuadrado naranja que +//! gira), con un blend de 0.35 s entre ellos. El input booleano `moving` dispara +//! las transiciones en ambos sentidos. **Espacio** o el botón lo togglean — +//! ves el crossfade disolver una animación en la otra. +//! +//! Esto es el cableado canónico inputs↔app (Tier 2): el `Model` tiene una +//! `Instance`, un `spawn_periodic` la avanza por frame (`advance(dt)`), los +//! eventos setean inputs (`set_bool`), y `view` pinta `render_frame()` vía +//! `state_machine_view`. El núcleo de estados no sabe de Lottie ni de vello. +//! +//! Corre con: +//! `cargo run -p llimphi-lottie --example state_machine_demo --release` + +use std::time::Duration; + +use llimphi_anim::{Condition, StateMachine}; +use llimphi_lottie::{state_machine_view, LottieAsset}; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View}; + +// ClipId 0 = idle, 1 = walk. +const IDLE: u32 = 0; +const WALK: u32 = 1; + +/// idle: círculo azul cuya opacidad late (100→35→100) en 2 s. +const IDLE_LOTTIE: &str = r#"{ + "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100, + "layers":[{"ty":4,"ip":0,"op":60,"st":0,"sr":1, + "ks":{"o":{"a":1,"k":[ + {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[100]}, + {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":30,"s":[35]}, + {"t":60,"s":[100]}]}, + "r":{"a":0,"k":0},"p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}}, + "shapes":[{"ty":"gr","it":[ + {"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[72,72]}}, + {"ty":"fl","c":{"a":0,"k":[0.30,0.55,0.95]},"o":{"a":0,"k":100}}, + {"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}]}]}"#; + +/// walk: cuadrado naranja redondeado que gira 360° en 2 s. +const WALK_LOTTIE: &str = r#"{ + "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100, + "layers":[{"ty":4,"ip":0,"op":60,"st":0,"sr":1, + "ks":{"o":{"a":0,"k":100}, + "r":{"a":1,"k":[ + {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[0]}, + {"t":60,"s":[360]}]}, + "p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}}, + "shapes":[{"ty":"gr","it":[ + {"ty":"rc","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[60,60]},"r":{"a":0,"k":10}}, + {"ty":"fl","c":{"a":0,"k":[0.95,0.55,0.15]},"o":{"a":0,"k":100}}, + {"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}]}]}"#; + +#[derive(Clone)] +enum Msg { + /// Tick de animación: avanza la máquina por `dt`. + Tick, + /// Togglea el input `moving` (Espacio o el botón). + ToggleMoving, +} + +struct Model { + inst: llimphi_anim::Instance, + clips: Vec, + moving: bool, +} + +struct Demo; + +/// Período del tick → también el `dt` que le pasamos a `advance` (fijo, alcanza +/// para una demo; una app real mediría el dt real). +const TICK: Duration = Duration::from_millis(16); + +impl App for Demo { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · state machine + crossfade" + } + + fn initial_size() -> (u32, u32) { + (420, 520) + } + + fn init(handle: &Handle) -> Self::Model { + let idle = LottieAsset::from_str(IDLE_LOTTIE).expect("idle lottie"); + let walk = LottieAsset::from_str(WALK_LOTTIE).expect("walk lottie"); + + let mut sm = StateMachine::new(); + let s_idle = sm.add_state("idle", IDLE, 1.0, true); + let s_walk = sm.add_state("walk", WALK, 1.0, true); + sm.set_entry(s_idle); + // El mismo input `moving` rige ambos sentidos, con blend de 0.35 s. + sm.transition(s_idle, s_walk, vec![Condition::bool("moving", true)], 0.35); + sm.transition(s_walk, s_idle, vec![Condition::bool("moving", false)], 0.35); + + // Corre el reloj de animación (~60 fps). + handle.spawn_periodic(TICK, || Msg::Tick); + + Model { + inst: sm.instance(), + clips: vec![idle, walk], + moving: false, + } + } + + fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle) -> Self::Model { + match msg { + Msg::Tick => model.inst.advance(TICK.as_secs_f64()), + Msg::ToggleMoving => { + model.moving = !model.moving; + model.inst.set_bool("moving", model.moving); + } + } + model + } + + fn on_key(_: &Self::Model, e: &KeyEvent) -> Option { + if e.state == KeyState::Pressed && e.key == Key::Named(NamedKey::Space) { + Some(Msg::ToggleMoving) + } else { + None + } + } + + fn view(model: &Self::Model) -> View { + // Caja cuadrada que aloja la animación (state_machine_view es absolute-fill). + let stage = View::new(Style { + size: Size { + width: length(280.0_f32), + height: length(280.0_f32), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .children(vec![state_machine_view::( + model.inst.render_frame(), + model.clips.clone(), + )]); + + let estado = if model.inst.is_transitioning() { + "· · · crossfade · · ·".to_string() + } else { + format!("estado: {}", model.inst.current_state()) + }; + let status = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .text(estado, 18.0, Color::from_rgba8(180, 200, 230, 255)); + + let label = if model.moving { + "moving = true (Espacio para parar)" + } else { + "moving = false (Espacio para mover)" + }; + let boton = View::new(Style { + size: Size { + width: length(280.0_f32), + height: length(52.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(if model.moving { + Color::from_rgba8(200, 120, 50, 255) + } else { + Color::from_rgba8(50, 110, 190, 255) + }) + .radius(12.0) + .text(label.to_string(), 16.0, Color::from_rgba8(245, 245, 250, 255)) + .on_click(Msg::ToggleMoving); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(20.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + padding: Rect { + left: length(24.0_f32), + right: length(24.0_f32), + top: length(24.0_f32), + bottom: length(24.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(18, 22, 30, 255)) + .children(vec![stage, status, boton]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-lottie/src/lib.rs b/llimphi-lottie/src/lib.rs new file mode 100644 index 0000000..9cade5f --- /dev/null +++ b/llimphi-lottie/src/lib.rs @@ -0,0 +1,461 @@ +//! `llimphi-lottie` — puente fino entre `velato` (reproductor de Lottie de +//! Linebender) y Llimphi. +//! +//! `llimphi-svg` cubre el vector *estático* arbitrario (íconos `.desktop`, +//! logos). `llimphi-motion` cubre el movimiento *generado por código* (tweens +//! sobre el bucle Elm). Este crate cubre el hueco del medio: **animación +//! vectorial autorada por fuera** — los `.json` de Lottie que exportan los +//! diseñadores desde After Effects / lottiefiles (íconos animados, +//! ilustraciones, estados vacíos, onboarding). +//! +//! Es el gemelo de `llimphi-svg`, con un eje de tiempo: `velato` parsea el JSON +//! a una `Composition` y, para un `frame` dado, emite a la **misma** +//! `vello::Scene` que usa `llimphi-raster`. +//! +//! ## Uso +//! +//! ```ignore +//! use llimphi_lottie::LottieAsset; +//! use std::time::Duration; +//! +//! // Parsea UNA vez (al cargar la app): +//! let anim = LottieAsset::from_str(include_str!("spinner.json")).expect("lottie válido"); +//! +//! // En el estado de la app guardas el tiempo transcurrido y lo avanzas con el +//! // bucle Elm — exactamente el patrón de los workers de simulación: +//! // handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick); +//! // y en update: self.t += 1.0 / 60.0; +//! +//! // Pintalo a su instante actual (escala + centra al rect, en loop): +//! View::new(style).children(vec![anim.view_at_time::(self.t)]) +//! ``` +//! +//! El parse cuesta (corre el importador serde de Lottie); el `paint`/`view` no. +//! `LottieAsset` es `Clone` barato (`Arc` internamente) — el mismo asset en +//! varios nodos no replica memoria. +//! +//! ## Por qué un `Renderer` nuevo por paint +//! +//! `velato::Renderer` es `Default` y sólo guarda Vecs de scratch que se limpian +//! en cada `append`. No tiene recursos de GPU (eso es trabajo de `llimphi-hal`). +//! Crear uno por frame es despreciable frente a empujar la geometría — que se +//! hace igual — y nos deja un `LottieAsset` `Send + Sync` sin `Mutex`. +//! +//! ## Cobertura +//! +//! `velato` 0.9 implementa shapes, transforms animados, gradientes, trim paths, +//! máscaras y precomposiciones — el grueso de los Lottie reales de íconos e +//! ilustración. **No** implementa todavía capas de **texto**, **effects** +//! (blur/drop-shadow) ni **expresiones** (declarado en su propio +//! `schema/mod.rs`). Un asset que use esas features se pinta omitiéndolas, no +//! rompe. Cuando un `.json` concreto las necesite, el camino es vendorizar +//! `velato` como `shared/foreign-lottie` y completar el `todo` puntual. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style}; +use llimphi_ui::llimphi_layout::taffy::Position; +use llimphi_ui::llimphi_raster::kurbo::Affine; +use llimphi_ui::llimphi_raster::vello::Scene; +use llimphi_ui::{PaintRect, View}; + +// El motor es nuestro fork vendorizado de velato; lo aliasamos a `velato` para +// que el resto del crate lea igual que la documentación upstream. +use foreign_lottie as velato; +use velato::Composition; + +/// Animación Lottie parseada y lista para stampear a cualquier `frame`. +/// Internamente guarda la `Composition` de `velato` (el modelo del `.json`) + +/// su geometría temporal. Cloneable barato (`Arc`) — un mismo asset en varios +/// nodos no replica memoria. `Send + Sync`. +#[derive(Clone)] +pub struct LottieAsset { + inner: Arc, +} + +/// Error al parsear un Lottie. Hoy es un wrap del texto del error de `velato` — +/// las apps típicas lo tratan como "fallback" (no animan) y no inspeccionan la +/// variante. +#[derive(Debug)] +pub struct LottieError(pub String); + +impl std::fmt::Display for LottieError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "lottie: {}", self.0) + } +} + +impl std::error::Error for LottieError {} + +impl LottieAsset { + /// Parsea un Lottie desde su JSON como string. Devuelve un asset inmutable + + /// cloneable. **Hace el parseo completo** (serde + importación al modelo + /// runtime de `velato`); pensado para llamarse UNA vez por asset, no por + /// frame. + pub fn from_str(json: &str) -> Result { + Self::from_bytes(json.as_bytes()) + } + + /// Parsea un Lottie desde sus bytes UTF-8 crudos (lo que devuelve + /// `include_bytes!("…json")` o `std::fs::read`). + /// + /// **Blindaje contra panics.** Nuestro fork `foreign-lottie` ya eliminó los + /// `todo!()`/`unimplemented!()` del importador de velato 0.9 (split + /// rotation/position, blends `Add`/`HardMix`, assets desconocidos, transform + /// sin rotación) — degradan con gracia en vez de paniquear. El `catch_unwind` + /// queda como red de seguridad secundaria: por si un `.json` raro alcanza + /// algún `panic!`/`unwrap` residual del deserializador, el asset cae al + /// fallback en vez de tumbar el hilo de UI. El render (`append`) es seguro, + /// así que no lo envolvemos por-frame. + pub fn from_bytes(json: &[u8]) -> Result { + let parsed = std::panic::catch_unwind(|| Composition::from_slice(json)); + let comp = match parsed { + Ok(Ok(comp)) => comp, + Ok(Err(e)) => return Err(LottieError(e.to_string())), + Err(_) => { + return Err(LottieError( + "velato paniqueó al importar (feature no soportada: split \ + rotation/position, asset o blend sin implementar)" + .to_string(), + )) + } + }; + Ok(Self { + inner: Arc::new(comp), + }) + } + + /// Tamaño nominal de la animación en px (el `w`/`h` del Lottie). Útil para + /// dimensionar el rect destino preservando aspect ratio. + pub fn size(&self) -> (f64, f64) { + (self.inner.width as f64, self.inner.height as f64) + } + + /// Cuadros por segundo declarados en el `.json` (`fr`). + pub fn frame_rate(&self) -> f64 { + self.inner.frame_rate + } + + /// Rango de frames activos `[ip, op)` del Lottie. `paint` espera un `frame` + /// dentro de este rango; fuera de él, lo clampa a los extremos. + pub fn frames(&self) -> std::ops::Range { + self.inner.frames.clone() + } + + /// Duración de una pasada completa, en segundos. `0.0` si el `.json` no + /// declara `fr` o no tiene frames. + pub fn duration_secs(&self) -> f64 { + let span = self.inner.frames.end - self.inner.frames.start; + if self.inner.frame_rate > 0.0 && span > 0.0 { + span / self.inner.frame_rate + } else { + 0.0 + } + } + + /// Convierte un instante en segundos a un `frame` dentro del rango activo, + /// **en loop** (módulo la duración). Es el mapeo que usa `view_at_time` / + /// `paint_at_time`. Si el asset no tiene duración válida, devuelve el primer + /// frame. + pub fn frame_at_time(&self, t_secs: f64) -> f64 { + let start = self.inner.frames.start; + let span = self.inner.frames.end - start; + if span <= 0.0 || self.inner.frame_rate <= 0.0 { + return start; + } + let frames_elapsed = (t_secs.max(0.0) * self.inner.frame_rate) % span; + start + frames_elapsed + } + + /// Pinta la animación al `frame` indicado sobre `scene`, ajustada al `rect`. + /// Escala uniforme al mínimo lado y **centra** dentro del rect (preserva + /// aspect ratio). `frame` se clampa al rango activo del Lottie. Útil cuando + /// el caller compone varios assets en un `paint_with` propio sin pasar por + /// `view()`. + pub fn paint(&self, scene: &mut Scene, rect: PaintRect, frame: f64) { + self.paint_alpha(scene, rect, frame, 1.0); + } + + /// Como `paint`, pero con una opacidad global `alpha` (0..1) aplicada a toda + /// la composición. Es lo que habilita el **crossfade** entre clips de una + /// máquina de estados (pintar el saliente a `1-mix` y el entrante a `mix`). + /// `velato` lo soporta nativo: el `alpha` va al `append`. + pub fn paint_alpha(&self, scene: &mut Scene, rect: PaintRect, frame: f64, alpha: f64) { + let (vb_w, vb_h) = self.size(); + let side_w = rect.w as f64; + let side_h = rect.h as f64; + if side_w <= 0.0 || side_h <= 0.0 || vb_w <= 0.0 || vb_h <= 0.0 || alpha <= 0.0 { + return; + } + let s = (side_w / vb_w).min(side_h / vb_h); + let used_w = vb_w * s; + let used_h = vb_h * s; + let tx = rect.x as f64 + (side_w - used_w) * 0.5; + let ty = rect.y as f64 + (side_h - used_h) * 0.5; + let xform = Affine::translate((tx, ty)) * Affine::scale(s); + + let frame = frame.clamp(self.inner.frames.start, self.inner.frames.end); + // Renderer nuevo por paint: es `Default`, sólo scratch Vecs, sin GPU. + let mut renderer = velato::Renderer::new(); + renderer.append(&self.inner, frame, xform, alpha.clamp(0.0, 1.0), scene); + } + + /// Como `paint`, pero recibe el instante en **segundos** y lo mapea a frame + /// en loop vía `frame_at_time`. Es la forma esperada de animar desde el + /// estado de la app (acumulas `t` en segundos en `update`). + pub fn paint_at_time(&self, scene: &mut Scene, rect: PaintRect, t_secs: f64) { + self.paint(scene, rect, self.frame_at_time(t_secs)); + } + + /// Construye un `View` posicionado en absoluto que ocupa todo el rect del + /// padre y pinta la animación al `frame` indicado, centrada + escalada al + /// mínimo lado. Gemelo de `SvgAsset::view`, con frame. Genérico sobre `Msg` + /// igual que los widgets — el `View` no tiene handlers; la app los pone en + /// el padre. + pub fn view(&self, frame: f64) -> View { + let asset = self.clone(); + View::new(absolute_fill()) + .paint_with(move |scene, _ts, rect| asset.paint(scene, rect, frame)) + } + + /// Como `view`, pero recibe el instante en segundos (loop vía + /// `frame_at_time`). Es la variante que usas con un `t` acumulado en el + /// estado de la app. + pub fn view_at_time(&self, t_secs: f64) -> View { + let asset = self.clone(); + View::new(absolute_fill()) + .paint_with(move |scene, _ts, rect| asset.paint_at_time(scene, rect, t_secs)) + } +} + +/// Pinta un [`RenderFrame`] de una máquina de estados [`llimphi_anim`] usando +/// `clips` indexados por `ClipId` (= índice en el slice). Pinta el clip primario +/// y, si hay una transición en curso, hace **crossfade**: saliente a `1-mix`, +/// entrante a `mix`. Los `ClipId` fuera de rango se omiten (no rompe). +/// +/// `ClipSample::time_secs` se mapea a frame con loop vía `frame_at_time`, así +/// que cada clip respeta su propio fps y duración. +/// +/// [`RenderFrame`]: llimphi_anim::RenderFrame +pub fn paint_render_frame( + scene: &mut Scene, + rect: PaintRect, + frame: &llimphi_anim::RenderFrame, + clips: &[LottieAsset], +) { + let sample = |s: llimphi_anim::ClipSample, alpha: f64, scene: &mut Scene| { + if let Some(asset) = clips.get(s.clip as usize) { + let f = asset.frame_at_time(s.time_secs); + asset.paint_alpha(scene, rect, f, alpha); + } + }; + match frame.blend { + None => sample(frame.primary, 1.0, scene), + Some((incoming, mix)) => { + let mix = mix as f64; + // Dissolve: el saliente se desvanece mientras el entrante aparece. + sample(frame.primary, 1.0 - mix, scene); + sample(incoming, mix, scene); + } + } +} + +/// Construye un `View` absoluto que ocupa el rect del padre y pinta el +/// [`RenderFrame`] actual de una máquina de estados (con crossfade). El caller +/// llama `instance.render_frame()` cada frame y le pasa el resultado + sus clips +/// (cloneados; baratos por `Arc`). Es el gemelo de [`LottieAsset::view`] para +/// animación dirigida por estado en vez de por un solo clip. +/// +/// [`RenderFrame`]: llimphi_anim::RenderFrame +pub fn state_machine_view( + frame: llimphi_anim::RenderFrame, + clips: Vec, +) -> View { + View::new(absolute_fill()) + .paint_with(move |scene, _ts, rect| paint_render_frame(scene, rect, &frame, &clips)) +} + +/// Estilo "ocupa todo el rect del padre, en absoluto" — compartido por las dos +/// variantes de `view`. +fn absolute_fill() -> Style { + Style { + position: Position::Absolute, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Lottie mínimo válido: 60 frames a 30 fps, lienzo 100×100, sin capas. + /// (Suficiente para ejercitar parse + geometría temporal; el render de un + /// shape real lo cubren los assets de ejemplo, no un unit test.) + const LOTTIE_OK: &str = + r#"{"v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,"layers":[]}"#; + + #[test] + fn from_str_parsea_ok() { + let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea"); + let (w, h) = a.size(); + assert_eq!((w, h), (100.0, 100.0)); + assert_eq!(a.frame_rate(), 30.0); + } + + #[test] + fn duracion_y_rango() { + let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea"); + assert_eq!(a.frames(), 0.0..60.0); + // 60 frames / 30 fps = 2 s. + assert!((a.duration_secs() - 2.0).abs() < 1e-9); + } + + #[test] + fn frame_at_time_hace_loop() { + let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea"); + // En t=0 → frame 0. + assert!((a.frame_at_time(0.0) - 0.0).abs() < 1e-9); + // En t=1 s (mitad) → frame 30. + assert!((a.frame_at_time(1.0) - 30.0).abs() < 1e-9); + // En t=2 s (una pasada exacta) → vuelve a 0 por el módulo. + assert!((a.frame_at_time(2.0) - 0.0).abs() < 1e-9); + // En t=2.5 s → frame 15 (loop). + assert!((a.frame_at_time(2.5) - 15.0).abs() < 1e-9); + } + + #[test] + fn json_inválido_da_error() { + assert!(LottieAsset::from_str("{no es lottie}").is_err()); + } + + #[test] + fn asset_es_cloneable_barato() { + let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea"); + let b = a.clone(); + assert_eq!(a.size(), b.size()); + } + + #[test] + fn paint_no_panica_con_rect_cero() { + let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea"); + let mut s = Scene::new(); + a.paint(&mut s, PaintRect { x: 0.0, y: 0.0, w: 0.0, h: 0.0 }, 0.0); + a.paint_at_time(&mut s, PaintRect { x: 0.0, y: 0.0, w: 10.0, h: 0.0 }, 0.5); + } + + /// `Send + Sync` es parte del contrato (igual que `SvgAsset`): el asset se + /// mueve a closures de paint que pueden cruzar threads. + #[test] + fn asset_es_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } + + /// Lottie con un shape real: un rectángulo rojo de 80×80 centrado en un + /// lienzo 100×100, estático 60 frames. Sirve para certificar — con + /// evidencia textual, no PNG — que la geometría llega a la `vello::Scene`. + const LOTTIE_RECT: &str = r#"{ + "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100, + "layers":[{ + "ty":4,"ip":0,"op":60,"st":0,"sr":1, + "ks":{"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0}}, + "shapes":[ + {"ty":"rc","p":{"a":0,"k":[50,50]},"s":{"a":0,"k":[80,80]},"r":{"a":0,"k":0}}, + {"ty":"fl","c":{"a":0,"k":[0.8,0,0,1]},"o":{"a":0,"k":100}} + ] + }] + }"#; + + #[test] + fn shape_real_emite_geometria_a_la_scene() { + let a = LottieAsset::from_str(LOTTIE_RECT).expect("parsea"); + let mut scene = Scene::new(); + // Antes de pintar: la Scene está vacía. + assert!(scene.encoding().is_empty(), "scene recién creada debe estar vacía"); + a.paint(&mut scene, PaintRect { x: 0.0, y: 0.0, w: 200.0, h: 200.0 }, 0.0); + // Después: velato empujó el rect → encoding con contenido. + assert!( + !scene.encoding().is_empty(), + "tras paint de un shape real, la Scene debe tener geometría" + ); + } + + /// Mismo layer pero con el transform SIN campo de rotación (`r`). En velato + /// 0.9 upstream esto **paniquea** (`todo!("split rotation")`); nuestro fork + /// `foreign-lottie` lo trata como rotación 0 y **parsea bien**. Aquí + /// certificamos el resultado del fork: parse Ok, sin panic. + const LOTTIE_SIN_ROTACION: &str = r#"{ + "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100, + "layers":[{ + "ty":4,"ip":0,"op":60,"st":0,"sr":1, + "ks":{"p":{"a":0,"k":[0,0]}}, + "shapes":[] + }] + }"#; + + #[test] + fn lottie_sin_rotacion_parsea_con_el_fork() { + // En velato upstream esto era un panic; el fork lo importa como rot=0. + let a = LottieAsset::from_str(LOTTIE_SIN_ROTACION) + .expect("el fork parsea un transform sin rotación"); + assert_eq!(a.size(), (100.0, 100.0)); + } + + /// Segundo clip distinto al rect: un círculo, para tener dos animaciones que + /// mezclar en la máquina de estados. + const LOTTIE_CIRC: &str = r#"{ + "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100, + "layers":[{ + "ty":4,"ip":0,"op":60,"st":0,"sr":1, + "ks":{"p":{"a":0,"k":[50,50]},"r":{"a":0,"k":0}}, + "shapes":[ + {"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[70,70]}}, + {"ty":"fl","c":{"a":0,"k":[0,0.4,0.9,1]},"o":{"a":0,"k":100}} + ] + }] + }"#; + + /// E2E del Tier 1: máquina de estados (llimphi-anim) + dos clips Lottie + + /// `paint_render_frame`. Verifica que tanto el estado simple como el + /// crossfade emiten geometría a la `Scene` (evidencia textual, sin PNG). + #[test] + fn state_machine_pinta_clips_y_crossfade() { + use llimphi_anim::{Condition, StateMachine}; + + let rect = LottieAsset::from_str(LOTTIE_RECT).expect("rect"); + let circ = LottieAsset::from_str(LOTTIE_CIRC).expect("circ"); + let clips = vec![rect, circ]; // ClipId 0 = rect, 1 = circ + + let mut sm = StateMachine::new(); + let idle = sm.add_state("idle", 0, 1.0, true); + let walk = sm.add_state("walk", 1, 1.0, true); + sm.set_entry(idle); + sm.transition(idle, walk, vec![Condition::bool("moving", true)], 0.4); + let mut inst = sm.instance(); + + let big = PaintRect { x: 0.0, y: 0.0, w: 200.0, h: 200.0 }; + + // Estado simple: pinta sólo el clip primario (rect). + let mut s0 = Scene::new(); + paint_render_frame(&mut s0, big, &inst.render_frame(), &clips); + assert!(!s0.encoding().is_empty(), "estado simple debe pintar geometría"); + + // Arranca la transición y cae a mitad del blend. + inst.set_bool("moving", true); + inst.advance(0.2); // 0.2/0.4 = mix 0.5 + let rf = inst.render_frame(); + assert!(rf.blend.is_some(), "debería estar en crossfade"); + let mut s1 = Scene::new(); + paint_render_frame(&mut s1, big, &rf, &clips); + assert!(!s1.encoding().is_empty(), "el crossfade debe pintar geometría"); + + // El view helper compila y produce un View sin panic. + let _v = state_machine_view::<()>(rf, clips); + } +} diff --git a/llimphi-mesh/Cargo.toml b/llimphi-mesh/Cargo.toml new file mode 100644 index 0000000..4878ff1 --- /dev/null +++ b/llimphi-mesh/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "llimphi-mesh" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-mesh — render de mallas deformables (llimphi-anim::skel::Mesh) a vello Scene. Ruta vectorial (fill por triángulo) y texturizada (clip + draw_image piecewise-affine), validadas contra vello 0.7. La matemática de huesos/skinning vive en llimphi-anim; este crate sólo pinta." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-anim = { workspace = true } + +[dev-dependencies] +# Sólo para el ejemplo combinado lottie+rive (Lottie + esqueleto/IK en una app). +llimphi-lottie = { workspace = true } diff --git a/llimphi-mesh/LEEME.md b/llimphi-mesh/LEEME.md new file mode 100644 index 0000000..0abc10a --- /dev/null +++ b/llimphi-mesh/LEEME.md @@ -0,0 +1,27 @@ +# llimphi-mesh + +*Read this in English: [README.md](README.md).* + +Render de mallas deformables a una `vello::Scene`. + +La matemática de huesos y skinning vive en `llimphi_anim::skel`; este crate +sólo **pinta** la malla ya deformada. Recibe la malla (`Mesh`) + las +posiciones deformadas de sus vértices (lo que devuelve `Mesh::deform`) y un +`xform` model→pantalla, y ofrece las dos rutas que el spike validó contra +vello 0.7: + +- `paint_solid` — rellena cada triángulo deformado con un color (malla + vectorial / debug). Trivial. +- `paint_textured` — malla **texturizada**: por triángulo, recorta al + triángulo deformado y dibuja la imagen con el afín que mapea sus UV a la + posición deformada (warp piecewise-affine). Costo = un clip-layer por + triángulo; cuidado con mallas de miles de triángulos. +- `paint_wireframe` — traza los bordes de los triángulos (ver la + deformación; ideal para demos/debug). + +Helpers de encuadre: `rest_bounds` (bbox de la malla en reposo) + +`fit_transform` (afín que encaja esos bounds, centrados, en un `PaintRect`). + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-mesh/README.md b/llimphi-mesh/README.md new file mode 100644 index 0000000..07fa55f --- /dev/null +++ b/llimphi-mesh/README.md @@ -0,0 +1,25 @@ +# llimphi-mesh + +Rendering of deformable meshes to a `vello::Scene`. + +The bone and skinning maths live in `llimphi_anim::skel`; this crate only +**paints** the already-deformed mesh. It receives the mesh (`Mesh`) plus the +deformed positions of its vertices (what `Mesh::deform` returns) and a +model→screen `xform`, and offers the two routes the spike validated against +vello 0.7: + +- `paint_solid` — fills each deformed triangle with a colour (vector mesh / + debug). Trivial. +- `paint_textured` — a **textured** mesh: per triangle, it clips to the deformed + triangle and draws the image with the affine mapping its UVs to the deformed + position (piecewise-affine warp). Cost = one clip layer per triangle; careful + with meshes of thousands of triangles. +- `paint_wireframe` — strokes the triangle edges (to see the deformation; ideal + for demos and debugging). + +Framing helpers: `rest_bounds` (the mesh's bbox at rest) and `fit_transform` (the +affine fitting those bounds, centred, into a `PaintRect`). + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-mesh/examples/bones_demo.rs b/llimphi-mesh/examples/bones_demo.rs new file mode 100644 index 0000000..5de9582 --- /dev/null +++ b/llimphi-mesh/examples/bones_demo.rs @@ -0,0 +1,221 @@ +//! Demo del Tier 4: una tira **texturizada deformada por una cadena de huesos** +//! que ondula como una bandera/tentáculo. +//! +//! La cadena de huesos (`llimphi_anim::skel`) se re-posa cada frame con una onda +//! senoidal de fase creciente; el skinning (LBS) deforma los vértices; y +//! `llimphi-mesh` pinta la malla texturizada (warp piecewise-affine) con un +//! wireframe encima para ver la deformación. **Espacio** alterna el wireframe. +//! +//! Todo Rust nativo, sobre vello 0.7 — cero C++. +//! +//! Corre con: +//! `cargo run -p llimphi-mesh --example bones_demo --release` + +use std::sync::Arc; +use std::time::Duration; + +use llimphi_anim::skel::{Mesh, Pose, Skeleton, Vertex}; +use llimphi_mesh::{fit_transform, paint_textured, paint_wireframe, rest_bounds}; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::kurbo::{Point, Vec2}; +use llimphi_ui::llimphi_raster::peniko::{ + Blob, Color, ImageAlphaType, ImageBrush, ImageData, ImageFormat, +}; +use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, PaintRect, View}; + +const SEG: usize = 8; // segmentos de la tira (= huesos de la cadena, +1 raíz) +const STEP: f64 = 30.0; // largo de cada hueso/segmento +const STRIP_H: f64 = 72.0; // alto de la tira + +/// Construye la cadena de huesos (raíz + SEG eslabones) y la malla-tira atada a +/// ella (rigid por columna), con UVs de 0..1 a lo ancho/alto. +fn build() -> (Skeleton, Mesh) { + let mut s = Skeleton::new(); + // Raíz anclada a la izquierda; cada eslabón se traslada STEP en x respecto + // al padre. Bone i tiene id i (orden de inserción). + s.add_bone(None, Pose::translate(Vec2::new(0.0, STRIP_H / 2.0))); + for _ in 1..=SEG { + s.add_bone(Some(s.len() - 1), Pose::translate(Vec2::new(STEP, 0.0))); + } + s.bind(); + + let mut m = Mesh::new(); + for i in 0..=SEG { + let x = i as f64 * STEP; + let u = i as f64 / SEG as f64; + m.vertices.push(Vertex::rigid(Point::new(x, 0.0), (u, 0.0), i)); // borde sup + m.vertices.push(Vertex::rigid(Point::new(x, STRIP_H), (u, 1.0), i)); // borde inf + } + for i in 0..SEG { + let (t0, t1) = ((2 * i) as u32, (2 * (i + 1)) as u32); + let (b0, b1) = ((2 * i + 1) as u32, (2 * (i + 1) + 1) as u32); + m.triangles.push([t0, t1, b1]); + m.triangles.push([t0, b1, b0]); + } + (s, m) +} + +/// Textura procedural: tablero de ajedrez (para ver el warp). +fn checker(n: u32, sq: u32) -> ImageBrush { + let mut px = Vec::with_capacity((n * n * 4) as usize); + for y in 0..n { + for x in 0..n { + let on = ((x / sq + y / sq) % 2) == 0; + let (r, g, b) = if on { (70, 130, 205) } else { (240, 180, 70) }; + px.extend_from_slice(&[r, g, b, 255]); + } + } + ImageBrush::new(ImageData { + data: Blob::new(Arc::new(px)), + format: ImageFormat::Rgba8, + alpha_type: ImageAlphaType::Alpha, + width: n, + height: n, + }) +} + +#[derive(Clone)] +enum Msg { + Tick, + ToggleWire, +} + +struct Model { + skel: Skeleton, + mesh: Mesh, + image: ImageBrush, + t: f64, + wireframe: bool, +} + +struct Demo; + +const TICK: Duration = Duration::from_millis(16); + +impl App for Demo { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · malla deformada por huesos" + } + fn initial_size() -> (u32, u32) { + (520, 420) + } + + fn init(handle: &Handle) -> Self::Model { + let (skel, mesh) = build(); + handle.spawn_periodic(TICK, || Msg::Tick); + Model { + skel, + mesh, + image: checker(8, 1), + t: 0.0, + wireframe: true, + } + } + + fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle) -> Self::Model { + match msg { + Msg::Tick => { + model.t += TICK.as_secs_f64(); + // Onda viajera: cada hueso oscila con fase creciente → la tira + // ondula desde el ancla hacia la punta. + for i in 1..=SEG { + let r = 0.38 * (model.t * 3.0 - i as f64 * 0.6).sin(); + model + .skel + .set_pose(i, Pose::new(Vec2::new(STEP, 0.0), r, Vec2::new(1.0, 1.0))); + } + model.skel.update(); + } + Msg::ToggleWire => model.wireframe = !model.wireframe, + } + model + } + + fn on_key(_: &Self::Model, e: &KeyEvent) -> Option { + if e.state == KeyState::Pressed && e.key == Key::Named(NamedKey::Space) { + Some(Msg::ToggleWire) + } else { + None + } + } + + fn view(model: &Self::Model) -> View { + // Deformar aquí (lectura) y mover los datos al closure de pintura. + let positions = model.mesh.deform(&model.skel); + let mesh = model.mesh.clone(); + let image = model.image.clone(); + let wire = model.wireframe; + + let stage = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + // Margen para que la onda no se recorte contra el borde. + let inner = PaintRect { + x: rect.x + 50.0, + y: rect.y + 50.0, + w: (rect.w - 100.0).max(1.0), + h: (rect.h - 100.0).max(1.0), + }; + let xf = fit_transform(rest_bounds(&mesh), inner); + paint_textured(scene, &mesh, &positions, xf, &image); + if wire { + paint_wireframe( + scene, + &mesh, + &positions, + xf, + Color::from_rgba8(255, 255, 255, 120), + 1.2, + ); + } + }); + + let hint = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(30.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .text( + "tira texturizada deformada por una cadena de huesos · Espacio: wireframe".to_string(), + 13.0, + Color::from_rgba8(140, 155, 180, 255), + ); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(8.0_f32), + bottom: length(8.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(18, 22, 30, 255)) + .children(vec![stage, hint]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-mesh/examples/clips_to_bones_demo.rs b/llimphi-mesh/examples/clips_to_bones_demo.rs new file mode 100644 index 0000000..575dc1b --- /dev/null +++ b/llimphi-mesh/examples/clips_to_bones_demo.rs @@ -0,0 +1,248 @@ +//! Demo que **cierra el círculo** del motor: máquina de estados → animación de +//! huesos → skinning → malla deformada. +//! +//! Dos clips son `BoneAnimation`s (animaciones esqueletales keyframeadas): +//! `rest` (tira recta) y `wave` (onda viajera). Una máquina de estados +//! (`llimphi-anim`) transiciona entre ellos por el input `active`, y +//! `pose_from_render_frame` posa el esqueleto **blendeando poses** durante el +//! crossfade — así la tira se *ease-in* a la onda en vez de saltar. El skinning +//! deforma la malla texturizada (`llimphi-mesh`). +//! +//! **Espacio** alterna `active` (rest ⇄ wave). Todo Rust nativo sobre vello 0.7. +//! +//! Corre con: +//! `cargo run -p llimphi-mesh --example clips_to_bones_demo --release` + +use std::f64::consts::PI; +use std::sync::Arc; +use std::time::Duration; + +use llimphi_anim::skel::{ + pose_from_render_frame, BoneAnimation, BoneTrack, Mesh, Pose, PoseKey, Skeleton, Vertex, +}; +use llimphi_anim::{Condition, Instance, StateMachine}; +use llimphi_mesh::{fit_transform, paint_textured, paint_wireframe, rest_bounds}; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::kurbo::{Point, Vec2}; +use llimphi_ui::llimphi_raster::peniko::{ + Blob, Color, ImageAlphaType, ImageBrush, ImageData, ImageFormat, +}; +use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, PaintRect, View}; + +const SEG: usize = 8; +const STEP: f64 = 30.0; +const STRIP_H: f64 = 72.0; + +/// Cadena de huesos (raíz anclada + SEG eslabones) + malla-tira atada rigid. +fn build_rig() -> (Skeleton, Mesh) { + let mut s = Skeleton::new(); + s.add_bone(None, Pose::translate(Vec2::new(0.0, STRIP_H / 2.0))); + for _ in 1..=SEG { + s.add_bone(Some(s.len() - 1), Pose::translate(Vec2::new(STEP, 0.0))); + } + s.bind(); + + let mut m = Mesh::new(); + for i in 0..=SEG { + let x = i as f64 * STEP; + let u = i as f64 / SEG as f64; + m.vertices.push(Vertex::rigid(Point::new(x, 0.0), (u, 0.0), i)); + m.vertices.push(Vertex::rigid(Point::new(x, STRIP_H), (u, 1.0), i)); + } + for i in 0..SEG { + let (t0, t1) = ((2 * i) as u32, (2 * (i + 1)) as u32); + let (b0, b1) = ((2 * i + 1) as u32, (2 * (i + 1) + 1) as u32); + m.triangles.push([t0, t1, b1]); + m.triangles.push([t0, b1, b0]); + } + (s, m) +} + +/// Pose de un eslabón: mantiene la translación STEP (estructura de la cadena) y +/// anima sólo la rotación. +fn link_pose(rot: f64) -> Pose { + Pose::new(Vec2::new(STEP, 0.0), rot, Vec2::new(1.0, 1.0)) +} + +/// Clip "recto": todos los eslabones a rotación 0 (un keyframe). +fn straight_anim() -> BoneAnimation { + let tracks = (1..=SEG) + .map(|i| BoneTrack { + bone: i, + keys: vec![PoseKey { t: 0.0, pose: link_pose(0.0) }], + }) + .collect(); + BoneAnimation { duration: 1.0, looping: true, tracks } +} + +/// Clip "onda": cada eslabón keyframeado con una senoidal de fase creciente. +fn wave_anim(amp: f64, period: f64, nkeys: usize) -> BoneAnimation { + let tracks = (1..=SEG) + .map(|i| { + let keys = (0..=nkeys) + .map(|k| { + let t = period * k as f64 / nkeys as f64; + let r = amp * (2.0 * PI * t / period - i as f64 * 0.6).sin(); + PoseKey { t, pose: link_pose(r) } + }) + .collect(); + BoneTrack { bone: i, keys } + }) + .collect(); + BoneAnimation { duration: period, looping: true, tracks } +} + +fn checker(n: u32, sq: u32) -> ImageBrush { + let mut px = Vec::with_capacity((n * n * 4) as usize); + for y in 0..n { + for x in 0..n { + let on = ((x / sq + y / sq) % 2) == 0; + let (r, g, b) = if on { (70, 130, 205) } else { (240, 180, 70) }; + px.extend_from_slice(&[r, g, b, 255]); + } + } + ImageBrush::new(ImageData { + data: Blob::new(Arc::new(px)), + format: ImageFormat::Rgba8, + alpha_type: ImageAlphaType::Alpha, + width: n, + height: n, + }) +} + +#[derive(Clone)] +enum Msg { + Tick, + Toggle, +} + +struct Model { + sm: Instance, + skel: Skeleton, + mesh: Mesh, + clips: Vec, + image: ImageBrush, + active: bool, +} + +struct Demo; + +const TICK: Duration = Duration::from_millis(16); + +impl App for Demo { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · clips → huesos → malla" + } + fn initial_size() -> (u32, u32) { + (560, 420) + } + + fn init(handle: &Handle) -> Self::Model { + let (skel, mesh) = build_rig(); + let clips = vec![straight_anim(), wave_anim(0.42, 2.2, 16)]; + + let mut sm = StateMachine::new(); + let rest = sm.add_state("rest", 0, 1.0, true); + let wave = sm.add_state("wave", 1, 1.0, true); + sm.set_entry(rest); + sm.transition(rest, wave, vec![Condition::bool("active", true)], 0.5); + sm.transition(wave, rest, vec![Condition::bool("active", false)], 0.5); + + handle.spawn_periodic(TICK, || Msg::Tick); + + Model { + sm: sm.instance(), + skel, + mesh, + clips, + image: checker(8, 1), + active: false, + } + } + + fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle) -> Self::Model { + match msg { + Msg::Tick => { + model.sm.advance(TICK.as_secs_f64()); + // El estado/transición de la máquina posa el esqueleto (blend de + // poses durante el crossfade). pose_from_render_frame llama + // skel.update() internamente. + let frame = model.sm.render_frame(); + pose_from_render_frame(&mut model.skel, &frame, &model.clips); + } + Msg::Toggle => { + model.active = !model.active; + model.sm.set_bool("active", model.active); + } + } + model + } + + fn on_key(_: &Self::Model, e: &KeyEvent) -> Option { + if e.state == KeyState::Pressed && e.key == Key::Named(NamedKey::Space) { + Some(Msg::Toggle) + } else { + None + } + } + + fn view(model: &Self::Model) -> View { + let positions = model.mesh.deform(&model.skel); + let mesh = model.mesh.clone(); + let image = model.image.clone(); + + let stage = View::new(Style { + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + flex_grow: 1.0, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + let inner = PaintRect { + x: rect.x + 50.0, + y: rect.y + 50.0, + w: (rect.w - 100.0).max(1.0), + h: (rect.h - 100.0).max(1.0), + }; + let xf = fit_transform(rest_bounds(&mesh), inner); + paint_textured(scene, &mesh, &positions, xf, &image); + paint_wireframe(scene, &mesh, &positions, xf, Color::from_rgba8(255, 255, 255, 110), 1.2); + }); + + let label = if model.sm.is_transitioning() { + "· · · blend de poses · · ·".to_string() + } else { + format!("estado: {} (Espacio: rest ⇄ wave)", model.sm.current_state()) + }; + let status = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(30.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .text(label, 14.0, Color::from_rgba8(150, 165, 190, 255)); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(8.0_f32), + bottom: length(8.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(18, 22, 30, 255)) + .children(vec![stage, status]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-mesh/examples/lottie_rive_demo.rs b/llimphi-mesh/examples/lottie_rive_demo.rs new file mode 100644 index 0000000..05974d6 --- /dev/null +++ b/llimphi-mesh/examples/lottie_rive_demo.rs @@ -0,0 +1,241 @@ +//! Demo **lottie + rive** en una sola app: los dos paradigmas de animación del +//! motor nativo trabajando juntos. +//! +//! - **rive (esqueletal + IK)**: un brazo de 2 huesos con una malla texturizada +//! skinneada, que **persigue el cursor** resolviendo IK de 2 huesos cada frame +//! (`llimphi_anim::constraint::solve_two_bone_ik`). +//! - **lottie**: un pulso vectorial (`llimphi-lottie` sobre el fork de velato) +//! reproducido en la **punta del brazo** y otro marcando el **objetivo** bajo +//! el cursor. +//! +//! Mové el mouse: el brazo alcanza el cursor (IK), el Lottie late en la punta y +//! en el blanco. **F** alterna el codo (flip de la solución IK). Todo Rust +//! nativo sobre vello 0.7, cero C++. +//! +//! Corre con: +//! `cargo run -p llimphi-mesh --example lottie_rive_demo --release` + +use std::sync::Arc; +use std::time::Duration; + +use llimphi_anim::constraint::solve_two_bone_ik; +use llimphi_anim::skel::{BoneId, Mesh, Pose, Skeleton, Vertex, Weight}; +use llimphi_lottie::LottieAsset; +use llimphi_mesh::{paint_textured, paint_wireframe}; +use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, Vec2}; +use llimphi_ui::llimphi_raster::peniko::{ + Blob, Color, ImageAlphaType, ImageBrush, ImageData, ImageFormat, +}; +use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, PaintRect, View}; + +// Brazo en coordenadas locales del lienzo (px). Ancla a la izquierda-centro. +const ANCHOR: (f64, f64) = (110.0, 200.0); +const L1: f64 = 95.0; // hueso superior +const L2: f64 = 95.0; // hueso inferior +const ARM_H: f64 = 40.0; // grosor del brazo +const COLS: usize = 6; // columnas de la malla a lo largo del brazo + +/// Pulso azul: círculo cuya opacidad late en 1.5 s. +const PULSE_LOTTIE: &str = r#"{ + "v":"5.5.2","fr":30,"ip":0,"op":45,"w":100,"h":100, + "layers":[{"ty":4,"ip":0,"op":45,"st":0,"sr":1, + "ks":{"o":{"a":1,"k":[ + {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[100]}, + {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":22,"s":[25]}, + {"t":45,"s":[100]}]}, + "r":{"a":0,"k":0},"p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}}, + "shapes":[{"ty":"gr","it":[ + {"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[60,60]}}, + {"ty":"fl","c":{"a":0,"k":[0.45,0.75,1.0]},"o":{"a":0,"k":100}}, + {"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}]}]}"#; + +/// Brazo de 2 huesos + malla-tira skinneada (peso suave alrededor del codo). +fn build_arm() -> (Skeleton, Mesh, BoneId, BoneId) { + let mut s = Skeleton::new(); + let a = s.add_bone(None, Pose::translate(Vec2::new(ANCHOR.0, ANCHOR.1))); + let b = s.add_bone(Some(a), Pose::translate(Vec2::new(L1, 0.0))); + s.bind(); + s.update(); + + let total = L1 + L2; + let blend = total * 0.16; // ancho del blend de peso en el codo + let mut m = Mesh::new(); + for i in 0..=COLS { + let p = total * i as f64 / COLS as f64; // posición a lo largo del brazo + // Peso suave: 1→A antes del codo, 0→A (todo B) después. + let wa = (1.0 - (p - (L1 - blend)) / (2.0 * blend)).clamp(0.0, 1.0); + let weights = vec![ + Weight { bone: a, weight: wa }, + Weight { bone: b, weight: 1.0 - wa }, + ]; + let u = i as f64 / COLS as f64; + let x = ANCHOR.0 + p; + m.vertices.push(Vertex { + rest: Point::new(x, ANCHOR.1 - ARM_H / 2.0), + uv: (u, 0.0), + weights: weights.clone(), + }); + m.vertices.push(Vertex { + rest: Point::new(x, ANCHOR.1 + ARM_H / 2.0), + uv: (u, 1.0), + weights, + }); + } + for i in 0..COLS { + let (t0, t1) = ((2 * i) as u32, (2 * (i + 1)) as u32); + let (b0, b1) = ((2 * i + 1) as u32, (2 * (i + 1) + 1) as u32); + m.triangles.push([t0, t1, b1]); + m.triangles.push([t0, b1, b0]); + } + (s, m, a, b) +} + +fn checker(n: u32, sq: u32) -> ImageBrush { + let mut px = Vec::with_capacity((n * n * 4) as usize); + for y in 0..n { + for x in 0..n { + let on = ((x / sq + y / sq) % 2) == 0; + let (r, g, b) = if on { (235, 110, 90) } else { (250, 200, 120) }; + px.extend_from_slice(&[r, g, b, 255]); + } + } + ImageBrush::new(ImageData { + data: Blob::new(Arc::new(px)), + format: ImageFormat::Rgba8, + alpha_type: ImageAlphaType::Alpha, + width: n, + height: n, + }) +} + +#[derive(Clone)] +enum Msg { + Tick, + Target(f64, f64), + Flip, +} + +struct Model { + skel: Skeleton, + mesh: Mesh, + upper: BoneId, + lower: BoneId, + image: ImageBrush, + pulse: LottieAsset, + target: Point, + t: f64, + flip: bool, +} + +struct Demo; + +const TICK: Duration = Duration::from_millis(16); +const TIP_LOCAL: Vec2 = Vec2::new(L2, 0.0); + +impl App for Demo { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · lottie + rive" + } + fn initial_size() -> (u32, u32) { + (640, 420) + } + + fn init(handle: &Handle) -> Self::Model { + let (skel, mesh, upper, lower) = build_arm(); + handle.spawn_periodic(TICK, || Msg::Tick); + Model { + skel, + mesh, + upper, + lower, + image: checker(8, 1), + pulse: LottieAsset::from_str(PULSE_LOTTIE).expect("pulse lottie"), + target: Point::new(360.0, 150.0), + t: 0.0, + flip: false, + } + } + + fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle) -> Self::Model { + match msg { + Msg::Tick => { + model.t += TICK.as_secs_f64(); + // IK cada frame: el brazo alcanza el objetivo (cursor). + solve_two_bone_ik( + &mut model.skel, + model.upper, + model.lower, + TIP_LOCAL, + model.target, + model.flip, + ); + } + Msg::Target(x, y) => model.target = Point::new(x, y), + Msg::Flip => model.flip = !model.flip, + } + model + } + + fn on_key(_: &Self::Model, e: &KeyEvent) -> Option { + if e.state == KeyState::Pressed { + if let Key::Character(c) = &e.key { + if c.as_str() == "f" { + return Some(Msg::Flip); + } + } + } + None + } + + fn view(model: &Self::Model) -> View { + let positions = model.mesh.deform(&model.skel); + // Punta del brazo (mundo local) = world(lower) · tip_local. + let tip_local = model.skel.world(model.lower) * Point::new(TIP_LOCAL.x, TIP_LOCAL.y); + let mesh = model.mesh.clone(); + let image = model.image.clone(); + let pulse = model.pulse.clone(); + let target = model.target; + let t = model.t; + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(18, 22, 30, 255)) + .paint_with(move |scene, _ts, rect| { + // Coords locales → pantalla (el lienzo arranca en rect.x/rect.y). + let to_screen = Affine::translate((rect.x as f64, rect.y as f64)); + // Brazo skinneado (rive). + paint_textured(scene, &mesh, &positions, to_screen, &image); + paint_wireframe( + scene, + &mesh, + &positions, + to_screen, + Color::from_rgba8(255, 255, 255, 90), + 1.0, + ); + // Pulso lottie en la punta del brazo y en el objetivo. + let pulse_rect = |p: Point, s: f64| PaintRect { + x: (rect.x as f64 + p.x - s) as f32, + y: (rect.y as f64 + p.y - s) as f32, + w: (s * 2.0) as f32, + h: (s * 2.0) as f32, + }; + pulse.paint_at_time(scene, pulse_rect(target, 30.0), t); + pulse.paint_at_time(scene, pulse_rect(tip_local, 22.0), t); + }) + .on_pointer_move_at(|lx, ly, _w, _h| Some(Msg::Target(lx as f64, ly as f64))) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-mesh/examples/physics_skeletons_demo.rs b/llimphi-mesh/examples/physics_skeletons_demo.rs new file mode 100644 index 0000000..7f2cacd --- /dev/null +++ b/llimphi-mesh/examples/physics_skeletons_demo.rs @@ -0,0 +1,211 @@ +//! Demo **chévere**: leyes físicas sobre varios esqueletos. +//! +//! Cinco tentáculos: cada uno es una cadena de huesos con una malla texturizada +//! skinneada, manejada por **física Verlet** (`llimphi_anim::physics`) — cuelgan +//! del techo, se balancean bajo gravedad y rebotan en el piso. **Mové el mouse** +//! y el cursor los **empuja** (campo de repulsión). Cero keyframes: la física +//! posa los esqueletos, el skinning deforma las mallas. +//! +//! Todo Rust nativo sobre vello 0.7. Pensado también como fondo vivo de +//! lock/greeter. +//! +//! Corre con: +//! `cargo run -p llimphi-mesh --example physics_skeletons_demo --release` + +use std::sync::Arc; +use std::time::Duration; + +use llimphi_anim::physics::{pose_chain_from_points, Physics}; +use llimphi_anim::skel::{BoneId, Mesh, Pose, Skeleton, Vertex}; +use llimphi_mesh::paint_textured; +use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, Vec2}; +use llimphi_ui::llimphi_raster::peniko::{ + Blob, Color, ImageAlphaType, ImageBrush, ImageData, ImageFormat, +}; +use llimphi_ui::{App, Handle, PaintRect, View}; + +const SEGS: usize = 9; +const SEG_LEN: f64 = 26.0; +const THICK: f64 = 22.0; +const FLOOR_Y: f64 = 372.0; + +/// Un tentáculo: física (partículas en cadena) + esqueleto + malla skinneada. +struct Rope { + phys: Physics, + skel: Skeleton, + mesh: Mesh, + bones: Vec, + image: ImageBrush, +} + +fn checker(a: (u8, u8, u8), b: (u8, u8, u8)) -> ImageBrush { + let n = 8u32; + let mut px = Vec::with_capacity((n * n * 4) as usize); + for y in 0..n { + for x in 0..n { + let (r, g, bl) = if (x + y) % 2 == 0 { a } else { b }; + px.extend_from_slice(&[r, g, bl, 255]); + } + } + ImageBrush::new(ImageData { + data: Blob::new(Arc::new(px)), + format: ImageFormat::Rgba8, + alpha_type: ImageAlphaType::Alpha, + width: n, + height: n, + }) +} + +fn make_rope(anchor: Point, image: ImageBrush) -> Rope { + // Física: partículas desde el ancla (fija) hacia abajo. + let mut phys = Physics::new(); + phys.floor_y = Some(FLOOR_Y); + let mut prev = phys.particle(anchor, true); + for i in 1..=SEGS { + let p = phys.particle(Point::new(anchor.x, anchor.y + i as f64 * SEG_LEN), false); + phys.link(prev, p); + prev = p; + } + + // Esqueleto en bind pose recto desde el ORIGEN (a lo largo de +x); la física + // lo reubica/orienta cada frame con pose_chain_from_points. + let mut skel = Skeleton::new(); + let mut bones = vec![skel.add_bone(None, Pose::identity())]; + for _ in 1..=SEGS { + bones.push(skel.add_bone(Some(*bones.last().unwrap()), Pose::translate(Vec2::new(SEG_LEN, 0.0)))); + } + skel.bind(); + + // Malla-tira en reposo (recta desde el origen), rigid por columna al hueso i. + let mut mesh = Mesh::new(); + for i in 0..=SEGS { + let x = i as f64 * SEG_LEN; + let u = i as f64 / SEGS as f64; + // La punta se afina (tentáculo). + let half = THICK * 0.5 * (1.0 - 0.6 * (i as f64 / SEGS as f64)); + mesh.vertices.push(Vertex::rigid(Point::new(x, -half), (u, 0.0), bones[i])); + mesh.vertices.push(Vertex::rigid(Point::new(x, half), (u, 1.0), bones[i])); + } + for i in 0..SEGS { + let (t0, t1) = ((2 * i) as u32, (2 * (i + 1)) as u32); + let (b0, b1) = ((2 * i + 1) as u32, (2 * (i + 1) + 1) as u32); + mesh.triangles.push([t0, t1, b1]); + mesh.triangles.push([t0, b1, b0]); + } + + Rope { phys, skel, mesh, bones, image } +} + +#[derive(Clone)] +enum Msg { + Tick, + Cursor(Option<(f64, f64)>), +} + +struct Model { + ropes: Vec, + cursor: Option, +} + +struct Demo; + +const TICK: Duration = Duration::from_millis(16); + +impl App for Demo { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · física sobre esqueletos" + } + fn initial_size() -> (u32, u32) { + (700, 440) + } + + fn init(handle: &Handle) -> Self::Model { + let palette = [ + ((90, 180, 230), (40, 90, 140)), + ((235, 130, 100), (150, 60, 50)), + ((130, 220, 150), (50, 120, 70)), + ((220, 180, 90), (140, 100, 40)), + ((200, 130, 220), (110, 60, 140)), + ]; + let ropes = palette + .iter() + .enumerate() + .map(|(i, (a, b))| { + let x = 110.0 + i as f64 * 120.0; + make_rope(Point::new(x, 40.0), checker(*a, *b)) + }) + .collect(); + handle.spawn_periodic(TICK, || Msg::Tick); + Model { ropes, cursor: None } + } + + fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle) -> Self::Model { + match msg { + Msg::Tick => { + let dt = TICK.as_secs_f64(); + let cursor = model.cursor; + for rope in &mut model.ropes { + rope.phys.step(dt, 8); + if let Some(c) = cursor { + rope.phys.repel(c, 90.0, 28.0); + } + let pts = rope.phys.positions(); + pose_chain_from_points(&mut rope.skel, &rope.bones, &pts); + } + } + Msg::Cursor(p) => model.cursor = p.map(|(x, y)| Point::new(x, y)), + } + model + } + + fn view(model: &Self::Model) -> View { + // Deformar todas las mallas ahora; mover los datos al closure. + let painted: Vec<(Mesh, Vec, ImageBrush)> = model + .ropes + .iter() + .map(|r| (r.mesh.clone(), r.mesh.deform(&r.skel), r.image.clone())) + .collect(); + + View::new(Style { + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .fill(Color::from_rgba8(16, 18, 26, 255)) + .paint_with(move |scene, _ts, rect| { + let to_screen = Affine::translate((rect.x as f64, rect.y as f64)); + // Piso. + let floor = PaintRect { + x: rect.x, + y: rect.y + FLOOR_Y as f32, + w: rect.w, + h: (rect.h - FLOOR_Y as f32).max(0.0), + }; + let mut fp = llimphi_ui::llimphi_raster::kurbo::BezPath::new(); + fp.move_to((floor.x as f64, floor.y as f64)); + fp.line_to((floor.x as f64 + floor.w as f64, floor.y as f64)); + fp.line_to((floor.x as f64 + floor.w as f64, floor.y as f64 + floor.h as f64)); + fp.line_to((floor.x as f64, floor.y as f64 + floor.h as f64)); + fp.close_path(); + scene.fill( + llimphi_ui::llimphi_raster::peniko::Fill::NonZero, + Affine::IDENTITY, + &llimphi_ui::llimphi_raster::peniko::Brush::Solid(Color::from_rgba8(26, 28, 38, 255)), + None, + &fp, + ); + for (mesh, positions, image) in &painted { + paint_textured(scene, mesh, positions, to_screen, image); + } + }) + .on_pointer_move_at(|lx, ly, _w, _h| Some(Msg::Cursor(Some((lx as f64, ly as f64))))) + .on_pointer_leave(Msg::Cursor(None)) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-mesh/src/lib.rs b/llimphi-mesh/src/lib.rs new file mode 100644 index 0000000..656224e --- /dev/null +++ b/llimphi-mesh/src/lib.rs @@ -0,0 +1,297 @@ +//! `llimphi-mesh` — render de mallas deformables a una `vello::Scene`. +//! +//! La matemática de huesos y skinning vive en [`llimphi_anim::skel`]; este crate +//! sólo **pinta** la malla ya deformada. Recibe la malla ([`Mesh`]) + las +//! posiciones deformadas de sus vértices (lo que devuelve [`Mesh::deform`]) y un +//! `xform` model→pantalla, y ofrece las dos rutas que el spike validó contra +//! vello 0.7: +//! +//! - [`paint_solid`] — rellena cada triángulo deformado con un color (malla +//! vectorial / debug). Trivial. +//! - [`paint_textured`] — malla **texturizada**: por triángulo, recorta al +//! triángulo deformado y dibuja la imagen con el afín que mapea sus UV a la +//! posición deformada (warp piecewise-affine). Costo = un clip-layer por +//! triángulo; cuidado con mallas de miles de triángulos. +//! - [`paint_wireframe`] — traza los bordes de los triángulos (ver la +//! deformación; ideal para demos/debug). +//! +//! Helpers de encuadre: [`rest_bounds`] (bbox de la malla en reposo) + +//! [`fit_transform`] (afín que encaja esos bounds, centrados, en un `PaintRect`). + +#![forbid(unsafe_code)] + +use llimphi_anim::skel::Mesh; +use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Point, Rect, Stroke}; +use llimphi_ui::llimphi_raster::peniko::{Brush, Color, Fill, ImageBrush}; +use llimphi_ui::llimphi_raster::vello::Scene; +use llimphi_ui::PaintRect; + +/// Afín que mapea el triángulo `src` al triángulo `dst` (3 correspondencias). +/// Es la base del warp piecewise-affine de la malla texturizada. Devuelve la +/// identidad si `src` es degenerado (área nula). +pub fn affine_from_tri(src: [Point; 3], dst: [Point; 3]) -> Affine { + let e1 = (src[1].x - src[0].x, src[1].y - src[0].y); + let e2 = (src[2].x - src[0].x, src[2].y - src[0].y); + let f1 = (dst[1].x - dst[0].x, dst[1].y - dst[0].y); + let f2 = (dst[2].x - dst[0].x, dst[2].y - dst[0].y); + let det = e1.0 * e2.1 - e2.0 * e1.1; + if det.abs() < 1e-9 { + return Affine::IDENTITY; + } + let inv = 1.0 / det; + let l00 = (f1.0 * e2.1 - f2.0 * e1.1) * inv; + let l01 = (-f1.0 * e2.0 + f2.0 * e1.0) * inv; + let l10 = (f1.1 * e2.1 - f2.1 * e1.1) * inv; + let l11 = (-f1.1 * e2.0 + f2.1 * e1.0) * inv; + let tx = dst[0].x - (l00 * src[0].x + l01 * src[0].y); + let ty = dst[0].y - (l10 * src[0].x + l11 * src[0].y); + Affine::new([l00, l10, l01, l11, tx, ty]) +} + +/// Bounding box de las posiciones de **reposo** de la malla. Útil para +/// `fit_transform`. Vacío (`Rect::ZERO`) si la malla no tiene vértices. +pub fn rest_bounds(mesh: &Mesh) -> Rect { + let mut it = mesh.vertices.iter(); + let Some(first) = it.next() else { + return Rect::ZERO; + }; + let (mut x0, mut y0, mut x1, mut y1) = (first.rest.x, first.rest.y, first.rest.x, first.rest.y); + for v in it { + x0 = x0.min(v.rest.x); + y0 = y0.min(v.rest.y); + x1 = x1.max(v.rest.x); + y1 = y1.max(v.rest.y); + } + Rect::new(x0, y0, x1, y1) +} + +/// Afín que encaja `bounds` (espacio de la malla) dentro de `rect` (pantalla), +/// escalando uniforme al mínimo lado y centrando (preserva aspecto). +pub fn fit_transform(bounds: Rect, rect: PaintRect) -> Affine { + let bw = bounds.width(); + let bh = bounds.height(); + if bw <= 0.0 || bh <= 0.0 || rect.w <= 0.0 || rect.h <= 0.0 { + return Affine::IDENTITY; + } + let s = (rect.w as f64 / bw).min(rect.h as f64 / bh); + let used_w = bw * s; + let used_h = bh * s; + let tx = rect.x as f64 + (rect.w as f64 - used_w) * 0.5 - bounds.x0 * s; + let ty = rect.y as f64 + (rect.h as f64 - used_h) * 0.5 - bounds.y0 * s; + Affine::translate((tx, ty)) * Affine::scale(s) +} + +/// Posiciones deformadas (model space) → pantalla, en un buffer reusable. +fn to_screen(positions: &[Point], xform: Affine, out: &mut Vec) { + out.clear(); + out.extend(positions.iter().map(|p| xform * *p)); +} + +fn tri_path(a: Point, b: Point, c: Point) -> BezPath { + let mut p = BezPath::new(); + p.move_to(a); + p.line_to(b); + p.line_to(c); + p.close_path(); + p +} + +/// Rellena cada triángulo deformado con un color sólido. `positions` son las +/// posiciones deformadas (de `Mesh::deform`), `xform` las lleva a pantalla. +pub fn paint_solid( + scene: &mut Scene, + mesh: &Mesh, + positions: &[Point], + xform: Affine, + color: Color, +) { + let mut screen = Vec::new(); + to_screen(positions, xform, &mut screen); + let brush = Brush::Solid(color); + for t in &mesh.triangles { + let (Some(&a), Some(&b), Some(&c)) = ( + screen.get(t[0] as usize), + screen.get(t[1] as usize), + screen.get(t[2] as usize), + ) else { + continue; + }; + scene.fill(Fill::NonZero, Affine::IDENTITY, &brush, None, &tri_path(a, b, c)); + } +} + +/// Malla texturizada: por triángulo, recorta al triángulo deformado y dibuja la +/// imagen con el afín que mapea las UV (espacio de imagen) a la posición +/// deformada en pantalla — warp piecewise-affine. `image` es la textura; las UV +/// de cada vértice (`0..1`) indexan en ella. +pub fn paint_textured( + scene: &mut Scene, + mesh: &Mesh, + positions: &[Point], + xform: Affine, + image: &ImageBrush, +) { + let mut screen = Vec::new(); + to_screen(positions, xform, &mut screen); + let iw = image.image.width as f64; + let ih = image.image.height as f64; + for t in &mesh.triangles { + let idx = [t[0] as usize, t[1] as usize, t[2] as usize]; + let (Some(&da), Some(&db), Some(&dc)) = + (screen.get(idx[0]), screen.get(idx[1]), screen.get(idx[2])) + else { + continue; + }; + // src = UV·tamaño-de-imagen (espacio de imagen); dst = pantalla deformada. + let uv = |i: usize| { + let v = &mesh.vertices[idx[i]]; + Point::new(v.uv.0 * iw, v.uv.1 * ih) + }; + let aff = affine_from_tri([uv(0), uv(1), uv(2)], [da, db, dc]); + scene.push_clip_layer(Fill::NonZero, Affine::IDENTITY, &tri_path(da, db, dc)); + scene.draw_image(image.as_ref(), aff); + scene.pop_layer(); + } +} + +/// Traza los bordes de los triángulos (para ver la deformación / debug). +pub fn paint_wireframe( + scene: &mut Scene, + mesh: &Mesh, + positions: &[Point], + xform: Affine, + color: Color, + width: f64, +) { + let mut screen = Vec::new(); + to_screen(positions, xform, &mut screen); + let brush = Brush::Solid(color); + let stroke = Stroke::new(width); + for t in &mesh.triangles { + let (Some(&a), Some(&b), Some(&c)) = ( + screen.get(t[0] as usize), + screen.get(t[1] as usize), + screen.get(t[2] as usize), + ) else { + continue; + }; + scene.stroke(&stroke, Affine::IDENTITY, &brush, None, &tri_path(a, b, c)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use llimphi_anim::skel::{Mesh, Pose, Skeleton, Vertex}; + use llimphi_ui::llimphi_raster::peniko::{Blob, ImageAlphaType, ImageData, ImageFormat}; + use std::sync::Arc; + + /// Una malla quad (2 triángulos) atada a un hueso, con UVs de esquina. + fn quad_mesh(bone: usize) -> Mesh { + let mut m = Mesh::new(); + m.vertices = vec![ + Vertex::rigid(Point::new(0.0, 0.0), (0.0, 0.0), bone), + Vertex::rigid(Point::new(100.0, 0.0), (1.0, 0.0), bone), + Vertex::rigid(Point::new(100.0, 100.0), (1.0, 1.0), bone), + Vertex::rigid(Point::new(0.0, 100.0), (0.0, 1.0), bone), + ]; + m.triangles = vec![[0, 1, 2], [0, 2, 3]]; + m + } + + fn skel_one_bone() -> (Skeleton, usize) { + let mut s = Skeleton::new(); + let b = s.add_bone(None, Pose::identity()); + s.bind(); + s.update(); + (s, b) + } + + fn img_2x2() -> ImageBrush { + let px: Vec = vec![ + 200, 60, 60, 255, 60, 200, 60, 255, 60, 60, 200, 255, 220, 200, 60, 255, + ]; + ImageBrush::new(ImageData { + data: Blob::new(Arc::new(px)), + format: ImageFormat::Rgba8, + alpha_type: ImageAlphaType::Alpha, + width: 2, + height: 2, + }) + } + + const RECT: PaintRect = PaintRect { + x: 0.0, + y: 0.0, + w: 200.0, + h: 200.0, + }; + + #[test] + fn fit_transform_centra_y_escala() { + let m = quad_mesh(0); + let xf = fit_transform(rest_bounds(&m), RECT); + // El quad 100×100 en un rect 200×200 escala ×2 y centra (sin offset, ya + // que llena el lado). Esquina (0,0) → (0,0), (100,100) → (200,200). + let p0 = xf * Point::new(0.0, 0.0); + let p1 = xf * Point::new(100.0, 100.0); + assert!((p0.x).abs() < 1e-9 && (p0.y).abs() < 1e-9, "{p0:?}"); + assert!((p1.x - 200.0).abs() < 1e-9 && (p1.y - 200.0).abs() < 1e-9, "{p1:?}"); + } + + #[test] + fn paint_solid_emite_geometria() { + let (s, b) = skel_one_bone(); + let m = quad_mesh(b); + let pos = m.deform(&s); + let xf = fit_transform(rest_bounds(&m), RECT); + let mut sc = Scene::new(); + paint_solid(&mut sc, &m, &pos, xf, Color::from_rgba8(200, 100, 50, 255)); + assert!(!sc.encoding().is_empty()); + } + + #[test] + fn paint_textured_emite_geometria() { + let (s, b) = skel_one_bone(); + let m = quad_mesh(b); + let pos = m.deform(&s); + let xf = fit_transform(rest_bounds(&m), RECT); + let img = img_2x2(); + let mut sc = Scene::new(); + paint_textured(&mut sc, &m, &pos, xf, &img); + assert!(!sc.encoding().is_empty(), "la malla texturizada debe emitir geometría"); + } + + #[test] + fn paint_wireframe_emite_geometria() { + let (s, b) = skel_one_bone(); + let m = quad_mesh(b); + let pos = m.deform(&s); + let xf = fit_transform(rest_bounds(&m), RECT); + let mut sc = Scene::new(); + paint_wireframe(&mut sc, &m, &pos, xf, Color::from_rgba8(255, 255, 255, 255), 1.5); + assert!(!sc.encoding().is_empty()); + } + + /// Deformar un hueso mueve la geometría pintada: las posiciones deformadas + /// cambian respecto al reposo (certifica que el render usa la deformación). + #[test] + fn deformar_cambia_las_posiciones() { + let mut s = Skeleton::new(); + let b = s.add_bone(None, Pose::identity()); + s.bind(); + s.update(); + let m = quad_mesh(b); + let reposo = m.deform(&s); + + s.set_pose(b, Pose::rotate(0.5)); + s.update(); + let deformado = m.deform(&s); + + let movido = reposo + .iter() + .zip(&deformado) + .any(|(a, d)| (a.x - d.x).abs() > 1e-6 || (a.y - d.y).abs() > 1e-6); + assert!(movido, "rotar el hueso debe mover los vértices"); + } +} diff --git a/llimphi-motion/Cargo.toml b/llimphi-motion/Cargo.toml index 3483cba..a580f72 100644 --- a/llimphi-motion/Cargo.toml +++ b/llimphi-motion/Cargo.toml @@ -5,9 +5,9 @@ edition.workspace = true license.workspace = true authors.workspace = true publish.workspace = true -repository.workspace = true description = "llimphi-motion — Tween + helpers de animación integrados al bucle Elm de llimphi-ui (Handle::spawn_periodic). Lerp para f32, Color, (f32,f32). Easings comparten convenciones de llimphi-theme::motion." +repository.workspace = true [dependencies] llimphi-ui = { workspace = true } llimphi-theme = { workspace = true } diff --git a/llimphi-motion/LEEME.md b/llimphi-motion/LEEME.md new file mode 100644 index 0000000..14c9e23 --- /dev/null +++ b/llimphi-motion/LEEME.md @@ -0,0 +1,49 @@ +# llimphi-motion + +*Read this in English: [README.md](README.md).* + +Animaciones simples sobre el bucle Elm de Llimphi. + +Llimphi es Elm puro: `update(msg) -> model`. Para animar un valor en +el tiempo (un alpha que sube de 0 a 1, una posición que se desliza) +la app guarda un `Tween` en su modelo y pide al `Handle` que le +dispatchee un `Msg::Tick` periódicamente (cada ~16 ms) hasta que la +animación termine. Cada `update` lee `tween.value()` y la `view` la +pinta. + +Esta crate es deliberadamente chiquita: +- `Lerp` — interpolación lineal genérica (impls para `f32`, + `(f32, f32)` y `Color`). +- `Tween` — interpolación temporizada con easing entre dos valores. +- `animate` — helper que arranca un loop de ticks autosuficiente + sobre un `Handle`. + +Las duraciones y easings canónicos viven en `llimphi_theme::motion`. + +## Patrón típico +```ignore +use llimphi_motion::{Tween, animate}; +use llimphi_theme::motion; + +enum Msg { ToastShow, Tick, ToastHidden } +struct Model { toast_alpha: Tween } + +// update: +Msg::ToastShow => { + model.toast_alpha = Tween::new(0.0, 1.0, motion::NORMAL, motion::ease_out_cubic); + animate(handle, motion::NORMAL, || Msg::Tick); + model +} +Msg::Tick => { + // El loop interno terminará solo cuando el tween esté done; + // la `view` ya lee el alpha actual sin más. + model +} + +// view: +toast_view().alpha(model.toast_alpha.value()) +``` + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-motion/README.md b/llimphi-motion/README.md new file mode 100644 index 0000000..2d69eea --- /dev/null +++ b/llimphi-motion/README.md @@ -0,0 +1,47 @@ +# llimphi-motion + +Simple animation over Llimphi's Elm loop. + +Llimphi is pure Elm: `update(msg) -> model`. To animate a value over time (an +alpha rising from 0 to 1, a position sliding) the app keeps a `Tween` in its +model and asks the `Handle` to dispatch a `Msg::Tick` periodically (every ~16 ms) +until the animation ends. Each `update` reads `tween.value()` and the `view` +paints it. + +This crate is deliberately tiny: + +- `Lerp` — generic linear interpolation (impls for `f32`, `(f32, f32)` and + `Color`). +- `Tween` — timed interpolation with easing between two values. +- `animate` — a helper that starts a self-sufficient tick loop over a `Handle`. + +The canonical durations and easings live in `llimphi_theme::motion`. + +## Typical pattern + +```ignore +use llimphi_motion::{Tween, animate}; +use llimphi_theme::motion; + +enum Msg { ToastShow, Tick, ToastHidden } +struct Model { toast_alpha: Tween } + +// update: +Msg::ToastShow => { + model.toast_alpha = Tween::new(0.0, 1.0, motion::NORMAL, motion::ease_out_cubic); + animate(handle, motion::NORMAL, || Msg::Tick); + model +} +Msg::Tick => { + // The internal loop ends by itself once the tween is done; + // the `view` already reads the current alpha. + model +} + +// view: +toast_view().alpha(model.toast_alpha.value()) +``` + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-raster/Cargo.toml b/llimphi-raster/Cargo.toml index cd5450e..b42e907 100644 --- a/llimphi-raster/Cargo.toml +++ b/llimphi-raster/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "llimphi-raster" -description = "2D GPU rasterizer for llimphi over vello, with an opt-in CPU+GPU hybrid renderer." version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true publish.workspace = true -repository.workspace = true +description = "2D GPU rasterizer for llimphi over vello, with an opt-in CPU+GPU hybrid renderer." +repository.workspace = true [dependencies] llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" } vello = { workspace = true } diff --git a/llimphi-raster/src/gpu.rs b/llimphi-raster/src/gpu.rs index bad0b8c..837caf7 100644 --- a/llimphi-raster/src/gpu.rs +++ b/llimphi-raster/src/gpu.rs @@ -7,10 +7,13 @@ //! //! Diseño minimal Fase 2/3: //! -//! - Vertex format triángulos: `[x: f32, y: f32, rgba: u32]` (12 B/vert). -//! - Instance format líneas: `[x0, y0, x1, y1, rgba]` (20 B/seg). -//! - Instance format rects: `[x, y, w, h, rgba]` (20 B/rect). -//! - Instance format discos: `[cx, cy, r, stroke, rgba]` (20 B/disco). +//! - Vertex format triángulos: `[x, y: f32, color: 4×f32]` (24 B/vert). +//! - Instance format líneas: `[x0, y0, x1, y1, color: 4×f32]` (32 B/seg). +//! - Instance format rects: `[x, y, w, h, color: 4×f32]` (32 B/rect). +//! - Instance format discos: `[cx, cy, r, stroke, color: 4×f32]` (32 B/disco). +//! - El color va como **4×f32** (no u32 empaquetado): preserva precisión HDR +//! (>1.0) y sub-8-bit cuando el target es `Rgba16Float`/`Rgb10a2`; en target +//! 8-bit el resultado visible es idéntico al pack anterior. //! - Sin texturas. Rects/líneas/tris obtienen AA de **bordes** vía MSAA 4× //! (ver más abajo); los discos SÍ traen AA por SDF en el fragment //! (smoothstep sobre `fwidth`), que MSAA respeta. Así rects/tris/líneas @@ -66,10 +69,10 @@ const MSAA_SAMPLES: u32 = 4; /// particles iniciales, viewport estático) y emitir draw calls /// manualmente reusando estas pipelines. /// -/// Layouts: -/// - Vertex buffer triángulos: `[x: f32, y: f32, rgba: u32]` (12 B/vert). -/// - Instance buffer rects: `[x, y, w, h, rgba]` (20 B/inst). -/// - Instance buffer líneas: `[x0, y0, x1, y1, rgba]` (20 B/inst). +/// Layouts (color = 4×f32, HDR-capable): +/// - Vertex buffer triángulos: `[x, y, color:4×f32]` (24 B/vert). +/// - Instance buffer rects: `[x, y, w, h, color:4×f32]` (32 B/inst). +/// - Instance buffer líneas: `[x0, y0, x1, y1, color:4×f32]`(32 B/inst). /// - Bind group 0 binding 0: uniform `{viewport: vec2, line_width: f32, _pad: f32}` (16 B). pub struct GpuPipelines { pub lines: wgpu::RenderPipeline, @@ -137,7 +140,7 @@ impl GpuPipelines { entry_point: Some("vs_tris"), compilation_options: Default::default(), buffers: &[wgpu::VertexBufferLayout { - array_stride: 12, + array_stride: 24, step_mode: wgpu::VertexStepMode::Vertex, attributes: &[ wgpu::VertexAttribute { @@ -146,7 +149,7 @@ impl GpuPipelines { shader_location: 0, }, wgpu::VertexAttribute { - format: wgpu::VertexFormat::Uint32, + format: wgpu::VertexFormat::Float32x4, offset: 8, shader_location: 1, }, @@ -178,7 +181,7 @@ impl GpuPipelines { entry_point: Some("vs_rects"), compilation_options: Default::default(), buffers: &[wgpu::VertexBufferLayout { - array_stride: 20, + array_stride: 32, step_mode: wgpu::VertexStepMode::Instance, attributes: &[ wgpu::VertexAttribute { @@ -192,7 +195,7 @@ impl GpuPipelines { shader_location: 1, }, wgpu::VertexAttribute { - format: wgpu::VertexFormat::Uint32, + format: wgpu::VertexFormat::Float32x4, offset: 16, shader_location: 2, }, @@ -226,7 +229,7 @@ impl GpuPipelines { entry_point: Some("vs_lines"), compilation_options: Default::default(), buffers: &[wgpu::VertexBufferLayout { - array_stride: 20, + array_stride: 32, step_mode: wgpu::VertexStepMode::Instance, attributes: &[ wgpu::VertexAttribute { @@ -235,7 +238,7 @@ impl GpuPipelines { shader_location: 0, }, wgpu::VertexAttribute { - format: wgpu::VertexFormat::Uint32, + format: wgpu::VertexFormat::Float32x4, offset: 16, shader_location: 1, }, @@ -272,7 +275,7 @@ impl GpuPipelines { entry_point: Some("vs_discs"), compilation_options: Default::default(), buffers: &[wgpu::VertexBufferLayout { - array_stride: 20, + array_stride: 32, step_mode: wgpu::VertexStepMode::Instance, attributes: &[ // cx, cy @@ -287,9 +290,9 @@ impl GpuPipelines { offset: 8, shader_location: 1, }, - // rgba + // rgba (4×f32, HDR-capable) wgpu::VertexAttribute { - format: wgpu::VertexFormat::Uint32, + format: wgpu::VertexFormat::Float32x4, offset: 16, shader_location: 2, }, @@ -422,6 +425,11 @@ pub struct GpuBatch<'a> { tri_vert_count: u32, rect_count: u32, disc_count: u32, + /// Rect de recorte (x, y, w, h) en px del target. `None` = pinta todo el + /// viewport (comportamiento histórico). Cuando está seteado, ambas pasadas + /// (primitivos y composite) se restringen a ese rect → sólo se tocan los + /// píxeles sucios: es el habilitador de **damage / partial-present**. + scissor: Option<(u32, u32, u32, u32)>, } impl<'a> GpuBatch<'a> { @@ -437,9 +445,21 @@ impl<'a> GpuBatch<'a> { tri_vert_count: 0, rect_count: 0, disc_count: 0, + scissor: None, } } + /// Restringe el `flush` a un rect (x, y, w, h) en px del target: sólo se + /// rasterizan y componen los píxeles dentro del rect, el resto del `view` + /// queda intacto. Es el primitivo de **damage / partial-present**: cuando + /// sólo cambió una región chica (un cursor, un spinner, un nodo animado), el + /// caller setea el rect sucio y evita re-pintar la pantalla entera. El rect + /// se clampea al viewport en `flush` (un rect fuera de rango no revienta). + /// Sin llamar a esto, `flush` pinta todo el viewport (histórico). + pub fn scissor(&mut self, x: u32, y: u32, w: u32, h: u32) { + self.scissor = Some((x, y, w, h)); + } + /// Grosor de las próximas líneas (en pixels del frame, sin AA). /// Se aplica a todas las líneas del batch — el lado bueno de una /// sola draw call es que sólo hay un grosor "vivo" por flush. @@ -449,12 +469,11 @@ impl<'a> GpuBatch<'a> { /// Añade un segmento de línea como instancia. pub fn add_line(&mut self, p0: (f32, f32), p1: (f32, f32), color: Color) { - let rgba = pack_rgba(color); self.line_verts.extend_from_slice(&p0.0.to_ne_bytes()); self.line_verts.extend_from_slice(&p0.1.to_ne_bytes()); self.line_verts.extend_from_slice(&p1.0.to_ne_bytes()); self.line_verts.extend_from_slice(&p1.1.to_ne_bytes()); - self.line_verts.extend_from_slice(&rgba.to_ne_bytes()); + push_color(&mut self.line_verts, color); self.line_count += 1; } @@ -485,10 +504,9 @@ impl<'a> GpuBatch<'a> { } fn push_tri_vert(&mut self, p: (f32, f32), color: Color) { - let rgba = pack_rgba(color); self.tri_verts.extend_from_slice(&p.0.to_ne_bytes()); self.tri_verts.extend_from_slice(&p.1.to_ne_bytes()); - self.tri_verts.extend_from_slice(&rgba.to_ne_bytes()); + push_color(&mut self.tri_verts, color); self.tri_vert_count += 1; } @@ -504,12 +522,11 @@ impl<'a> GpuBatch<'a> { /// Añade un rectángulo lleno como instancia (sin radio — para /// rounded rects sigue por vello). pub fn add_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Color) { - let rgba = pack_rgba(color); self.rect_insts.extend_from_slice(&x.to_ne_bytes()); self.rect_insts.extend_from_slice(&y.to_ne_bytes()); self.rect_insts.extend_from_slice(&w.to_ne_bytes()); self.rect_insts.extend_from_slice(&h.to_ne_bytes()); - self.rect_insts.extend_from_slice(&rgba.to_ne_bytes()); + push_color(&mut self.rect_insts, color); self.rect_count += 1; } @@ -532,12 +549,11 @@ impl<'a> GpuBatch<'a> { } fn push_disc(&mut self, cx: f32, cy: f32, r: f32, stroke: f32, color: Color) { - let rgba = pack_rgba(color); self.disc_insts.extend_from_slice(&cx.to_ne_bytes()); self.disc_insts.extend_from_slice(&cy.to_ne_bytes()); self.disc_insts.extend_from_slice(&r.to_ne_bytes()); self.disc_insts.extend_from_slice(&stroke.to_ne_bytes()); - self.disc_insts.extend_from_slice(&rgba.to_ne_bytes()); + push_color(&mut self.disc_insts, color); self.disc_count += 1; } @@ -638,9 +654,24 @@ impl<'a> GpuBatch<'a> { // Texturas por-flush dimensionadas al viewport (mismo patrón que // los buffers de arriba; el resize "sale gratis"). `tex_w/h` se // clampean a ≥1 para evitar Extent3d de 0 (un viewport degenerado - // no debería llegar acá, pero defensivo). + // no debería llegar aquí, pero defensivo). let tex_w = (viewport.0.round() as u32).max(1); let tex_h = (viewport.1.round() as u32).max(1); + // Rect de damage clampeado al viewport. Si se pidió scissor pero clampea + // a área 0 (región sucia fuera de rango), no hay nada que pintar → salir. + let scissor_px: Option<(u32, u32, u32, u32)> = match self.scissor { + None => None, + Some((x, y, w, h)) => { + let x = x.min(tex_w); + let y = y.min(tex_h); + let w = w.min(tex_w.saturating_sub(x)); + let h = h.min(tex_h.saturating_sub(y)); + if w == 0 || h == 0 { + return; + } + Some((x, y, w, h)) + } + }; let extent = wgpu::Extent3d { width: tex_w, height: tex_h, @@ -696,6 +727,9 @@ impl<'a> GpuBatch<'a> { occlusion_query_set: None, }); pass.set_bind_group(0, &bind_group, &[]); + if let Some((x, y, w, h)) = scissor_px { + pass.set_scissor_rect(x, y, w, h); + } // Orden de draws: rects (fondo) → discos → tris → lines (encima). // Match de la convención usual "fill abajo, stroke arriba". @@ -763,16 +797,25 @@ impl<'a> GpuBatch<'a> { }); cpass.set_pipeline(&self.pipelines.composite); cpass.set_bind_group(0, &composite_bg, &[]); + if let Some((x, y, w, h)) = scissor_px { + cpass.set_scissor_rect(x, y, w, h); + } cpass.draw(0..3, 0..1); } } -/// Empaqueta un `peniko::Color` a u32 little-endian RGBA8. -/// El shader lo lee como `inst.rgba` y separa bytes — debe coincidir -/// con la convención del WGSL (`r = rgba & 0xFF`, etc.). -fn pack_rgba(c: Color) -> u32 { - let [r, g, b, a] = c.to_rgba8().to_u8_array(); - (r as u32) | ((g as u32) << 8) | ((b as u32) << 16) | ((a as u32) << 24) +/// Empuja un `peniko::Color` como 4×`f32` (RGBA lineal-del-color, 16 B) al +/// buffer de vértices/instancias. Se lee `Color::components` **directo** — a +/// diferencia de `to_rgba8()`, NO cuantiza a 8-bit ni clampea a [0,1], así que +/// un color HDR (componente >1.0) o un paso fino entre dos niveles 8-bit +/// sobrevive hasta el target. Cuando el target es 8-bit (`Rgba8Unorm`, el caso +/// normal) el resultado visible es idéntico al del pack u32 anterior; la +/// precisión extra sólo aparece con un target `Rgba16Float`/`Rgb10a2`. El WGSL +/// lo recibe como `vec4` directo (sin `unpack_rgba`). +fn push_color(buf: &mut Vec, c: Color) { + for comp in c.components { + buf.extend_from_slice(&comp.to_ne_bytes()); + } } const WGSL: &str = r#" @@ -789,36 +832,28 @@ struct V2F { @location(0) color: vec4, }; -fn unpack_rgba(c: u32) -> vec4 { - let r = f32( c & 0xFFu) / 255.0; - let g = f32((c >> 8u) & 0xFFu) / 255.0; - let b = f32((c >> 16u) & 0xFFu) / 255.0; - let a = f32((c >> 24u) & 0xFFu) / 255.0; - return vec4(r, g, b, a); -} - fn px_to_ndc(p: vec2) -> vec2 { return vec2(p.x / u.viewport.x * 2.0 - 1.0, 1.0 - p.y / u.viewport.y * 2.0); } -// -------- triángulos: 1 vértice = (xy, rgba) -------- +// -------- triángulos: 1 vértice = (xy, color) -------- @vertex -fn vs_tris(@location(0) xy: vec2, @location(1) rgba: u32) -> V2F { +fn vs_tris(@location(0) xy: vec2, @location(1) color: vec4) -> V2F { var out: V2F; out.pos = vec4(px_to_ndc(xy), 0.0, 1.0); - out.color = unpack_rgba(rgba); + out.color = color; return out; } -// -------- rects: 1 instancia = (xy, wh, rgba), 6 vértices/quad -------- +// -------- rects: 1 instancia = (xy, wh, color), 6 vértices/quad -------- @vertex fn vs_rects( @builtin(vertex_index) vid: u32, @location(0) inst_xy: vec2, @location(1) inst_wh: vec2, - @location(2) inst_rgba: u32, + @location(2) color: vec4, ) -> V2F { var corners = array, 6>( vec2(0.0, 0.0), @@ -832,17 +867,17 @@ fn vs_rects( let px = inst_xy + local * inst_wh; var out: V2F; out.pos = vec4(px_to_ndc(px), 0.0, 1.0); - out.color = unpack_rgba(inst_rgba); + out.color = color; return out; } -// -------- líneas: 1 instancia = (p0xy, p1xy, rgba), expandida a quad ---- +// -------- líneas: 1 instancia = (p0xy, p1xy, color), expandida a quad ---- @vertex fn vs_lines( @builtin(vertex_index) vid: u32, @location(0) seg: vec4, - @location(1) rgba: u32, + @location(1) color: vec4, ) -> V2F { // Quad perpendicular al segmento, grosor uniforme `u.line_width` px. // vid 0..5 mapea a los 6 vértices del quad (2 tris). @@ -865,7 +900,7 @@ fn vs_lines( let px = along + across; var out: V2F; out.pos = vec4(px_to_ndc(px), 0.0, 1.0); - out.color = unpack_rgba(rgba); + out.color = color; return out; } @@ -888,7 +923,7 @@ fn vs_discs( @builtin(vertex_index) vid: u32, @location(0) inst_c: vec2, @location(1) inst_rs: vec2, - @location(2) inst_rgba: u32, + @location(2) color: vec4, ) -> DiscV2F { var corners = array, 6>( vec2(-1.0, -1.0), @@ -904,7 +939,7 @@ fn vs_discs( let px = inst_c + local; var out: DiscV2F; out.pos = vec4(px_to_ndc(px), 0.0, 1.0); - out.color = unpack_rgba(inst_rgba); + out.color = color; out.local = local; out.params = inst_rs; return out; diff --git a/llimphi-raster/tests/gpu_hdr_precision.rs b/llimphi-raster/tests/gpu_hdr_precision.rs new file mode 100644 index 0000000..046c4b8 --- /dev/null +++ b/llimphi-raster/tests/gpu_hdr_precision.rs @@ -0,0 +1,170 @@ +//! Prueba de que el backend GPU-directo carga **precisión de color que vello no +//! puede** (vello 0.7 exige target `Rgba8Unorm`; sólo emite 8-bit). Con el color +//! del vértice ensanchado a 4×f32 y un target `Rgba16Float`, el path directo +//! preserva: +//! +//! 1. valores HDR (componente >1.0) que 8-bit **clampea** a 1.0, y +//! 2. pasos de color **más finos que un nivel 8-bit** que 8-bit **cuantiza** al +//! mismo byte. +//! +//! Es la certificación headless (§8, por texto: `assert` numérico sobre el +//! readback) del deliverable "GPU-directo desbloquea HDR" de PLAN-MOTOR-PROFUNDO. +//! Corre en llvmpipe. El look HDR en pantalla sigue siendo [METAL]. + +use llimphi_hal::{wgpu, Hal}; +use llimphi_raster::gpu::{GpuBatch, GpuPipelines}; +use llimphi_raster::peniko::Color; + +const N: u32 = 8; // target chico; el rect cubre todo + +/// Decodifica un half-float (f16, IEEE 754 binary16) a f32. +fn f16_to_f32(h: u16) -> f32 { + let sign = if (h >> 15) & 1 == 1 { -1.0 } else { 1.0 }; + let exp = (h >> 10) & 0x1f; + let frac = h & 0x3ff; + if exp == 0 { + sign * (frac as f32) * 2f32.powi(-24) // subnormal + } else if exp == 0x1f { + if frac == 0 { sign * f32::INFINITY } else { f32::NAN } + } else { + sign * (1.0 + frac as f32 / 1024.0) * 2f32.powi(exp as i32 - 15) + } +} + +/// Rasteriza un rect que cubre todo el target de formato `fmt` con `color` y +/// devuelve los bytes crudos del pixel central (fila con padding quitado). +fn solid_center_bytes( + hal: &Hal, + fmt: wgpu::TextureFormat, + bytes_per_pixel: usize, + color: Color, +) -> Vec { + let pipelines = GpuPipelines::new(&hal.device, fmt); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("hdr-target"), + size: wgpu::Extent3d { width: N, height: N, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: fmt, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + + let mut batch = GpuBatch::new(&pipelines); + batch.add_rect(0.0, 0.0, N as f32, N as f32, color); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + batch.flush( + &hal.device, + &hal.queue, + &mut enc, + &view, + (N as f32, N as f32), + // Clear a negro OPACO: en el pixel cubierto el composite (premult + // over) deja el color del rect intacto; en los no cubiertos, negro. + wgpu::LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), + ); + hal.queue.submit(std::iter::once(enc.finish())); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + + // Readback con padding de fila. + let unpadded = N as usize * bytes_per_pixel; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("hdr-readback"), + size: (padded * N as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(N), + }, + }, + wgpu::Extent3d { width: N, height: N, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + // Pixel central (4,4). + let row = 4usize; + let col = 4usize; + let start = row * padded + col * bytes_per_pixel; + let px = data[start..start + bytes_per_pixel].to_vec(); + drop(data); + buf.unmap(); + px +} + +/// Lee el canal R de un pixel `Rgba16Float` (2 B/canal). +fn r_channel_f16(px: &[u8]) -> f32 { + f16_to_f32(u16::from_le_bytes([px[0], px[1]])) +} + +#[test] +fn valor_hdr_mayor_que_uno_sobrevive_en_float_target() { + let hal = pollster::block_on(Hal::new_headless(true)).expect("hal"); + // R = 4.0 (HDR). En Rgba8Unorm esto clampea a 1.0; en float debe sobrevivir. + let color = Color::new([4.0, 0.0, 0.0, 1.0]); + let px = solid_center_bytes(&hal, wgpu::TextureFormat::Rgba16Float, 8, color); + let r = r_channel_f16(&px); + assert!( + r > 3.9, + "el valor HDR 4.0 no sobrevivió al target float: R leído = {r} \ + (si fuera 8-bit estaría clampeado a 1.0)" + ); +} + +#[test] +fn paso_sub_8bit_se_pierde_en_8bit_pero_sobrevive_en_float() { + let hal = pollster::block_on(Hal::new_headless(true)).expect("hal"); + // Dos valores dentro del MISMO nivel de 8-bit: base 100 (lejos de cualquier + // borde .5 de redondeo) + 0.4/255. Ambos redondean a 100 en 8-bit, pero + // distan ~0.0016 en lineal → f16 (paso ~0.0005 cerca de 0.4) los distingue. + let v1 = 100.0 / 255.0; + let v2 = v1 + 0.4 / 255.0; + let c1 = Color::new([v1, 0.0, 0.0, 1.0]); + let c2 = Color::new([v2, 0.0, 0.0, 1.0]); + + // En 8-bit: ambos cuantizan al MISMO byte (indistinguibles). + let r1_8 = solid_center_bytes(&hal, wgpu::TextureFormat::Rgba8Unorm, 4, c1)[0]; + let r2_8 = solid_center_bytes(&hal, wgpu::TextureFormat::Rgba8Unorm, 4, c2)[0]; + assert_eq!( + r1_8, r2_8, + "premisa: en 8-bit los dos valores deben cuantizar al mismo byte \ + (fueron {r1_8} y {r2_8})" + ); + + // En float: el path directo los preserva distintos. + let r1_f = r_channel_f16(&solid_center_bytes(&hal, wgpu::TextureFormat::Rgba16Float, 8, c1)); + let r2_f = r_channel_f16(&solid_center_bytes(&hal, wgpu::TextureFormat::Rgba16Float, 8, c2)); + assert!( + (r1_f - r2_f).abs() > 0.0007, + "el target float debería distinguir el paso sub-8-bit: R1={r1_f} R2={r2_f} \ + (Δ={:.5}); en 8-bit Δ fue exactamente 0", + (r1_f - r2_f).abs() + ); +} diff --git a/llimphi-raster/tests/gpu_scissor.rs b/llimphi-raster/tests/gpu_scissor.rs new file mode 100644 index 0000000..4c72f3b --- /dev/null +++ b/llimphi-raster/tests/gpu_scissor.rs @@ -0,0 +1,134 @@ +//! Correctitud del `scissor` del GPU-directo (habilitador de damage / +//! partial-present): un `flush` con `scissor(x,y,w,h)` + `LoadOp::Load` sólo +//! pinta dentro del rect y **preserva** el resto del `view`. Es la propiedad que +//! el runtime necesita para re-pintar únicamente la región sucia (un cursor, un +//! spinner) sin tocar la pantalla entera. Certificado por texto (§8), llvmpipe. + +use llimphi_hal::{wgpu, Hal}; +use llimphi_raster::gpu::{GpuBatch, GpuPipelines}; +use llimphi_raster::peniko::Color; + +const N: u32 = 16; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn read_all(hal: &Hal, target: &wgpu::Texture) -> Vec { + let unpadded = N as usize * 4; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("scissor-readback"), + size: (padded * N as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(N), + }, + }, + wgpu::Extent3d { width: N, height: N, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut out = Vec::with_capacity((N * N * 4) as usize); + for row in 0..N as usize { + let s = row * padded; + out.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + out +} + +fn px(buf: &[u8], x: u32, y: u32) -> [u8; 4] { + let o = ((y * N + x) * 4) as usize; + [buf[o], buf[o + 1], buf[o + 2], buf[o + 3]] +} + +#[test] +fn scissor_pinta_solo_la_region_sucia_y_preserva_el_resto() { + let hal = pollster::block_on(Hal::new_headless(true)).expect("hal"); + let pipelines = GpuPipelines::new(&hal.device, FMT); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("scissor-target"), + size: wgpu::Extent3d { width: N, height: N, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + + // 1) Pintar TODO de azul (estado "previo" del view). + let mut base = GpuBatch::new(&pipelines); + base.add_rect(0.0, 0.0, N as f32, N as f32, Color::from_rgba8(0, 0, 255, 255)); + base.flush( + &hal.device, + &hal.queue, + &mut enc, + &view, + (N as f32, N as f32), + wgpu::LoadOp::Clear(wgpu::Color::BLACK), + ); + + // 2) Un rect rojo full-viewport PERO con scissor a [4,4]-[12,12] y Load: + // debe pintar rojo sólo ahí y dejar el azul intacto afuera. + let mut dirty = GpuBatch::new(&pipelines); + dirty.add_rect(0.0, 0.0, N as f32, N as f32, Color::from_rgba8(255, 0, 0, 255)); + dirty.scissor(4, 4, 8, 8); + dirty.flush( + &hal.device, + &hal.queue, + &mut enc, + &view, + (N as f32, N as f32), + wgpu::LoadOp::Load, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); + + let buf = read_all(&hal, &target); + + // Centro (8,8): dentro del scissor → rojo. + let c = px(&buf, 8, 8); + assert!(c[0] > 200 && c[2] < 60, "centro debería ser rojo, fue {c:?}"); + // Esquinas fuera del scissor → azul preservado. + for (x, y) in [(1, 1), (14, 1), (1, 14), (14, 14)] { + let p = px(&buf, x, y); + assert!( + p[2] > 200 && p[0] < 60, + "({x},{y}) fuera del scissor debería seguir azul, fue {p:?}" + ); + } + // Justo fuera del borde del scissor (3,3) azul; justo dentro (4,4) rojo. + let out = px(&buf, 3, 3); + assert!(out[2] > 200 && out[0] < 60, "(3,3) borde-fuera debería ser azul, fue {out:?}"); + let inn = px(&buf, 5, 5); + assert!(inn[0] > 200 && inn[2] < 60, "(5,5) borde-dentro debería ser rojo, fue {inn:?}"); +} diff --git a/llimphi-surface/Cargo.toml b/llimphi-surface/Cargo.toml index fb424bf..50d03cd 100644 --- a/llimphi-surface/Cargo.toml +++ b/llimphi-surface/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "llimphi-surface" -description = "Surface/compositing glue for the llimphi UI framework." version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true publish.workspace = true -repository.workspace = true +description = "Surface/compositing glue for the llimphi UI framework." +repository.workspace = true [dependencies] llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" } llimphi-ui = { path = "../llimphi-ui", version = "0.1.0" } diff --git a/llimphi-surface/LEEME.md b/llimphi-surface/LEEME.md new file mode 100644 index 0000000..5476d5c --- /dev/null +++ b/llimphi-surface/LEEME.md @@ -0,0 +1,40 @@ +# llimphi-surface + +*Read this in English: [README.md](README.md).* + +Superficies externas dentro del bucle Elm. + +Un `ExternalSurface` es una textura RGBA8 que vive en GPU y se pinta +sobre un rect del frame Llimphi cada vez que la app lo expone vía +`View::gpu_paint_with`. La fuente de bytes corre afuera del bucle +Elm: un decoder de video, un capture de cámara, un raster de PDF, +una textura raw producida por otro motor — cualquier productor que +genere RGBA puede empujar frames con `ExternalSurface::upload` y +ver el resultado en la próxima pasada de raster. + +El crate provee: + +- `ExternalSurface`: dueño de la textura + render pipeline + bind + group. `upload(rgba, w, h)` sube bytes y recrea la textura si + `w`/`h` cambiaron. +- `ExternalSurface::view`: helper que construye un `View` con + `gpu_paint_with` ya conectado. La app sólo elige el `Style` del + nodo (qué porción del layout ocupa). + +## Diseño + +El pipeline es un textured-quad clásico: dos triángulos cubren el +rect destino, el fragment shader samplea la textura externa con +sampler bilineal. Las coordenadas NDC del quad se computan en GPU +a partir de `(rect, viewport)` que viajan por uniform — por eso +el callback necesita el `viewport` que `llimphi-ui` empezó a +propagar en `GpuPaintFn`. + +La textura intermedia donde Llimphi pinta vello es `Rgba8Unorm` +(ver `llimphi-hal::INTERMEDIATE_FORMAT`). El pipeline emite +`Rgba8Unorm` también — el target del render pass es esa misma +intermedia con `LoadOp::Load`, así el fondo vello queda preservado. + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-surface/README.md b/llimphi-surface/README.md new file mode 100644 index 0000000..25b3431 --- /dev/null +++ b/llimphi-surface/README.md @@ -0,0 +1,35 @@ +# llimphi-surface + +External surfaces inside the Elm loop. + +An `ExternalSurface` is an RGBA8 texture living on the GPU, painted over a rect +of the Llimphi frame every time the app exposes it through +`View::gpu_paint_with`. The byte source runs outside the Elm loop: a video +decoder, a camera capture, a PDF raster, a raw texture produced by another engine +— any producer generating RGBA can push frames with `ExternalSurface::upload` and +see the result on the next raster pass. + +The crate provides: + +- `ExternalSurface`: owner of the texture + render pipeline + bind group. + `upload(rgba, w, h)` uploads bytes and recreates the texture if `w`/`h` changed. +- `ExternalSurface::view`: a helper that builds a `View` with `gpu_paint_with` + already wired. The app only chooses the node's `Style` (which portion of the + layout it occupies). + +## Design + +The pipeline is a classic textured quad: two triangles cover the destination +rect, and the fragment shader samples the external texture with a bilinear +sampler. The quad's NDC coordinates are computed on the GPU from +`(rect, viewport)` travelling through a uniform — which is why the callback needs +the `viewport` that `llimphi-ui` began propagating in `GpuPaintFn`. + +The intermediate texture Llimphi paints vello into is `Rgba8Unorm` (see +`llimphi-hal::INTERMEDIATE_FORMAT`). The pipeline emits `Rgba8Unorm` too — the +render pass target is that same intermediate with `LoadOp::Load`, so the vello +background is preserved. + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-svg/Cargo.toml b/llimphi-svg/Cargo.toml new file mode 100644 index 0000000..5b41eca --- /dev/null +++ b/llimphi-svg/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-svg" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-svg — puente fino vello_svg → Llimphi. Carga .svg arbitrario una vez (SvgAsset) y lo stampea en cualquier View vía paint_with, escalado al rect." + +[dependencies] +llimphi-ui = { workspace = true } +vello_svg = { version = "0.9", default-features = false } diff --git a/llimphi-svg/LEEME.md b/llimphi-svg/LEEME.md new file mode 100644 index 0000000..aa862f2 --- /dev/null +++ b/llimphi-svg/LEEME.md @@ -0,0 +1,44 @@ +# llimphi-svg + +*Read this in English: [README.md](README.md).* + +Puente fino entre `vello_svg` y Llimphi. + +`llimphi-icons` cubre el set canónico de ~50 íconos a mano (BezPath en +grid 24×24). Para lo demás — íconos de apps `.desktop` arbitrarias, +logotipos, assets de marca, exports vectoriales de pineal/cosmos — hace +falta cargar SVG real. Este crate es el puente: parsea una vez con +`vello_svg` y stampea la `vello::Scene` resultante en cualquier `View` +escalándola al rect del nodo. + +## Uso +```ignore +use llimphi_svg::SvgAsset; + +// Parsea UNA vez (al cargar la app o el ícono): +let svg = SvgAsset::from_str(include_str!("logo.svg")).expect("logo válido"); + +// Pintalo en un View tantas veces como quieras (escala al rect): +View::new(style).children(vec![svg.view::()]) +``` +El parse cuesta — el `view()` no. Si vas a stampear el mismo SVG en muchos +nodos (lista de apps con el mismo ícono fallback), parsea una sola vez y +clonea el `SvgAsset` (es barato: `Arc` internamente). + +## Por qué no parsear en cada paint + +`vello_svg::render` corre el parser de `usvg` (~ms por SVG no trivial). En +una lista con 80 íconos `.desktop`, parsear en cada frame mata el thread +de UI. La regla: **el asset se parsea una vez**, la `Scene` resultante se +retiene en memoria y se stampea con `scene.append(&inner, Some(xf))` — +cuesta lo mismo que dibujar el resto del UI. + +## Errores + +`SvgAsset::from_str` devuelve `Result` — si el XML está +corrupto o usa features que `usvg` no soporta. Las apps típicas tratan el +error como "fallback a glyph genérico" — no rompen. + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-svg/README.md b/llimphi-svg/README.md new file mode 100644 index 0000000..415254f --- /dev/null +++ b/llimphi-svg/README.md @@ -0,0 +1,43 @@ +# llimphi-svg + +A thin bridge between `vello_svg` and Llimphi. + +`llimphi-icons` covers the canonical set of ~50 hand-made icons (a `BezPath` on a +24×24 grid). For everything else — arbitrary `.desktop` app icons, logos, brand +assets, vector exports from pineal/cosmos — you need to load real SVG. This crate +is the bridge: it parses once with `vello_svg` and stamps the resulting +`vello::Scene` into any `View`, scaling it to the node's rect. + +## Use + +```ignore +use llimphi_svg::SvgAsset; + +// Parse ONCE (when loading the app or the icon): +let svg = SvgAsset::from_str(include_str!("logo.svg")).expect("valid logo"); + +// Paint it in a View as many times as you like (it scales to the rect): +View::new(style).children(vec![svg.view::()]) +``` + +The parse costs something — `view()` does not. If you are going to stamp the same +SVG in many nodes (an app list sharing a fallback icon), parse once and clone the +`SvgAsset` (it is cheap: `Arc` internally). + +## Why not parse on every paint + +`vello_svg::render` runs `usvg`'s parser (~ms for a non-trivial SVG). In a list +of 80 `.desktop` icons, parsing every frame kills the UI thread. The rule: **the +asset is parsed once**, the resulting `Scene` is retained in memory and stamped +with `scene.append(&inner, Some(xf))` — which costs the same as drawing the rest +of the UI. + +## Errors + +`SvgAsset::from_str` returns `Result` — for corrupt XML or +features `usvg` does not support. Typical apps treat the error as "fall back to a +generic glyph" — they do not break. + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-svg/src/lib.rs b/llimphi-svg/src/lib.rs new file mode 100644 index 0000000..2111568 --- /dev/null +++ b/llimphi-svg/src/lib.rs @@ -0,0 +1,191 @@ +//! `llimphi-svg` — puente fino entre `vello_svg` y Llimphi. +//! +//! `llimphi-icons` cubre el set canónico de ~50 íconos a mano (BezPath en +//! grid 24×24). Para lo demás — íconos de apps `.desktop` arbitrarias, +//! logotipos, assets de marca, exports vectoriales de pineal/cosmos — hace +//! falta cargar SVG real. Este crate es el puente: parsea una vez con +//! `vello_svg` y stampea la `vello::Scene` resultante en cualquier `View` +//! escalándola al rect del nodo. +//! +//! ## Uso +//! +//! ```ignore +//! use llimphi_svg::SvgAsset; +//! +//! // Parsea UNA vez (al cargar la app o el ícono): +//! let svg = SvgAsset::from_str(include_str!("logo.svg")).expect("logo válido"); +//! +//! // Pintalo en un View tantas veces como quieras (escala al rect): +//! View::new(style).children(vec![svg.view::()]) +//! ``` +//! +//! El parse cuesta — el `view()` no. Si vas a stampear el mismo SVG en muchos +//! nodos (lista de apps con el mismo ícono fallback), parsea una sola vez y +//! clonea el `SvgAsset` (es barato: `Arc` internamente). +//! +//! ## Por qué no parsear en cada paint +//! +//! `vello_svg::render` corre el parser de `usvg` (~ms por SVG no trivial). En +//! una lista con 80 íconos `.desktop`, parsear en cada frame mata el thread +//! de UI. La regla: **el asset se parsea una vez**, la `Scene` resultante se +//! retiene en memoria y se stampea con `scene.append(&inner, Some(xf))` — +//! cuesta lo mismo que dibujar el resto del UI. +//! +//! ## Errores +//! +//! `SvgAsset::from_str` devuelve `Result` — si el XML está +//! corrupto o usa features que `usvg` no soporta. Las apps típicas tratan el +//! error como "fallback a glyph genérico" — no rompen. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style}; +use llimphi_ui::llimphi_layout::taffy::Position; +use llimphi_ui::llimphi_raster::kurbo::Affine; +use llimphi_ui::llimphi_raster::vello::Scene; +use llimphi_ui::{PaintRect, View}; + +/// Asset SVG parseado y listo para stampear. Internamente guarda la +/// `vello::Scene` ya construida + el viewBox del SVG (para escalar al rect del +/// nodo manteniendo aspecto). Cloneable barato (`Arc`) — un mismo asset en una +/// lista de 80 íconos no replica memoria. +#[derive(Clone)] +pub struct SvgAsset { + inner: Arc, +} + +struct Inner { + scene: Scene, + /// Tamaño del viewBox en unidades del SVG (px nominales). Lo necesitamos + /// para escalar el `scene.append(...)` al rect del nodo destino. + vb_w: f64, + vb_h: f64, +} + +/// Errores al parsear un SVG. Hoy es un wrap del tipo de `vello_svg::Error` — +/// las apps típicas lo tratan como "fallback" y no inspeccionan la variante. +#[derive(Debug)] +pub struct SvgError(pub String); + +impl std::fmt::Display for SvgError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "svg: {}", self.0) + } +} + +impl std::error::Error for SvgError {} + +impl SvgAsset { + /// Parsea un SVG desde su representación XML como string. Devuelve un asset + /// inmutable + cloneable. **Hace el parseo completo** (usvg + render a una + /// `vello::Scene`); pensado para llamarse UNA vez por asset, no por frame. + pub fn from_str(svg: &str) -> Result { + let scene = vello_svg::render(svg).map_err(|e| SvgError(e.to_string()))?; + // El size del viewBox sale del propio SVG: lo recuperamos con un parse + // mínimo de usvg para no acoplar este crate a la geometría del SVG por + // fuera de vello_svg. + let opt = vello_svg::usvg::Options::default(); + let tree = vello_svg::usvg::Tree::from_str(svg, &opt).map_err(|e| SvgError(e.to_string()))?; + let size = tree.size(); + Ok(Self { + inner: Arc::new(Inner { + scene, + vb_w: size.width() as f64, + vb_h: size.height() as f64, + }), + }) + } + + /// Parsea un SVG desde sus bytes UTF-8 crudos (lo que devuelve + /// `include_bytes!("…svg")` o `std::fs::read`). + pub fn from_bytes(svg: &[u8]) -> Result { + let s = std::str::from_utf8(svg).map_err(|e| SvgError(e.to_string()))?; + Self::from_str(s) + } + + /// Tamaño del viewBox del SVG en px nominales. Útil cuando el caller quiere + /// dimensionar el rect destino preservando el aspect ratio en vez de dejar + /// que el `view()` lo escale al máximo lado. + pub fn size(&self) -> (f64, f64) { + (self.inner.vb_w, self.inner.vb_h) + } + + /// Pinta el SVG sobre `scene`, ajustado al `rect` indicado. Escala + /// uniforme al mínimo lado y **centra** dentro del rect (preserva + /// aspect ratio). Útil cuando el caller compone varios assets en un + /// `paint_with` propio sin pasar por `view()`. + pub fn paint(&self, scene: &mut Scene, rect: PaintRect) { + let side_w = rect.w as f64; + let side_h = rect.h as f64; + if side_w <= 0.0 || side_h <= 0.0 || self.inner.vb_w <= 0.0 || self.inner.vb_h <= 0.0 { + return; + } + let s = (side_w / self.inner.vb_w).min(side_h / self.inner.vb_h); + let used_w = self.inner.vb_w * s; + let used_h = self.inner.vb_h * s; + let tx = rect.x as f64 + (side_w - used_w) * 0.5; + let ty = rect.y as f64 + (side_h - used_h) * 0.5; + let xform = Affine::translate((tx, ty)) * Affine::scale(s); + scene.append(&self.inner.scene, Some(xform)); + } + + /// Construye un `View` posicionado en absoluto, que ocupa todo el rect del + /// padre y pinta el SVG centrado + escalado al mínimo lado. Es el equivalente + /// de `icon_view` para SVG arbitrarios. Genérico sobre `Msg` igual que los + /// widgets — el `View` no tiene handlers; la app los pone en el padre. + pub fn view(&self) -> View { + let asset = self.clone(); + View::new(Style { + position: Position::Absolute, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + asset.paint(scene, rect); + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Un SVG mínimo: una caja roja de 24×24 con un círculo blanco al centro. + const SVG_OK: &str = r##" + + + "##; + + #[test] + fn from_str_parsea_ok() { + let a = SvgAsset::from_str(SVG_OK).expect("parsea"); + let (w, h) = a.size(); + assert!((w - 24.0).abs() < 1e-6 && (h - 24.0).abs() < 1e-6); + } + + #[test] + fn svg_inválido_da_error() { + let bad = ""; + assert!(SvgAsset::from_str(bad).is_err()); + } + + #[test] + fn asset_es_cloneable_barato() { + let a = SvgAsset::from_str(SVG_OK).expect("parsea"); + let b = a.clone(); + assert_eq!(a.size(), b.size()); + } + + #[test] + fn paint_no_panica_con_rect_cero() { + let a = SvgAsset::from_str(SVG_OK).expect("parsea"); + let mut s = Scene::new(); + a.paint(&mut s, PaintRect { x: 0.0, y: 0.0, w: 0.0, h: 0.0 }); + a.paint(&mut s, PaintRect { x: 0.0, y: 0.0, w: 10.0, h: 0.0 }); + } +} diff --git a/llimphi-term-graphics/Cargo.toml b/llimphi-term-graphics/Cargo.toml new file mode 100644 index 0000000..ea299b1 --- /dev/null +++ b/llimphi-term-graphics/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "llimphi-term-graphics" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-term-graphics — decodificador agnóstico de protocolos de gráficos de terminal (kitty graphics protocol vía APC, y sixel vía DCS). Scanner streaming que deja pasar el texto/ANSI normal (para el emulador vt100) y extrae las secuencias gráficas completas, decodificándolas a RGBA8. Sin dependencias de UI — produce píxeles, el caller los sube a una textura/peniko::Image." + +[dependencies] +base64 = { workspace = true } +flate2 = { workspace = true } +# Sólo PNG/JPEG/WEBP/etc. para el formato kitty f=100 (imagen embebida). +# Sixel y RGBA/RGB crudos (f=24/32) se decodifican a mano, sin el crate. +image = { workspace = true } diff --git a/llimphi-term-graphics/LEEME.md b/llimphi-term-graphics/LEEME.md new file mode 100644 index 0000000..b5dafc6 --- /dev/null +++ b/llimphi-term-graphics/LEEME.md @@ -0,0 +1,29 @@ +# llimphi-term-graphics + +*Read this in English: [README.md](README.md).* + +Decodificador agnóstico de protocolos de gráficos de terminal. + +Dos protocolos cubiertos: +- **kitty graphics protocol** — secuencias APC `\e_G;\e\\`. + Soporta transmisión chunked (`m=1`), formatos `f=100` (PNG/JPEG/… vía el + crate `image`), `f=24` (RGB crudo) y `f=32` (RGBA crudo), y compresión + `o=z` (zlib). Acciones `t`/`T` (transmitir [y mostrar]), `q` (query), + `d` (delete). +- **sixel** — secuencias DCS `\ePq\e\\`. Decodificador a mano + (paleta, selección de color `#`, RLE `!`, CR `$`, LF `-`, atributos + raster `"`). + +El corazón es `GraphicsScanner`: un autómata streaming al que se le +alimentan los chunks de bytes del PTY. Deja pasar **intacto** todo el texto +y los escapes ANSI normales (para que el emulador vt100 los procese) y sólo +**secuestra** las secuencias gráficas completas, que devuelve decodificadas +como `GraphicsCommand`. No tiene estado de UI ni dependencias de render: +produce píxeles RGBA8, el caller los sube a una textura / `peniko::Image`. + +Robusto a fronteras de chunk: una secuencia partida entre dos `feed()` +queda buffereada y se completa en la llamada siguiente. + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-term-graphics/README.md b/llimphi-term-graphics/README.md new file mode 100644 index 0000000..cea89bb --- /dev/null +++ b/llimphi-term-graphics/README.md @@ -0,0 +1,28 @@ +# llimphi-term-graphics + +Agnostic decoder of terminal graphics protocols. + +Two protocols covered: + +- **kitty graphics protocol** — APC sequences `\e_G;\e\\`. + It supports chunked transmission (`m=1`), the `f=100` (PNG/JPEG/… through the + `image` crate), `f=24` (raw RGB) and `f=32` (raw RGBA) formats, and `o=z` + (zlib) compression. Actions `t`/`T` (transmit [and display]), `q` (query), + `d` (delete). +- **sixel** — DCS sequences `\ePq\e\\`. A hand-written decoder + (palette, `#` colour selection, `!` RLE, `$` CR, `-` LF, `"` raster + attributes). + +Its heart is `GraphicsScanner`: a streaming automaton fed the PTY's byte chunks. +It lets **all** ordinary text and ANSI escapes through untouched (so the vt100 +emulator can process them) and only **hijacks** complete graphics sequences, +returning them decoded as a `GraphicsCommand`. It has no UI state and no render +dependencies: it produces RGBA8 pixels and the caller uploads them to a texture +or a `peniko::Image`. + +It is robust to chunk boundaries: a sequence split across two `feed()` calls is +buffered and completed on the next one. + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-term-graphics/src/kitty.rs b/llimphi-term-graphics/src/kitty.rs new file mode 100644 index 0000000..e3cc8a0 --- /dev/null +++ b/llimphi-term-graphics/src/kitty.rs @@ -0,0 +1,212 @@ +//! kitty graphics protocol — parser de control APC + ensamblador de +//! transmisión chunked + decodificación a RGBA. +//! +//! Cuerpo APC esperado (sin el `\e_` ni el `\e\\`): `G;` donde +//! `` son pares `clave=valor` separados por comas. Claves que nos +//! importan: +//! - `a` acción: `t` transmitir, `T` transmitir+mostrar, `q` query, `d` delete. +//! - `f` formato: `32` RGBA (default), `24` RGB, `100` PNG/imagen embebida. +//! - `s`,`v` ancho,alto en px (para `f=24`/`f=32`). +//! - `c`,`r` columnas,filas de celdas pedidas (0 = derivar de los px). +//! - `i` id de imagen. `m` more-chunks (`1` = sigue, `0`/ausente = último). +//! - `o` compresión: `z` = zlib (RFC1950) sobre los datos pre-base64. + +use std::io::Read; + +use base64::Engine; + +use crate::{DecodedImage, GraphicsCommand, Protocol}; + +/// Errores de decodificación kitty. Hoy informativos; el scanner los traga +/// (una imagen rota no debe tumbar la terminal) pero los exponemos por si el +/// caller quiere loguearlos. +#[derive(Debug)] +pub enum KittyError { + Base64, + Zlib, + Image(String), + BadDims, +} + +/// Acumula la transmisión chunked (`m=1`) hasta el último chunk. +#[derive(Default)] +pub struct KittyAssembler { + pending: Option, +} + +struct Pending { + fmt: u32, + s: u32, + v: u32, + id: u32, + cols: u16, + rows: u16, + compressed: bool, + payload_b64: Vec, +} + +impl KittyAssembler { + /// Procesa un cuerpo APC completo. Devuelve un comando cuando la + /// transmisión cierra (o es query/delete); `None` mientras espera más + /// chunks o si el APC no es kitty. + pub fn feed_apc(&mut self, seq: &[u8]) -> Option { + // Debe empezar con 'G' (kitty graphics). Otros APC no son nuestros. + if seq.first() != Some(&b'G') { + return None; + } + let body = &seq[1..]; + let (control, payload): (&[u8], &[u8]) = match body.iter().position(|&b| b == b';') { + Some(p) => (&body[..p], &body[p + 1..]), + None => (body, &[]), + }; + + let is_continuation = self.pending.is_some(); + + // Sólo el primer chunk trae el control completo; las continuaciones + // traen (a lo sumo) `m`. Parseamos siempre `m`; el resto sólo si es + // el primer chunk. + let mut more = false; + let mut action = b't'; + let mut fmt = 32u32; + let (mut s, mut v) = (0u32, 0u32); + let mut id = 0u32; + let (mut cols, mut rows) = (0u16, 0u16); + let mut compressed = false; + + for tok in control.split(|&b| b == b',') { + if tok.is_empty() { + continue; + } + let mut kv = tok.splitn(2, |&b| b == b'='); + let k = kv.next().unwrap_or(&[]); + let val = kv.next().unwrap_or(&[]); + match k { + b"m" => more = val == b"1", + b"a" => action = val.first().copied().unwrap_or(b't'), + b"f" => fmt = parse_u32(val).unwrap_or(32), + b"s" => s = parse_u32(val).unwrap_or(0), + b"v" => v = parse_u32(val).unwrap_or(0), + b"i" => id = parse_u32(val).unwrap_or(0), + b"c" => cols = parse_u32(val).unwrap_or(0) as u16, + b"r" => rows = parse_u32(val).unwrap_or(0) as u16, + b"o" => compressed = val == b"z", + _ => {} + } + } + + // Acciones sin payload (sólo válidas en el primer chunk). + if !is_continuation { + match action { + b'q' => { + // Responder OK por el PTY para que el emisor sepa que hay + // soporte kitty. Formato: `\e_Gi=;OK\e\\`. + let response = format!("\x1b_Gi={id};OK\x1b\\").into_bytes(); + return Some(GraphicsCommand::Query { response }); + } + b'd' => { + let id = if id == 0 { None } else { Some(id) }; + return Some(GraphicsCommand::Delete { id }); + } + _ => {} + } + } + + // Acumular payload base64. + if is_continuation { + let p = self.pending.as_mut().unwrap(); + p.payload_b64.extend_from_slice(payload); + } else { + self.pending = Some(Pending { + fmt, + s, + v, + id, + cols, + rows, + compressed, + payload_b64: payload.to_vec(), + }); + } + + if more { + return None; + } + + // Último chunk: finalizar. + let p = self.pending.take()?; + match decode_pending(&p) { + Ok(image) => Some(GraphicsCommand::Image { + image, + cols: p.cols, + rows: p.rows, + id: p.id, + protocol: Protocol::Kitty, + }), + Err(_) => None, + } + } +} + +fn decode_pending(p: &Pending) -> Result { + // 1. base64 → bytes. + let raw = base64::engine::general_purpose::STANDARD + .decode(&p.payload_b64) + .map_err(|_| KittyError::Base64)?; + + // 2. zlib si o=z. + let data = if p.compressed { + let mut dec = flate2::read::ZlibDecoder::new(&raw[..]); + let mut out = Vec::new(); + dec.read_to_end(&mut out).map_err(|_| KittyError::Zlib)?; + out + } else { + raw + }; + + // 3. interpretar según formato. + match p.fmt { + 32 => { + let (w, h) = (p.s, p.v); + let need = (w as usize) * (h as usize) * 4; + if w == 0 || h == 0 || data.len() < need { + return Err(KittyError::BadDims); + } + Ok(DecodedImage { + width: w, + height: h, + rgba: data[..need].to_vec(), + }) + } + 24 => { + let (w, h) = (p.s, p.v); + let need = (w as usize) * (h as usize) * 3; + if w == 0 || h == 0 || data.len() < need { + return Err(KittyError::BadDims); + } + let mut rgba = Vec::with_capacity((w as usize) * (h as usize) * 4); + for px in data[..need].chunks_exact(3) { + rgba.extend_from_slice(&[px[0], px[1], px[2], 255]); + } + Ok(DecodedImage { + width: w, + height: h, + rgba, + }) + } + // f=100 (PNG) y cualquier otro: dejamos que el crate `image` adivine + // el formato por los magic bytes. + _ => { + let img = image::load_from_memory(&data).map_err(|e| KittyError::Image(e.to_string()))?; + let rgba = img.to_rgba8(); + Ok(DecodedImage { + width: rgba.width(), + height: rgba.height(), + rgba: rgba.into_raw(), + }) + } + } +} + +fn parse_u32(b: &[u8]) -> Option { + std::str::from_utf8(b).ok()?.trim().parse().ok() +} diff --git a/llimphi-term-graphics/src/lib.rs b/llimphi-term-graphics/src/lib.rs new file mode 100644 index 0000000..e826152 --- /dev/null +++ b/llimphi-term-graphics/src/lib.rs @@ -0,0 +1,600 @@ +//! `llimphi-term-graphics` — decodificador agnóstico de protocolos de gráficos +//! de terminal. +//! +//! Dos protocolos cubiertos: +//! - **kitty graphics protocol** — secuencias APC `\e_G;\e\\`. +//! Soporta transmisión chunked (`m=1`), formatos `f=100` (PNG/JPEG/… vía el +//! crate `image`), `f=24` (RGB crudo) y `f=32` (RGBA crudo), y compresión +//! `o=z` (zlib). Acciones `t`/`T` (transmitir [y mostrar]), `q` (query), +//! `d` (delete). +//! - **sixel** — secuencias DCS `\ePq\e\\`. Decodificador a mano +//! (paleta, selección de color `#`, RLE `!`, CR `$`, LF `-`, atributos +//! raster `"`). +//! +//! El corazón es [`GraphicsScanner`]: un autómata streaming al que se le +//! alimentan los chunks de bytes del PTY. Deja pasar **intacto** todo el texto +//! y los escapes ANSI normales (para que el emulador vt100 los procese) y sólo +//! **secuestra** las secuencias gráficas completas, que devuelve decodificadas +//! como [`GraphicsCommand`]. No tiene estado de UI ni dependencias de render: +//! produce píxeles RGBA8, el caller los sube a una textura / `peniko::Image`. +//! +//! Robusto a fronteras de chunk: una secuencia partida entre dos `feed()` +//! queda buffereada y se completa en la llamada siguiente. + +#![forbid(unsafe_code)] + +mod kitty; +mod sixel; + +pub use kitty::KittyError; + +/// Imagen decodificada: RGBA8 sin premultiplicar, fila-mayor, sin padding. +/// `rgba.len() == width * height * 4`. +#[derive(Clone, PartialEq, Eq)] +pub struct DecodedImage { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +impl std::fmt::Debug for DecodedImage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DecodedImage") + .field("width", &self.width) + .field("height", &self.height) + .field("rgba_len", &self.rgba.len()) + .finish() + } +} + +/// Protocolo de origen de un comando gráfico. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Protocol { + Kitty, + Sixel, +} + +/// Un comando gráfico completo extraído del stream del PTY. +#[derive(Debug, Clone)] +pub enum GraphicsCommand { + /// Transmitir (y normalmente mostrar) una imagen en la posición actual del + /// cursor. `cols`/`rows` son las celdas pedidas por el protocolo (kitty + /// `c=`/`r=`); `0` = el caller deriva el tamaño de los píxeles. + Image { + image: DecodedImage, + cols: u16, + rows: u16, + /// id de imagen kitty (para placement/delete posterior); sixel = 0. + id: u32, + protocol: Protocol, + }, + /// kitty `a=d`: borrar placements. `id == None` = borrar todo. + Delete { id: Option }, + /// Una query de capacidad (kitty `a=q`) que el emulador debe **responder** + /// escribiendo `response` de vuelta por el stdin del PTY. + Query { response: Vec }, +} + +const ESC: u8 = 0x1b; +const BEL: u8 = 0x07; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + /// Texto normal; copiando a passthrough. + Normal, + /// Vimos ESC, esperando el byte que decide (no lo copiamos todavía). + Esc, + /// Dentro de APC (`\e_`), acumulando en `seq` hasta ST/BEL. + Apc, + /// Dentro de APC y vimos ESC (esperando `\` del ST). + ApcEsc, + /// Dentro de DCS (`\eP`), acumulando en `seq` hasta ST/BEL. + Dcs, + /// Dentro de DCS y vimos ESC. + DcsEsc, + /// Dentro de CSI (`\e[`), acumulando params/intermedios hasta el byte + /// final (`0x40..=0x7e`). La CSI se reenvía **verbatim** al passthrough; + /// sólo emitimos una respuesta si resulta una query de capacidad. + Csi, +} + +/// Autómata streaming que separa el texto/ANSI normal de las secuencias +/// gráficas. Ver el módulo para el contrato completo. +pub struct GraphicsScanner { + state: State, + /// Cuerpo de la secuencia en curso (sin el ESC inicial ni el terminador). + seq: Vec, + /// Acumulador de la transmisión chunked de kitty (`m=1`). + kitty: kitty::KittyAssembler, + /// Tope de bytes para una sola secuencia — defensa contra un stream malo + /// que nunca cierra el APC/DCS (no crecemos sin límite). + max_seq: usize, +} + +impl Default for GraphicsScanner { + fn default() -> Self { + Self::new() + } +} + +impl GraphicsScanner { + pub fn new() -> Self { + Self { + state: State::Normal, + seq: Vec::new(), + kitty: kitty::KittyAssembler::default(), + // 64 MiB: una imagen base64 grande cabe; un stream roto no nos cuelga. + max_seq: 64 * 1024 * 1024, + } + } + + /// Consume `input`: escribe los bytes no-gráficos en `passthrough` (para el + /// vt100) y devuelve los comandos gráficos completados en este chunk. + pub fn feed(&mut self, input: &[u8], passthrough: &mut Vec) -> Vec { + let mut out = Vec::new(); + for &b in input { + match self.state { + State::Normal => { + if b == ESC { + self.state = State::Esc; + } else { + passthrough.push(b); + } + } + State::Esc => match b { + b'_' => { + self.seq.clear(); + self.state = State::Apc; + } + b'P' => { + self.seq.clear(); + self.state = State::Dcs; + } + b'[' => { + self.seq.clear(); + self.state = State::Csi; + } + ESC => { + // ESC ESC: el primero no era nuestro → a passthrough; + // seguimos evaluando este nuevo ESC. + passthrough.push(ESC); + } + _ => { + // Cualquier otra secuencia (CSI `[`, OSC `]`, charset…) + // no nos interesa: devolvemos el ESC + este byte y el + // resto fluye como texto normal al vt100. + passthrough.push(ESC); + passthrough.push(b); + self.state = State::Normal; + } + }, + State::Apc => self.accumulate(b, &mut out, false), + State::ApcEsc => self.accumulate_esc(b, &mut out, false), + State::Dcs => self.accumulate(b, &mut out, true), + State::DcsEsc => self.accumulate_esc(b, &mut out, true), + State::Csi => self.accumulate_csi(b, passthrough, &mut out), + } + } + out + } + + /// Byte dentro de un cuerpo APC/DCS (no estábamos tras un ESC). + fn accumulate(&mut self, b: u8, out: &mut Vec, is_dcs: bool) { + if b == ESC { + self.state = if is_dcs { State::DcsEsc } else { State::ApcEsc }; + } else if b == BEL { + // BEL también cierra (algunas terminales lo aceptan para APC). + self.finish(out, is_dcs); + } else { + if self.seq.len() < self.max_seq { + self.seq.push(b); + } + // Si rebalsa el tope, seguimos consumiendo bytes (descartándolos) + // hasta el terminador, para no resincronizar mal. + } + } + + /// Byte dentro de un cuerpo APC/DCS justo después de un ESC. + fn accumulate_esc(&mut self, b: u8, out: &mut Vec, is_dcs: bool) { + if b == b'\\' { + // ST: secuencia completa. + self.finish(out, is_dcs); + } else if b == ESC { + // ESC ESC dentro del cuerpo: el primero es literal. + if self.seq.len() < self.max_seq { + self.seq.push(ESC); + } + // seguimos en *Esc esperando el resolutor. + } else { + // El ESC era literal y este byte también. + if self.seq.len() + 2 <= self.max_seq { + self.seq.push(ESC); + self.seq.push(b); + } + self.state = if is_dcs { State::Dcs } else { State::Apc }; + } + } + + /// Byte dentro de una CSI (`\e[…`). La CSI siempre se reenvía verbatim al + /// passthrough (el vt100 la ve idéntica); si es una query de capacidad, + /// además emitimos su respuesta. + fn accumulate_csi( + &mut self, + b: u8, + passthrough: &mut Vec, + out: &mut Vec, + ) { + if b == ESC { + // CSI abortada por un ESC nuevo: descargamos lo acumulado y + // re-evaluamos este ESC. + passthrough.extend_from_slice(&[ESC, b'[']); + passthrough.extend_from_slice(&self.seq); + self.seq.clear(); + self.state = State::Esc; + } else if (0x40..=0x7e).contains(&b) { + // Byte final: la CSI está completa. + self.seq.push(b); + passthrough.extend_from_slice(&[ESC, b'[']); + passthrough.extend_from_slice(&self.seq); + if let Some(response) = csi_query_response(&self.seq) { + out.push(GraphicsCommand::Query { response }); + } + self.seq.clear(); + self.state = State::Normal; + } else { + // Params (0x30..0x3f) / intermedios (0x20..0x2f). + if self.seq.len() < self.max_seq { + self.seq.push(b); + } + } + } + + /// Cierra la secuencia acumulada: la decodifica y resetea a Normal. + fn finish(&mut self, out: &mut Vec, is_dcs: bool) { + let seq = std::mem::take(&mut self.seq); + self.state = State::Normal; + if is_dcs { + if let Some(img) = sixel::decode(&seq) { + out.push(GraphicsCommand::Image { + image: img, + cols: 0, + rows: 0, + id: 0, + protocol: Protocol::Sixel, + }); + } + } else if let Some(cmd) = self.kitty.feed_apc(&seq) { + out.push(cmd); + } + } +} + +/// Si `csi` (los bytes de una CSI tras `\e[`, incluido el byte final) es una +/// query de capacidad gráfica, devuelve la respuesta que el emulador debe +/// escribir por el stdin del PTY para anunciar soporte. Anunciamos sixel +/// porque el decodificador lo soporta; las demás CSI devuelven `None`. +fn csi_query_response(csi: &[u8]) -> Option> { + match csi { + // Primary Device Attributes (`\e[c` / `\e[0c`). El `4` en la respuesta + // es el flag que chafa/otros leen como "soporta sixel". 62 = VT220. + b"c" | b"0c" => Some(b"\x1b[?62;4c".to_vec()), + // Secondary DA (`\e[>c` / `\e[>0c`) — versión del terminal, estilo + // xterm. Algunos programas la piden antes de decidir capacidades. + b">c" | b">0c" => Some(b"\x1b[>1;95;0c".to_vec()), + // XTSMGRAPHICS (`\e[?Pi;Pa;…S`): consulta de registros de color / + // geometría sixel. Respondemos valores holgados (la sizing real la + // hace el caller con su tamaño de celda). + _ if csi.first() == Some(&b'?') && csi.last() == Some(&b'S') => { + let body = &csi[1..csi.len() - 1]; + let mut it = body.split(|&b| b == b';'); + let pi = it.next().and_then(|v| std::str::from_utf8(v).ok()?.parse::().ok()); + match pi { + Some(1) => Some(b"\x1b[?1;0;65536S".to_vec()), // registros de color + Some(2) => Some(b"\x1b[?2;0;1000;1000S".to_vec()), // geometría máx. + _ => None, + } + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// El texto plano pasa intacto y no genera comandos. + #[test] + fn texto_normal_pasa_intacto() { + let mut sc = GraphicsScanner::new(); + let mut pt = Vec::new(); + let cmds = sc.feed(b"hola mundo\n", &mut pt); + assert!(cmds.is_empty()); + assert_eq!(pt, b"hola mundo\n"); + } + + /// Una secuencia CSI (color SGR) NO se secuestra: fluye al passthrough. + #[test] + fn csi_sgr_no_se_secuestra() { + let mut sc = GraphicsScanner::new(); + let mut pt = Vec::new(); + let input = b"\x1b[31mrojo\x1b[0m"; + let cmds = sc.feed(input, &mut pt); + assert!(cmds.is_empty()); + assert_eq!(pt, input); + } + + /// kitty RGBA crudo 1x1: APC con f=32, s=1, v=1, payload = 4 bytes base64. + #[test] + fn kitty_rgba_1x1() { + use base64::Engine; + let px = [10u8, 20, 30, 255]; + let b64 = base64::engine::general_purpose::STANDARD.encode(px); + let seq = format!("\x1b_Gf=32,s=1,v=1,a=T;{b64}\x1b\\"); + let mut sc = GraphicsScanner::new(); + let mut pt = Vec::new(); + let cmds = sc.feed(seq.as_bytes(), &mut pt); + assert!(pt.is_empty(), "no debería pasar nada al vt100"); + assert_eq!(cmds.len(), 1); + match &cmds[0] { + GraphicsCommand::Image { image, protocol, .. } => { + assert_eq!(*protocol, Protocol::Kitty); + assert_eq!((image.width, image.height), (1, 1)); + assert_eq!(image.rgba, vec![10, 20, 30, 255]); + } + other => panic!("se esperaba Image, vino {other:?}"), + } + } + + /// La secuencia partida entre dos `feed()` se completa igual. + #[test] + fn secuencia_partida_entre_chunks() { + use base64::Engine; + let px = [1u8, 2, 3, 4]; + let b64 = base64::engine::general_purpose::STANDARD.encode(px); + let seq = format!("\x1b_Gf=32,s=1,v=1;{b64}\x1b\\"); + let bytes = seq.as_bytes(); + let mid = bytes.len() / 2; + let mut sc = GraphicsScanner::new(); + let mut pt = Vec::new(); + let mut cmds = sc.feed(&bytes[..mid], &mut pt); + cmds.extend(sc.feed(&bytes[mid..], &mut pt)); + assert!(pt.is_empty()); + assert_eq!(cmds.len(), 1); + match &cmds[0] { + GraphicsCommand::Image { image, .. } => { + assert_eq!(image.rgba, vec![1, 2, 3, 4]); + } + other => panic!("se esperaba Image, vino {other:?}"), + } + } + + /// Texto antes y después de la imagen sobrevive en el passthrough. + #[test] + fn texto_rodeando_imagen() { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode([9u8, 9, 9, 9]); + let seq = format!("antes\x1b_Gf=32,s=1,v=1;{b64}\x1b\\después"); + let mut sc = GraphicsScanner::new(); + let mut pt = Vec::new(); + let cmds = sc.feed(seq.as_bytes(), &mut pt); + assert_eq!(cmds.len(), 1); + assert_eq!(pt, "antesdespués".as_bytes()); + } + + /// Primary DA (`\e[c`) → respuesta con flag sixel (`4`), y la query igual + /// se reenvía verbatim al vt100. + #[test] + fn da_primary_anuncia_sixel() { + let mut sc = GraphicsScanner::new(); + let mut pt = Vec::new(); + let cmds = sc.feed(b"\x1b[c", &mut pt); + assert_eq!(pt, b"\x1b[c", "la CSI debe pasar idéntica al vt100"); + assert_eq!(cmds.len(), 1); + match &cmds[0] { + GraphicsCommand::Query { response } => { + assert_eq!(response, b"\x1b[?62;4c"); + } + other => panic!("se esperaba Query, vino {other:?}"), + } + } + + /// XTSMGRAPHICS de registros de color (`\e[?1;1;0S`) → respuesta `?1;0;…S`. + #[test] + fn xtsmgraphics_registros_color() { + let mut sc = GraphicsScanner::new(); + let mut pt = Vec::new(); + let cmds = sc.feed(b"\x1b[?1;1;0S", &mut pt); + assert_eq!(pt, b"\x1b[?1;1;0S"); + assert_eq!(cmds.len(), 1); + match &cmds[0] { + GraphicsCommand::Query { response } => { + let s = String::from_utf8_lossy(response); + assert!(s.starts_with("\x1b[?1;0;"), "resp: {s:?}"); + } + other => panic!("se esperaba Query, vino {other:?}"), + } + } + + /// Una CSI normal (cursor home `\e[H`) no genera respuesta y pasa intacta. + #[test] + fn csi_normal_sin_respuesta() { + let mut sc = GraphicsScanner::new(); + let mut pt = Vec::new(); + let cmds = sc.feed(b"\x1b[H\x1b[2J", &mut pt); + assert!(cmds.is_empty()); + assert_eq!(pt, b"\x1b[H\x1b[2J"); + } + + /// kitty f=100 (PNG embebido) — el camino real de chafa/icat. Construimos + /// un PNG 2×2 con el crate `image`, lo metemos en un APC y verificamos que + /// vuelve a salir como RGBA 2×2. + #[test] + fn kitty_png_embebido() { + use base64::Engine; + // PNG 2×2: rojo, verde / azul, blanco. + let mut img = image::RgbaImage::new(2, 2); + img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); + img.put_pixel(1, 0, image::Rgba([0, 255, 0, 255])); + img.put_pixel(0, 1, image::Rgba([0, 0, 255, 255])); + img.put_pixel(1, 1, image::Rgba([255, 255, 255, 255])); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(img) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(png.into_inner()); + let seq = format!("\x1b_Gf=100,a=T,c=4,r=2;{b64}\x1b\\"); + + let mut sc = GraphicsScanner::new(); + let mut pt = Vec::new(); + let cmds = sc.feed(seq.as_bytes(), &mut pt); + assert_eq!(cmds.len(), 1); + match &cmds[0] { + GraphicsCommand::Image { image, cols, rows, .. } => { + assert_eq!((image.width, image.height), (2, 2)); + assert_eq!((*cols, *rows), (4, 2)); + assert_eq!(&image.rgba[0..4], &[255, 0, 0, 255]); // rojo + } + other => panic!("se esperaba Image, vino {other:?}"), + } + } + + /// Transmisión chunked: control + payload en varios APC con `m=1` y el + /// último con `m=0`. El payload base64 se concatena y decodifica una vez. + #[test] + fn kitty_chunked() { + use base64::Engine; + // 2×1 RGBA crudo = 8 bytes. + let raw = [10u8, 11, 12, 13, 20, 21, 22, 23]; + let b64 = base64::engine::general_purpose::STANDARD.encode(raw); + let (a, b) = b64.split_at(b64.len() / 2); + // primer chunk: control completo + m=1; segundo: sólo m=0 + resto. + let seq = format!("\x1b_Gf=32,s=2,v=1,m=1;{a}\x1b\\\x1b_Gm=0;{b}\x1b\\"); + + let mut sc = GraphicsScanner::new(); + let mut pt = Vec::new(); + let cmds = sc.feed(seq.as_bytes(), &mut pt); + assert_eq!(cmds.len(), 1, "un solo Image al cerrar el último chunk"); + match &cmds[0] { + GraphicsCommand::Image { image, .. } => { + assert_eq!((image.width, image.height), (2, 1)); + assert_eq!(image.rgba, raw.to_vec()); + } + other => panic!("se esperaba Image, vino {other:?}"), + } + } + + /// Compresión zlib (o=z) sobre RGBA crudo. + #[test] + fn kitty_zlib() { + use base64::Engine; + use std::io::Write; + let raw = [1u8, 2, 3, 4, 5, 6, 7, 8]; + let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + enc.write_all(&raw).unwrap(); + let compressed = enc.finish().unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(compressed); + let seq = format!("\x1b_Gf=32,s=2,v=1,o=z;{b64}\x1b\\"); + + let mut sc = GraphicsScanner::new(); + let mut pt = Vec::new(); + let cmds = sc.feed(seq.as_bytes(), &mut pt); + assert_eq!(cmds.len(), 1); + match &cmds[0] { + GraphicsCommand::Image { image, .. } => { + assert_eq!(image.rgba, raw.to_vec()); + } + other => panic!("se esperaba Image, vino {other:?}"), + } + } + + /// Round-trip contra el encoder real `chafa`, en sus dos formatos. Genera + /// un PNG, lo pasa por `chafa -f {sixel,kitty}` y verifica que el scanner + /// recupera exactamente una imagen coherente — pese a la `CSI` de mostrar/ + /// ocultar cursor que chafa intercala (debe ir a passthrough) y a que el + /// formato kitty viene en chunks. Se salta si chafa no está instalado. + #[test] + fn chafa_round_trip_real() { + use std::io::Write; + use std::process::{Command, Stdio}; + + // PNG 16×16 con patrón de dos colores. + let mut img = image::RgbaImage::new(16, 16); + for y in 0..16u32 { + for x in 0..16u32 { + let c = if (x / 4 + y / 4) % 2 == 0 { + image::Rgba([200, 30, 30, 255]) + } else { + image::Rgba([30, 30, 200, 255]) + }; + img.put_pixel(x, y, c); + } + } + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(img) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let png = png.into_inner(); + + for (fmt, want) in [("sixel", Protocol::Sixel), ("kitty", Protocol::Kitty)] { + let mut child = match Command::new("chafa") + .args(["-f", fmt, "--size", "20x10", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + { + Ok(c) => c, + Err(_) => { + eprintln!("chafa no instalado — test saltado"); + return; + } + }; + child.stdin.take().unwrap().write_all(&png).unwrap(); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success(), "chafa -f {fmt} falló"); + + // Alimentamos en dos mitades para ejercitar también la frontera de + // chunk del scanner sobre datos reales. + let mut sc = GraphicsScanner::new(); + let mut pt = Vec::new(); + let mid = out.stdout.len() / 2; + let mut cmds = sc.feed(&out.stdout[..mid], &mut pt); + cmds.extend(sc.feed(&out.stdout[mid..], &mut pt)); + let imgs: Vec<_> = cmds + .iter() + .filter_map(|c| match c { + GraphicsCommand::Image { image, protocol, .. } => Some((image, *protocol)), + _ => None, + }) + .collect(); + assert_eq!(imgs.len(), 1, "fmt={fmt}: se esperaba 1 imagen"); + let (image, protocol) = imgs[0]; + assert_eq!(protocol, want, "fmt={fmt}: protocolo"); + assert!(image.width > 0 && image.height > 0, "fmt={fmt}: dims nulas"); + assert_eq!( + image.rgba.len(), + (image.width as usize) * (image.height as usize) * 4, + "fmt={fmt}: largo del buffer RGBA" + ); + // El passthrough debe contener la CSI de cursor (no la tragamos). + assert!(!pt.is_empty(), "fmt={fmt}: la CSI debió ir a passthrough"); + } + } + + /// Query de capacidad kitty → comando Query con respuesta OK. + #[test] + fn kitty_query_responde_ok() { + let mut sc = GraphicsScanner::new(); + let mut pt = Vec::new(); + let cmds = sc.feed(b"\x1b_Gi=31,s=1,v=1,a=q;AAAA\x1b\\", &mut pt); + assert_eq!(cmds.len(), 1); + match &cmds[0] { + GraphicsCommand::Query { response } => { + let s = String::from_utf8_lossy(response); + assert!(s.contains("i=31"), "respuesta: {s}"); + assert!(s.contains("OK"), "respuesta: {s}"); + } + other => panic!("se esperaba Query, vino {other:?}"), + } + } +} diff --git a/llimphi-term-graphics/src/sixel.rs b/llimphi-term-graphics/src/sixel.rs new file mode 100644 index 0000000..698c0b4 --- /dev/null +++ b/llimphi-term-graphics/src/sixel.rs @@ -0,0 +1,317 @@ +//! Decodificador sixel (a mano — no hay crate en el workspace). +//! +//! Entrada: el cuerpo DCS (sin el `\eP` inicial ni el `\e\\` final). Estructura: +//! `q` y luego los datos sixel: +//! - `"Pan;Pad;Ph;Pv` — atributos raster (aspecto + ancho `Ph` × alto `Pv`). +//! - `#Pc;Pu;Px;Py;Pz` — define el color del registro `Pc` (`Pu=2` ⇒ RGB en +//! porcentaje 0..100). `#Pc` solo ⇒ selecciona el registro `Pc`. +//! - `!Pn ` — repite el sixel `` `Pn` veces (RLE). +//! - `$` — carriage return (vuelve al inicio de la banda, misma `y`). +//! - `-` — line feed (avanza 6 filas). +//! - chars `0x3F..=0x7E` — 6 píxeles verticales; bit `i` (LSB = arriba) prende +//! la fila `y+i` con el color actual. +//! +//! Estrategia de tamaño: si hay atributos raster los usamos; si no, dos +//! pasadas — la primera mide (`max_x`/`max_y`), la segunda pinta. + +use crate::DecodedImage; + +/// Tope defensivo de píxeles (16 Mpx ≈ 64 MiB RGBA) para no estallar con un +/// stream sixel malicioso/roto. +const MAX_PIXELS: u64 = 16 * 1024 * 1024; + +pub fn decode(seq: &[u8]) -> Option { + let qpos = seq.iter().position(|&b| b == b'q')?; + let data = &seq[qpos + 1..]; + + // Tamaño: de atributos raster si están; si no, midiendo. + let (mut w, mut h) = raster_size(data); + if w == 0 || h == 0 { + let (mx, my) = measure(data); + if w == 0 { + w = mx; + } + if h == 0 { + h = my; + } + } + if w == 0 || h == 0 { + return None; + } + if (w as u64) * (h as u64) > MAX_PIXELS { + return None; + } + + let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4]; + paint(data, w, h, &mut rgba); + Some(DecodedImage { + width: w, + height: h, + rgba, + }) +} + +/// Lee los atributos raster `"Pan;Pad;Ph;Pv` si aparecen al inicio. Devuelve +/// `(Ph, Pv)` o `(0,0)` si no hay. +fn raster_size(data: &[u8]) -> (u32, u32) { + let mut i = 0; + // saltar whitespace inicial + while i < data.len() && (data[i] == b'\n' || data[i] == b'\r' || data[i] == b' ') { + i += 1; + } + if i >= data.len() || data[i] != b'"' { + return (0, 0); + } + let (nums, _) = parse_nums(data, i + 1); + if nums.len() >= 4 { + (nums[2], nums[3]) + } else { + (0, 0) + } +} + +/// Pasada de medición: corre la máquina sin pintar, devolviendo +/// `(max_x+1, max_y+1)`. +fn measure(data: &[u8]) -> (u32, u32) { + let mut x: u32 = 0; + let mut y0: u32 = 0; + let mut max_x: u32 = 0; + let mut max_y: u32 = 0; + let mut i = 0; + while i < data.len() { + let b = data[i]; + match b { + b'"' | b'#' => { + let (_, ni) = parse_nums(data, i + 1); + i = ni; + } + b'!' => { + let (nums, ni) = parse_nums(data, i + 1); + i = ni; + let count = nums.first().copied().unwrap_or(0).max(1); + if i < data.len() { + let c = data[i]; + i += 1; + if (0x3f..=0x7e).contains(&c) { + let bits = c - 0x3f; + if bits != 0 { + max_x = max_x.max(x + count - 1); + max_y = max_y.max(y0 + top_bit(bits)); + } + x += count; + } + } + } + b'$' => { + x = 0; + i += 1; + } + b'-' => { + x = 0; + y0 += 6; + i += 1; + } + 0x3f..=0x7e => { + let bits = b - 0x3f; + if bits != 0 { + max_x = max_x.max(x); + max_y = max_y.max(y0 + top_bit(bits)); + } + x += 1; + i += 1; + } + _ => i += 1, + } + } + (max_x + 1, max_y + 1) +} + +/// Pasada de pintura sobre `rgba` (ya dimensionado a `w`×`h`). +fn paint(data: &[u8], w: u32, h: u32, rgba: &mut [u8]) { + let mut palette = default_palette(); + let mut cur: u16 = 0; + let mut x: u32 = 0; + let mut y0: u32 = 0; + let mut i = 0; + while i < data.len() { + let b = data[i]; + match b { + b'"' => { + let (_, ni) = parse_nums(data, i + 1); + i = ni; + } + b'#' => { + let (nums, ni) = parse_nums(data, i + 1); + i = ni; + if let Some(&pc) = nums.first() { + cur = pc as u16; + if nums.len() >= 5 { + let pu = nums[1]; + let (px, py, pz) = (nums[2], nums[3], nums[4]); + let color = if pu == 2 { + [pct(px), pct(py), pct(pz), 255] + } else { + // Pu=1 (HLS) es raro; aproximamos como RGB para no + // bajar a negro. Suficiente para chafa/img2sixel + // que emiten siempre Pu=2. + [pct(px), pct(py), pct(pz), 255] + }; + palette.insert(cur, color); + } + } + } + b'!' => { + let (nums, ni) = parse_nums(data, i + 1); + i = ni; + let count = nums.first().copied().unwrap_or(0).max(1); + if i < data.len() { + let c = data[i]; + i += 1; + if (0x3f..=0x7e).contains(&c) { + let bits = c - 0x3f; + let color = *palette.get(&cur).unwrap_or(&[0, 0, 0, 255]); + for _ in 0..count { + put(rgba, w, h, x, y0, bits, color); + x += 1; + } + } + } + } + b'$' => { + x = 0; + i += 1; + } + b'-' => { + x = 0; + y0 += 6; + i += 1; + } + 0x3f..=0x7e => { + let bits = b - 0x3f; + let color = *palette.get(&cur).unwrap_or(&[0, 0, 0, 255]); + put(rgba, w, h, x, y0, bits, color); + x += 1; + i += 1; + } + _ => i += 1, + } + } +} + +/// Pinta los 6 píxeles verticales de un sixel en `(x, y0..y0+6)`. +fn put(rgba: &mut [u8], w: u32, h: u32, x: u32, y0: u32, bits: u8, color: [u8; 4]) { + if x >= w { + return; + } + for r in 0..6u32 { + if bits & (1 << r) != 0 { + let y = y0 + r; + if y >= h { + continue; + } + let idx = ((y as usize) * (w as usize) + x as usize) * 4; + rgba[idx..idx + 4].copy_from_slice(&color); + } + } +} + +/// Fila más alta (0-based) prendida por `bits` (LSB = arriba). +fn top_bit(bits: u8) -> u32 { + let mut top = 0; + for r in 0..6u32 { + if bits & (1 << r) != 0 { + top = r; + } + } + top +} + +/// Porcentaje sixel (0..100) → byte (0..255). +fn pct(v: u32) -> u8 { + ((v.min(100) * 255 + 50) / 100) as u8 +} + +/// Lee una secuencia de enteros separados por `;` a partir de `start`. +/// Devuelve los números y el índice del primer byte no consumido. +fn parse_nums(data: &[u8], start: usize) -> (Vec, usize) { + let mut nums = Vec::new(); + let mut i = start; + let mut cur: u32 = 0; + let mut any = false; + loop { + if i >= data.len() { + break; + } + let b = data[i]; + if b.is_ascii_digit() { + cur = cur.saturating_mul(10).saturating_add((b - b'0') as u32); + any = true; + i += 1; + } else if b == b';' { + nums.push(cur); + cur = 0; + any = true; + i += 1; + } else { + break; + } + } + if any { + nums.push(cur); + } + (nums, i) +} + +/// Paleta por defecto (negro en el registro 0; el resto se llena con las +/// definiciones `#Pc;2;…` del stream). chafa/img2sixel siempre definen sus +/// colores, así que no hace falta la paleta VT340 completa. +fn default_palette() -> std::collections::HashMap { + let mut m = std::collections::HashMap::new(); + m.insert(0, [0, 0, 0, 255]); + m +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Sixel mínimo 1×6: un color RGB puro y un carácter con todos los bits. + #[test] + fn sixel_columna_1x6() { + // ESC P ya removido. Params vacíos + 'q'. Color 0 = rojo (100,0,0). + // '~' = 0x7e → bits = 0x3f = 0b111111 → 6 px prendidos. + let body = b"q#0;2;100;0;0~"; + let img = decode(body).expect("decodifica"); + assert_eq!((img.width, img.height), (1, 6)); + // Todos los 6 px rojos opacos. + for px in img.rgba.chunks_exact(4) { + assert_eq!(px, &[255, 0, 0, 255]); + } + } + + /// RLE: `!5~` = 5 columnas de 6 px. + #[test] + fn sixel_rle_ancho() { + let body = b"q#0;2;0;100;0!5~"; + let img = decode(body).expect("decodifica"); + assert_eq!((img.width, img.height), (5, 6)); + // verde + assert_eq!(&img.rgba[0..4], &[0, 255, 0, 255]); + } + + /// Atributos raster fijan el tamaño aunque los datos sean más chicos. + #[test] + fn sixel_raster_attrs() { + let body = b"q\"1;1;10;12#0;2;0;0;100~"; + let img = decode(body).expect("decodifica"); + assert_eq!((img.width, img.height), (10, 12)); + } + + /// LF `-` baja una banda (6 px) → alto 12. + #[test] + fn sixel_dos_bandas() { + let body = b"q#0;2;100;100;100~-~"; + let img = decode(body).expect("decodifica"); + assert_eq!(img.height, 12); + } +} diff --git a/llimphi-test/Cargo.toml b/llimphi-test/Cargo.toml new file mode 100644 index 0000000..02908df --- /dev/null +++ b/llimphi-test/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "llimphi-test" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-test — arnés de golden-image headless y determinista para Llimphi. Renderiza un `View` a una textura por el adapter de SOFTWARE (llvmpipe/lavapipe, mismo píxel host-a-host y en CI) y lo diffea contra un golden con tolerancia perceptual. Convierte 'renderiza y mira' (caro en tokens, subjetivo) en `assert_golden!` (barato, objetivo). Certifica cambios visuales por texto (DiffStats) según CLAUDE.md §8." + +[dependencies] +llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" } +llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" } +llimphi-compositor = { path = "../llimphi-compositor", version = "0.1.0" } +llimphi-text = { path = "../llimphi-text", version = "0.1.0" } +llimphi-layout = { path = "../llimphi-layout", version = "0.1.0" } +pollster = { workspace = true } +image = { workspace = true } + +[dev-dependencies] +llimphi-theme = { path = "../llimphi-theme", version = "0.1.0" } diff --git a/llimphi-test/LEEME.md b/llimphi-test/LEEME.md new file mode 100644 index 0000000..12b9153 --- /dev/null +++ b/llimphi-test/LEEME.md @@ -0,0 +1,35 @@ +# llimphi-test + +*Read this in English: [README.md](README.md).* + +# llimphi-test — arnés golden-image headless y determinista + +El problema que cierra: en `PLAN-NUCLEO-RETENIDO.md` y `PARIDAD-FLUTTER.md` +cada bloque termina con "falta verificar en metal / PNG-diff pendiente", y +CLAUDE.md §8 prohíbe renderizar-y-mirar de rutina por costo de tokens. Este +crate convierte esa verificación cara y subjetiva en `assert_golden!(...)`: +barata, objetiva, en CI, certificada por **texto** (`DiffStats`). + +## Determinismo + +El `Harness` pide un `Hal` por el adapter de **software** +(`llimphi_hal::Hal::new_headless(true)`): el rasterizador llvmpipe/lavapipe +(deviceType CPU) produce el **mismo píxel** en cualquier host y en CI, no +atado a la GPU de quien generó el golden. Si la máquina no tiene un fallback +adapter, cae al adapter normal y la tolerancia perceptual de `GoldenConfig` +absorbe la diferencia de vendor. + +## Uso típico (dentro de un test de otro crate) +```ignore +use llimphi_test::assert_golden; +#[test] +fn card_con_sombra() { + let v = mi_widget::card(...); + assert_golden!("card_con_sombra", v, 400, 240); // tests/goldens/card_con_sombra.png +} +``` +Regenerar los goldens: `LLIMPHI_GOLDEN_UPDATE=1 cargo test`. + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-test/README.md b/llimphi-test/README.md new file mode 100644 index 0000000..ebf783a --- /dev/null +++ b/llimphi-test/README.md @@ -0,0 +1,35 @@ +# llimphi-test + +Headless, deterministic golden-image harness. + +The problem it closes: in `PLAN-NUCLEO-RETENIDO.md` and `PARIDAD-FLUTTER.md` +every block used to end with "still to verify on metal / PNG diff pending", and +CLAUDE.md §8 forbids routine render-and-look because of its token cost. This +crate turns that expensive, subjective verification into `assert_golden!(...)`: +cheap, objective, in CI, certified by **text** (`DiffStats`). + +## Determinism + +The `Harness` asks for a `Hal` through the **software** adapter +(`llimphi_hal::Hal::new_headless(true)`): the llvmpipe/lavapipe rasterizer +(deviceType CPU) produces the **same pixel** on any host and in CI, not tied to +the GPU of whoever generated the golden. If the machine has no fallback adapter +it falls back to the normal one, and `GoldenConfig`'s perceptual tolerance +absorbs the vendor difference. + +## Typical use (inside another crate's test) + +```ignore +use llimphi_test::assert_golden; +#[test] +fn card_with_shadow() { + let v = my_widget::card(...); + assert_golden!("card_with_shadow", v, 400, 240); // tests/goldens/card_with_shadow.png +} +``` + +Regenerate the goldens with `LLIMPHI_GOLDEN_UPDATE=1 cargo test`. + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-test/src/lib.rs b/llimphi-test/src/lib.rs new file mode 100644 index 0000000..7c28e2b --- /dev/null +++ b/llimphi-test/src/lib.rs @@ -0,0 +1,419 @@ +//! # llimphi-test — arnés golden-image headless y determinista +//! +//! El problema que cierra: en `PLAN-NUCLEO-RETENIDO.md` y `PARIDAD-FLUTTER.md` +//! cada bloque termina con "falta verificar en metal / PNG-diff pendiente", y +//! CLAUDE.md §8 prohíbe renderizar-y-mirar de rutina por costo de tokens. Este +//! crate convierte esa verificación cara y subjetiva en `assert_golden!(...)`: +//! barata, objetiva, en CI, certificada por **texto** ([`DiffStats`]). +//! +//! ## Determinismo +//! +//! El [`Harness`] pide un `Hal` por el adapter de **software** +//! ([`llimphi_hal::Hal::new_headless(true)`]): el rasterizador llvmpipe/lavapipe +//! (deviceType CPU) produce el **mismo píxel** en cualquier host y en CI, no +//! atado a la GPU de quien generó el golden. Si la máquina no tiene un fallback +//! adapter, cae al adapter normal y la tolerancia perceptual de [`GoldenConfig`] +//! absorbe la diferencia de vendor. +//! +//! ## Uso típico (dentro de un test de otro crate) +//! +//! ```ignore +//! use llimphi_test::assert_golden; +//! #[test] +//! fn card_con_sombra() { +//! let v = mi_widget::card(...); +//! assert_golden!("card_con_sombra", v, 400, 240); // tests/goldens/card_con_sombra.png +//! } +//! ``` +//! +//! Regenerar los goldens: `LLIMPHI_GOLDEN_UPDATE=1 cargo test`. + +use std::cell::RefCell; +use std::mem::ManuallyDrop; +use std::path::Path; + +use llimphi_compositor::{measure_text_node, mount, paint, View}; +use llimphi_hal::{wgpu, Hal}; +use llimphi_layout::taffy; +use llimphi_layout::LayoutTree; +use llimphi_raster::peniko::Color; +use llimphi_raster::{vello, Renderer}; +use llimphi_text::Typesetter; + +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +/// Un frame rasterizado en memoria: RGBA8 sin padding, `w*h*4` bytes. +#[derive(Clone)] +pub struct Rendered { + pub w: u32, + pub h: u32, + /// RGBA8, fila mayor, `w*h*4` bytes exactos (sin el padding de wgpu). + pub rgba: Vec, +} + +impl Rendered { + /// Guarda a PNG (formato inferido por la extensión). + pub fn save(&self, path: &Path) -> Result<(), String> { + let img = image::RgbaImage::from_raw(self.w, self.h, self.rgba.clone()) + .ok_or_else(|| "buffer RGBA de tamaño inconsistente".to_string())?; + img.save(path).map_err(|e| e.to_string()) + } + + /// Carga un PNG a `Rendered` (lo normaliza a RGBA8). + pub fn load(path: &Path) -> Result { + let img = image::open(path).map_err(|e| e.to_string())?.to_rgba8(); + Ok(Self { w: img.width(), h: img.height(), rgba: img.into_raw() }) + } +} + +/// Motor de render headless reutilizable: un `Hal` + `Renderer` + `Typesetter` +/// vivos para muchos frames. Caro de construir (pide adapter), barato de reusar. +pub struct Harness { + hal: Hal, + renderer: Renderer, + ts: Typesetter, +} + +impl Harness { + /// Construye el arnés sobre el adapter de software (determinista). Ver + /// [`llimphi_hal::Hal::new_headless`]. + pub fn new() -> Result { + let hal = pollster::block_on(Hal::new_headless(true)).map_err(|e| e.to_string())?; + let renderer = Renderer::new(&hal).map_err(|e| e.to_string())?; + Ok(Self { hal, renderer, ts: Typesetter::new() }) + } + + /// Rasteriza `view` en un lienzo `w×h` sobre fondo `bg` y devuelve el RGBA8. + /// Mismo pipeline que las apps reales: `mount → compute_with_measure → paint + /// → Renderer::render_to_view`, más el readback de la textura a CPU. + pub fn render(&mut self, view: View, w: u32, h: u32, bg: Color) -> Rendered { + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, view); + let ts = &mut self.ts; + 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(ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("compute_with_measure") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, ts, None, None); + + let target = self.hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("llimphi-test-target"), + 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 tex_view = target.create_view(&wgpu::TextureViewDescriptor::default()); + self.renderer + .render_to_view(&self.hal, &scene, &tex_view, w, h, bg) + .expect("render_to_view"); + let rgba = readback(&self.hal, &target, w, h); + Rendered { w, h, rgba } + } +} + +impl Harness { + /// Golden-testea contenido del backend **GPU-directo** (`GpuBatch` / + /// `gpu_paint_with`), que NO pasa por el `View`/vello de [`render`](Self::render). + /// Crea un target `Rgba8Unorm`, lo limpia a `bg`, y le pasa a `f` el + /// `(device, queue, encoder, view, viewport)` — la misma firma que + /// `View::gpu_paint_with` — para que pinte directo; después lee de vuelta. + /// El caller típico arma un `GpuBatch` y hace `flush(..., LoadOp::Load)` + /// (compone sobre el `bg` ya limpiado, igual que sobre lo que vello pintó en + /// la app real). Cierra el hueco "el path GPU-directo no tenía cobertura de + /// regresión visual" (antes sólo el smoke test "no panic"). + pub fn render_gpu(&mut self, w: u32, h: u32, bg: Color, f: F) -> Rendered + where + F: FnOnce(&wgpu::Device, &wgpu::Queue, &mut wgpu::CommandEncoder, &wgpu::TextureView, (f32, f32)), + { + let target = self.hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("llimphi-test-gpu-target"), + 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::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let tex_view = target.create_view(&wgpu::TextureViewDescriptor::default()); + let mut enc = self + .hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + // Pre-clear a `bg` (como haría vello en la app real antes del gpu_paint_with). + let [r, g, b, a] = bg.components; + { + let _clear = enc.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("llimphi-test-gpu-clear"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &tex_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: r as f64, + g: g as f64, + b: b as f64, + a: a as f64, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + } + f(&self.hal.device, &self.hal.queue, &mut enc, &tex_view, (w as f32, h as f32)); + self.hal.queue.submit(std::iter::once(enc.finish())); + let rgba = readback(&self.hal, &target, w, h); + Rendered { w, h, rgba } + } +} + +/// Copia la textura a un buffer mapeable, quita el padding de fila de wgpu y +/// devuelve RGBA8 compacto. Patrón idéntico al `write_png` de los examples. +fn readback(hal: &Hal, target: &wgpu::Texture, w: u32, h: u32) -> 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("llimphi-test-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 out = Vec::with_capacity((w * h * 4) as usize); + for row in 0..h as usize { + let s = row * padded; + out.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + out +} + +// ───────────────────────────── diff ───────────────────────────── + +/// Estadísticas de un diff, imprimibles como **texto** — esto es lo que +/// certifica sin mirar el PNG (CLAUDE.md §8: "stats numéricas como texto"). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DiffStats { + /// Píxeles cuyo mayor delta de canal superó el umbral. + pub differing_pixels: u64, + pub total_pixels: u64, + /// Máximo delta de canal (0-255) observado en toda la imagen. + pub max_delta: u8, + /// `differing_pixels / total_pixels`. + pub fraction: f64, +} + +/// Compara dos frames píxel a píxel. Un píxel "difiere" si el máximo de los +/// deltas absolutos de sus cuatro canales supera `max_channel_delta`. Devuelve +/// un heatmap (rojo ∝ delta sobre gris oscuro) + las stats. Los frames deben +/// tener las mismas dimensiones. +pub fn diff(a: &Rendered, b: &Rendered, max_channel_delta: u8) -> Result<(Rendered, DiffStats), String> { + if a.w != b.w || a.h != b.h { + return Err(format!("dimensiones distintas: {}×{} vs {}×{}", a.w, a.h, b.w, b.h)); + } + let total = (a.w * a.h) as u64; + let mut differing = 0u64; + let mut max_delta = 0u8; + let mut heat = vec![0u8; a.rgba.len()]; + for (i, (pa, pb)) in a.rgba.chunks_exact(4).zip(b.rgba.chunks_exact(4)).enumerate() { + let d = (0..4).map(|c| pa[c].abs_diff(pb[c])).max().unwrap_or(0); + max_delta = max_delta.max(d); + let o = i * 4; + if d > max_channel_delta { + differing += 1; + heat[o] = 255; // R ∝ presencia de diff + heat[o + 1] = 30; + heat[o + 2] = 30; + heat[o + 3] = 255; + } else { + heat[o] = 20; // gris oscuro = sin diff + heat[o + 1] = 20; + heat[o + 2] = 20; + heat[o + 3] = 255; + } + } + let stats = DiffStats { + differing_pixels: differing, + total_pixels: total, + max_delta, + fraction: differing as f64 / total.max(1) as f64, + }; + Ok((Rendered { w: a.w, h: a.h, rgba: heat }, stats)) +} + +// ───────────────────────────── golden ───────────────────────────── + +/// Tolerancia de comparación golden. Default pensado para el adapter de +/// software: casi bit-exacto, con un colchón mínimo para versiones distintas +/// de LLVM/Mesa entre la máquina que generó el golden y la que lo verifica. +#[derive(Debug, Clone, Copy)] +pub struct GoldenConfig { + /// Delta de canal (0-255) por debajo del cual un píxel NO cuenta como diff. + pub max_channel_delta: u8, + /// Fracción de píxeles diferentes tolerada antes de fallar. + pub max_diff_fraction: f64, +} + +impl Default for GoldenConfig { + fn default() -> Self { + Self { max_channel_delta: 4, max_diff_fraction: 0.001 } + } +} + +/// Compara `rendered` contra el golden en `path`. +/// +/// - `LLIMPHI_GOLDEN_UPDATE=1` en el entorno → **reescribe** el golden y pasa. +/// - Golden ausente y sin modo update → error (junto al golden queda el +/// `.actual.png` para revisar y promover con el modo update). +/// - Presente → diff con `cfg`; si supera la tolerancia, escribe `.actual.png` +/// y `.diff.png` al lado y devuelve error con las [`DiffStats`]. +pub fn compare_golden(path: &Path, rendered: &Rendered, cfg: &GoldenConfig) -> Result<(), String> { + let update = std::env::var("LLIMPHI_GOLDEN_UPDATE").map(|v| v == "1").unwrap_or(false); + if update { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + rendered.save(path)?; + return Ok(()); + } + if !path.exists() { + let actual = with_suffix(path, "actual"); + let _ = rendered.save(&actual); + return Err(format!( + "golden ausente: {path:?}. Escribe {actual:?} para revisar; \ + promovelo con LLIMPHI_GOLDEN_UPDATE=1" + )); + } + let golden = Rendered::load(path)?; + let (heat, stats) = diff(&golden, rendered, cfg.max_channel_delta)?; + if stats.fraction > cfg.max_diff_fraction { + let actual = with_suffix(path, "actual"); + let dpng = with_suffix(path, "diff"); + let _ = rendered.save(&actual); + let _ = heat.save(&dpng); + return Err(format!( + "golden {path:?}: {} / {} px difieren (frac {:.5} > {:.5}), max_delta {}. \ + Ver {actual:?} y {dpng:?}", + stats.differing_pixels, stats.total_pixels, stats.fraction, cfg.max_diff_fraction, stats.max_delta + )); + } + Ok(()) +} + +fn with_suffix(path: &Path, suffix: &str) -> std::path::PathBuf { + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("golden"); + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + parent.join(format!("{stem}.{suffix}.png")) +} + +// ───────────────────────── harness compartido ───────────────────────── + +thread_local! { + // `ManuallyDrop`: el `Hal` envuelve una `wgpu::Instance` cuyo teardown sobre + // Vulkan/llvmpipe al cerrar el hilo (destructor de TLS) paniquea —el mismo + // teardown frágil que `Hal::new` documenta para GL. Un proceso de test que + // sale no necesita liberar la GPU, así que NO soltamos el arnés: se leakea al + // salir el proceso (barato, seguro) en vez de crashear en el destructor. + static TL_HARNESS: RefCell>> = const { RefCell::new(None) }; +} + +/// Corre `f` con el [`Harness`] del hilo actual (lo crea perezosamente). Un +/// arnés por hilo evita compartir el `Typesetter`/`Hal` entre threads de test. +pub fn with_harness(f: impl FnOnce(&mut Harness) -> R) -> R { + TL_HARNESS.with(|h| { + let mut b = h.borrow_mut(); + if b.is_none() { + *b = Some(ManuallyDrop::new( + Harness::new().expect("crear Harness de llimphi-test"), + )); + } + f(b.as_mut().unwrap()) + }) +} + +/// Fondo por defecto de `assert_golden!` — negro opaco. +pub fn default_bg() -> Color { + Color::from_rgba8(0, 0, 0, 255) +} + +/// Rasteriza `view` con el arnés compartido y lo compara contra el golden en +/// `path`. Paniquea (fallo de test) si supera la tolerancia. Ver +/// [`compare_golden`]. Usar el macro [`assert_golden!`] para el caso común. +pub fn assert_golden_at( + path: &Path, + view: View, + w: u32, + h: u32, + bg: Color, + cfg: GoldenConfig, +) { + let rendered = with_harness(|hn| hn.render(view, w, h, bg)); + if let Err(e) = compare_golden(path, &rendered, &cfg) { + panic!("{e}"); + } +} + +/// `assert_golden!("nombre", view, w, h[, bg])` — compara contra +/// `/tests/goldens/nombre.png` con la tolerancia default. El path usa el +/// `CARGO_MANIFEST_DIR` del crate que invoca el macro (no el de llimphi-test). +#[macro_export] +macro_rules! assert_golden { + ($name:expr, $view:expr, $w:expr, $h:expr) => { + $crate::assert_golden!($name, $view, $w, $h, $crate::default_bg()) + }; + ($name:expr, $view:expr, $w:expr, $h:expr, $bg:expr) => {{ + let path = ::std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("goldens") + .join(format!("{}.png", $name)); + $crate::assert_golden_at(&path, $view, $w, $h, $bg, $crate::GoldenConfig::default()); + }}; +} diff --git a/llimphi-test/tests/goldens/card_tier1.png b/llimphi-test/tests/goldens/card_tier1.png new file mode 100644 index 0000000..510c6fe Binary files /dev/null and b/llimphi-test/tests/goldens/card_tier1.png differ diff --git a/llimphi-test/tests/goldens/starfield_gpu.png b/llimphi-test/tests/goldens/starfield_gpu.png new file mode 100644 index 0000000..539839c Binary files /dev/null and b/llimphi-test/tests/goldens/starfield_gpu.png differ diff --git a/llimphi-test/tests/gpu_golden.rs b/llimphi-test/tests/gpu_golden.rs new file mode 100644 index 0000000..20bd088 --- /dev/null +++ b/llimphi-test/tests/gpu_golden.rs @@ -0,0 +1,45 @@ +//! Golden del backend **GPU-directo** vía `Harness::render_gpu`. Cierra el hueco +//! "el path GPU-directo no tenía cobertura de regresión visual" (antes sólo el +//! smoke test "no panic" de llimphi-raster). Un starfield determinista de discos +//! con AA por SDF — el caso canónico denso del SDD §"GPU directo". Si el +//! rasterizado de discos/AA/color regresiona, el golden falla con DiffStats. +//! +//! Regenerar: `LLIMPHI_GOLDEN_UPDATE=1 cargo test -p llimphi-test`. + +use std::path::Path; + +use llimphi_hal::wgpu; +use llimphi_raster::gpu::{GpuBatch, GpuPipelines}; +use llimphi_raster::peniko::Color; +use llimphi_test::{compare_golden, with_harness, GoldenConfig}; + +#[test] +fn golden_starfield_gpu_directo() { + let rendered = with_harness(|h| { + h.render_gpu(320, 200, Color::from_rgba8(6, 8, 16, 255), |device, queue, enc, view, vp| { + let pipelines = GpuPipelines::new(device, wgpu::TextureFormat::Rgba8Unorm); + let mut batch = GpuBatch::new(&pipelines); + // Starfield determinista (LCG con seed fijo → golden reproducible). + let mut s: u32 = 0x1234_5678; + let lcg = |s: &mut u32| -> f32 { + *s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (*s >> 8) as f32 / 16_777_216.0 + }; + for _ in 0..300 { + let x = lcg(&mut s) * vp.0; + let y = lcg(&mut s) * vp.1; + let bright = 0.3 + lcg(&mut s) * 0.7; + let t = lcg(&mut s); + // Blanco-azulado a amarillo suave, alpha ∝ brillo. + let col = Color::new([0.85 + 0.15 * t, 0.88 + 0.10 * (1.0 - t), 0.95, bright]); + let r = 0.8 + bright * 1.6; + batch.add_disc(x, y, r, col); + } + batch.flush(device, queue, enc, view, vp, wgpu::LoadOp::Load); + }) + }); + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/goldens/starfield_gpu.png"); + if let Err(e) = compare_golden(&path, &rendered, &GoldenConfig::default()) { + panic!("{e}"); + } +} diff --git a/llimphi-test/tests/self.rs b/llimphi-test/tests/self.rs new file mode 100644 index 0000000..02be94f --- /dev/null +++ b/llimphi-test/tests/self.rs @@ -0,0 +1,115 @@ +//! Self-tests del arnés: determinismo del readback, detección de diff, y un +//! golden real de una card (sombra + gradiente + borde + radio) que ejercita +//! las primitivas del Tier 1. Correr `LLIMPHI_GOLDEN_UPDATE=1 cargo test -p +//! llimphi-test` para (re)generar los goldens en `tests/goldens/`. + +use llimphi_compositor::{Shadow, View}; +use llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style}; +use llimphi_layout::taffy::{AlignItems, JustifyContent, Rect}; +use llimphi_raster::peniko::{Color, Gradient}; +use llimphi_raster::vello::kurbo::Point; +use llimphi_test::{assert_golden, diff, with_harness}; + +fn rgb(r: u8, g: u8, b: u8) -> Color { + Color::from_rgba8(r, g, b, 255) +} + +/// Rect coloreado simple — para determinismo y diff. +fn swatch(color: Color) -> View<()> { + View::<()>::new(Style { + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + ..Default::default() + }) + .fill(color) +} + +/// Card centrada con sombra + gradiente + borde + radio + un texto. Ejercita +/// las primitivas visuales del Tier 1 en una sola escena. +fn card() -> View<()> { + let inner = View::<()>::new(Style { + size: Size { width: length(220.0_f32), height: length(120.0_f32) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill_gradient( + Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0)) + .with_stops([rgb(90, 120, 220), rgb(40, 60, 120)].as_slice()), + ) + .radius(16.0) + .border(2.0, rgb(200, 210, 255)) + .shadow(Shadow { + blur: 18.0, + dx: 0.0, + dy: 8.0, + color: Color::from_rgba8(0, 0, 0, 140), + spread: 2.0, + }) + .children(vec![View::<()>::new(Style { + size: Size { width: length(180.0_f32), height: length(24.0_f32) }, + ..Default::default() + }) + .text_aligned( + "llimphi-test".to_string(), + 18.0, + rgb(240, 244, 255), + llimphi_text::Alignment::Center, + )]); + + 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), + flex_direction: FlexDirection::Column, + padding: Rect { left: length(0.0_f32), right: length(0.0_f32), top: length(0.0_f32), bottom: length(0.0_f32) }, + ..Default::default() + }) + .fill(rgb(24, 26, 34)) + .children(vec![inner]) +} + +#[test] +fn readback_es_determinista() { + // El mismo view rasterizado dos veces por el adapter de software debe dar + // píxeles idénticos: certifica el pipeline headless + el readback sin ruido. + let a = with_harness(|h| h.render(card(), 400, 240, rgb(24, 26, 34))); + let b = with_harness(|h| h.render(card(), 400, 240, rgb(24, 26, 34))); + let (_, stats) = diff(&a, &b, 0).expect("mismas dims"); + assert_eq!( + stats.differing_pixels, 0, + "render no determinista: {} px difieren (max_delta {})", + stats.differing_pixels, stats.max_delta + ); +} + +#[test] +fn diff_detecta_cambio_de_color() { + // Dos frames de colores distintos deben diferir en ~todos los píxeles y con + // delta grande — prueba que el diff no da falsos negativos. + let rojo = with_harness(|h| h.render(swatch(rgb(220, 30, 30)), 64, 64, rgb(0, 0, 0))); + let verde = with_harness(|h| h.render(swatch(rgb(30, 220, 30)), 64, 64, rgb(0, 0, 0))); + let (_, stats) = diff(&rojo, &verde, 4).expect("mismas dims"); + assert_eq!(stats.total_pixels, 64 * 64); + assert!( + stats.fraction > 0.95, + "esperaba casi todos los px distintos, fue frac {:.3}", + stats.fraction + ); + assert!(stats.max_delta > 150, "delta de color chico: {}", stats.max_delta); +} + +#[test] +fn diff_identico_es_cero() { + let a = with_harness(|h| h.render(swatch(rgb(120, 120, 120)), 32, 32, rgb(0, 0, 0))); + let (_, stats) = diff(&a, &a.clone(), 0).expect("mismas dims"); + assert_eq!(stats.differing_pixels, 0); + assert_eq!(stats.max_delta, 0); + assert_eq!(stats.fraction, 0.0); +} + +#[test] +fn golden_card_tier1() { + // Golden real: si esta card cambia de aspecto (regresión en sombra/gradiente/ + // borde/radio/texto) el test falla con DiffStats y escribe .actual/.diff. + assert_golden!("card_tier1", card(), 400, 240, rgb(24, 26, 34)); +} diff --git a/llimphi-text/Cargo.toml b/llimphi-text/Cargo.toml index 3579cab..2b1151e 100644 --- a/llimphi-text/Cargo.toml +++ b/llimphi-text/Cargo.toml @@ -1,24 +1,24 @@ [package] name = "llimphi-text" -description = "Text shaping for llimphi over parley (Inter default, DejaVu Sans symbol fallback)." version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true publish.workspace = true -repository.workspace = true # vello directo (no llimphi-raster): el motor de texto sólo necesita # Scene/peniko/kurbo para construir y pintar layouts — nada del Renderer ni # de llimphi-hal. Eso mantiene llimphi-text (y quien lo use: el compositor) # libre de winit, condición para correr sobre el framebuffer de wawa. +description = "Text shaping for llimphi over parley (Inter default, DejaVu Sans symbol fallback)." +repository.workspace = true [dependencies] vello = { workspace = true } parley = { workspace = true } [dev-dependencies] -llimphi-raster = { path = "../llimphi-raster" } -llimphi-hal = { path = "../llimphi-hal" } +llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" } +llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" } pollster = { workspace = true } [[example]] diff --git a/llimphi-text/src/lib.rs b/llimphi-text/src/lib.rs index 653013c..df9c1e8 100644 --- a/llimphi-text/src/lib.rs +++ b/llimphi-text/src/lib.rs @@ -70,6 +70,18 @@ struct ShapeKey { /// dentro de una palabra para que entre en la caja. Cambia el line-break, /// así que separa la entrada del caché. overflow_wrap: bool, + /// `font-feature-settings` (+ `font-kerning` plegado como `kern`) en formato + /// CSS crudo (`"liga" 1, "kern" 0`). Vacío = default de la fuente. Cambia el + /// shaping (glifos/ligaduras), así que entra en la clave del caché. + features: String, + /// `font-variation-settings` en formato CSS crudo (`"wght" 700, "wdth" 85`). + /// Vacío = ejes en su default. Cambia el shaping (instancia de la fuente + /// variable), así que entra en la clave. + variations: String, + /// `font-stretch`/`font-width` como **porcentaje** (100 = normal, <100 + /// condensada, >100 expandida). Selecciona la face condensada/expandida (o + /// el eje `wdth` de una variable) ⇒ cambia el shaping, entra en la clave. + font_width_bits: u32, } fn align_tag(a: Alignment) -> u8 { @@ -110,7 +122,7 @@ impl ShapeCache { if let Some(v) = self.hot.get(key) { return Some(v.clone()); } - // Hit frío: sacalo de cold y reinsertalo en hot (promoción). Una sola + // Hit frío: sácalo de cold y reinsertalo en hot (promoción). Una sola // clonación: el clon queda en hot, el original se devuelve al caller. if let Some(v) = self.cold.remove(key) { self.hot.insert(key.clone(), v.clone()); @@ -121,8 +133,8 @@ impl ShapeCache { fn put(&mut self, key: ShapeKey, layout: parley::Layout<()>) { if self.hot.len() >= self.cap { - // Rotá la generación: lo no reaccedido desde la última rotación - // (quedó sólo en cold) se libera acá. + // Rota la generación: lo no reaccedido desde la última rotación + // (quedó sólo en cold) se libera aquí. self.cold = std::mem::take(&mut self.hot); } self.hot.insert(key, layout); @@ -186,6 +198,12 @@ const LIBERATION_MONO: &[u8] = include_bytes!("../assets/LiberationMono.ttf"); /// que el render normal, sin volver a embeber el archivo. pub const MONO_FONT_BYTES: &[u8] = LIBERATION_MONO; +/// Bytes de la fuente **sans de UI embebida** (Inter Regular TTF). Pública para +/// que otros crates rastericen texto proporcional con la misma fuente que el +/// render normal (p. ej. `tullpu` para sus capas de texto), sin re-embeber el +/// archivo. Referenciada por familia con [`UI_SANS`]. +pub const SANS_FONT_BYTES: &[u8] = INTER_SANS; + /// Nombre de familia de la fuente monoespaciada embebida. Pasalo como /// `font_family: Some(llimphi_text::MONOSPACE)` en un [`TextBlock`] (o el /// `font_family` de `layout`) para render de ancho fijo garantizado. @@ -321,7 +339,7 @@ impl Typesetter { ) -> parley::Layout<()> { self.layout_inner( text, size_px, max_width, alignment, line_height, italic, font_family, weight, - underline, strikethrough, letter_spacing, word_spacing, false, + underline, strikethrough, letter_spacing, word_spacing, false, "", "", 100.0, ) } @@ -346,6 +364,9 @@ impl Typesetter { letter_spacing: f32, word_spacing: f32, overflow_wrap: bool, + font_features: &str, + font_variations: &str, + font_width: f32, ) -> parley::Layout<()> { // Caché de shaping: clave por todos los parámetros que determinan el // layout. En el hit clonamos el `parley::Layout` (memcpy de vectores, @@ -366,6 +387,9 @@ impl Typesetter { letter_bits: letter_spacing.to_bits(), word_bits: word_spacing.to_bits(), overflow_wrap, + features: font_features.to_string(), + variations: font_variations.to_string(), + font_width_bits: font_width.to_bits(), }; if let Some(hit) = self.cache.get(&key) { self.cache_hits += 1; @@ -379,11 +403,13 @@ impl Typesetter { builder.push_default(parley::StyleProperty::LineHeight( parley::LineHeight::FontSizeRelative(line_height), )); - if weight != 400.0 { - builder.push_default(parley::StyleProperty::FontWeight( - parley::FontWeight::new(weight), - )); - } + // SIEMPRE fijamos el peso (antes se saltaba `== 400.0` como "optimización"): + // con una fuente VARIABLE, NO setear `FontWeight` deja el peso en el DEFAULT + // del archivo (que puede ser bold), no en el 400 pedido. Ese era el "texto + // bold feo" al escribir en un campo. Pedir 400 debe dar 400. + builder.push_default(parley::StyleProperty::FontWeight( + parley::FontWeight::new(weight), + )); if italic { builder.push_default(parley::StyleProperty::FontStyle( parley::FontStyle::Italic, @@ -420,6 +446,30 @@ impl Typesetter { parley::OverflowWrap::Anywhere, )); } + // `font-feature-settings`/`font-variation-settings` (+ `font-kerning` + // plegado en la fuente de features): parley/swash parsean el string CSS + // crudo (`FontSettings::Source`, gramática `"tag" value, …`). Vacío = + // default de la fuente (no-op, no se empuja). El `&str` vive hasta el + // `build`, así que el `Cow::Borrowed` es válido (mismo patrón que + // `FontStack::Source`). + if !font_features.is_empty() { + builder.push_default(parley::StyleProperty::FontFeatures( + parley::FontSettings::Source(std::borrow::Cow::Borrowed(font_features)), + )); + } + if !font_variations.is_empty() { + builder.push_default(parley::StyleProperty::FontVariations( + parley::FontSettings::Source(std::borrow::Cow::Borrowed(font_variations)), + )); + } + // `font-stretch`/`font-width` (porcentaje, 100 = normal): selecciona la + // face condensada/expandida o el eje `wdth` de una variable. Sólo se + // empuja si difiere de normal (no-op en el caso común). + if (font_width - 100.0).abs() > 0.01 { + builder.push_default(parley::StyleProperty::FontWidth( + parley::FontWidth::from_percentage(font_width), + )); + } let mut layout = builder.build(text); layout.break_all_lines(max_width); layout.align( @@ -461,10 +511,14 @@ impl Typesetter { letter_spacing: f32, word_spacing: f32, overflow_wrap: bool, + font_features: &str, + font_variations: &str, + font_width: f32, ) -> parley::Layout<()> { let full = self.layout_inner( text, size_px, max_width, alignment, line_height, italic, font_family, weight, - underline, strikethrough, letter_spacing, word_spacing, overflow_wrap, + underline, strikethrough, letter_spacing, word_spacing, overflow_wrap, font_features, + font_variations, font_width, ); let limit = match max_lines { Some(n) if n >= 1 => n, @@ -488,9 +542,10 @@ impl Typesetter { return self.layout_inner( base, size_px, max_width, alignment, line_height, italic, font_family, weight, underline, strikethrough, letter_spacing, word_spacing, overflow_wrap, + font_features, font_variations, font_width, ); } - // Recortá graphemes del final hasta que `base…` vuelva a caber en + // Recorta graphemes del final hasta que `base…` vuelva a caber en // `limit` líneas (apilar el `…` puede empujar una palabra a una línea // extra). Acotado: cada vuelta quita ≥1 char. let mut s = base.to_string(); @@ -499,6 +554,7 @@ impl Typesetter { let lay = self.layout_inner( &candidate, size_px, max_width, alignment, line_height, italic, font_family, weight, underline, strikethrough, letter_spacing, word_spacing, overflow_wrap, + font_features, font_variations, font_width, ); if s.is_empty() || lay.lines().count() <= limit { return lay; @@ -516,6 +572,12 @@ impl Typesetter { /// la convención de parley). Pensado para syntax highlighting: shapear /// la línea entera una vez con un color por token, en vez de un layout /// por token. Sin wrap (`max_width = None`); el caller posiciona la línea. + /// + /// `font_family` es la misma familia CSS-like que toma [`Self::layout`]. + /// **No es opcional de verdad:** sin ella este camino shapeaba siempre con + /// la sans default, así que cualquier vista que pedía `.mono()` **y** color + /// por tramos perdía el ancho fijo en silencio — nada compilaba mal, sólo + /// se torcían las tablas de la consola. #[allow(clippy::too_many_arguments)] pub fn layout_runs( &mut self, @@ -528,19 +590,27 @@ impl Typesetter { weight: f32, underline: bool, strikethrough: bool, + font_family: Option<&str>, ) -> parley::Layout { let mut builder = self .runs_cx .ranged_builder(&mut self.font_cx, text, 1.0, true); + if let Some(ff) = font_family { + builder.push_default(parley::StyleProperty::FontStack( + parley::FontStack::Source(std::borrow::Cow::Borrowed(ff)), + )); + } builder.push_default(parley::StyleProperty::FontSize(size_px)); builder.push_default(parley::StyleProperty::LineHeight( parley::LineHeight::FontSizeRelative(line_height), )); - if weight != 400.0 { - builder.push_default(parley::StyleProperty::FontWeight( - parley::FontWeight::new(weight), - )); - } + // SIEMPRE fijamos el peso (antes se saltaba `== 400.0` como "optimización"): + // con una fuente VARIABLE, NO setear `FontWeight` deja el peso en el DEFAULT + // del archivo (que puede ser bold), no en el 400 pedido. Ese era el "texto + // bold feo" al escribir en un campo. Pedir 400 debe dar 400. + builder.push_default(parley::StyleProperty::FontWeight( + parley::FontWeight::new(weight), + )); builder.push_default(parley::StyleProperty::Brush(RunBrush(default_color))); if underline { builder.push_default(parley::StyleProperty::Underline(true)); @@ -598,11 +668,13 @@ impl Typesetter { builder.push_default(parley::StyleProperty::LineHeight( parley::LineHeight::FontSizeRelative(line_height), )); - if weight != 400.0 { - builder.push_default(parley::StyleProperty::FontWeight( - parley::FontWeight::new(weight), - )); - } + // SIEMPRE fijamos el peso (antes se saltaba `== 400.0` como "optimización"): + // con una fuente VARIABLE, NO setear `FontWeight` deja el peso en el DEFAULT + // del archivo (que puede ser bold), no en el 400 pedido. Ese era el "texto + // bold feo" al escribir en un campo. Pedir 400 debe dar 400. + builder.push_default(parley::StyleProperty::FontWeight( + parley::FontWeight::new(weight), + )); if italic { builder.push_default(parley::StyleProperty::FontStyle( parley::FontStyle::Italic, @@ -790,9 +862,9 @@ pub struct Measurement { } /// Construye el layout (shaping + line break + alineación) listo para medir -/// y/o pintar. Usá esta API cuando necesitás el alto **antes** de elegir el -/// origen (p. ej. centrado vertical) y no querés repetir el shaping en el -/// `draw`: medís sobre el layout retornado y luego lo pasás a +/// y/o pintar. Usa esta API cuando necesitas el alto **antes** de elegir el +/// origen (p. ej. centrado vertical) y no quieres repetir el shaping en el +/// `draw`: medís sobre el layout retornado y luego lo pasas a /// [`draw_layout`]. pub fn layout_block(ts: &mut Typesetter, block: &TextBlock<'_>) -> parley::Layout<()> { ts.layout( @@ -827,6 +899,49 @@ pub fn measurement(layout: &parley::Layout<()>) -> Measurement { } } +thread_local! { + /// Typesetter dedicado a **medir** texto en el hilo de UI. Instala la MISMA + /// fuente UI que usa el compositor, así el ancho medido coincide al píxel con + /// el glifo dibujado. El costo de cargar la fuente se paga una vez por hilo. + /// Origen: el `MEASURE_TS` privado del `text-input`, promovido aquí para que + /// cualquier widget mida glifos reales en `update`/`view` sin `&mut Typesetter`. + static MEASURE_TS: std::cell::RefCell = + std::cell::RefCell::new(Typesetter::new()); +} + +/// Ancho en px de `text` renderizado en **una sola línea** a `size_px`, medido +/// con la fuente UI real (shaping proporcional, kerning, ligaduras). Es la +/// alternativa correcta al anti-patrón `chars().count() * constante`, que finge +/// una fuente monoespaciada sobre una proporcional y desalinea todo cálculo de +/// ancho (chips, títulos de menú, botones, tooltips…). Reusa un `Typesetter` +/// thread-local, así se puede llamar desde `update`/`view` sin prestar uno. +/// +/// `text` vacío → `0.0`. Para medir un **prefijo** por columnas usar +/// `text.chars().take(n).collect::()` como argumento. +pub fn text_width(text: &str, size_px: f32) -> f32 { + if text.is_empty() { + return 0.0; + } + MEASURE_TS.with(|ts| { + let mut ts = ts.borrow_mut(); + let lp = ts.layout( + text, + size_px, + None, + Alignment::Start, + 1.2, + false, + None, + 400.0, + false, + false, + 0.0, + 0.0, + ); + measurement(&lp).width + }) +} + /// Pinta un layout ya resuelto en `scene` con `color` y un offset `origin` /// (esquina superior-izquierda del bloque). No alloca: los glifos van /// directo del iterador de parley al builder de vello. @@ -857,7 +972,7 @@ pub fn draw_layout_xf( /// (p. ej. CSS `background-clip: text`). El brush se interpreta en el espacio /// **local** del layout (origen 0,0) y `transform` lo lleva al lugar final — /// así un gradiente construido en coords (0,0)-(w,h) queda alineado con los -/// glifos. Para texto normal usá [`draw_layout_xf`] (solid = máxima compat). +/// glifos. Para texto normal usa [`draw_layout_xf`] (solid = máxima compat). pub fn draw_layout_brush_xf( scene: &mut vello::Scene, layout: &parley::Layout<()>, @@ -1012,11 +1127,37 @@ mod tests { 0.0, 0.0, false, + "", + "", + 100.0, ) .lines() .count() } + #[test] + fn text_width_es_proporcional_no_monoespaciado() { + // El anti-patrón `chars*const` daría igual ancho a "iii" y "WWW" + // (3 chars c/u). La medición real con fuente proporcional debe + // distinguirlos: 'W' es mucho más ancho que 'i'. + let angosto = text_width("iii", 13.0); + let ancho = text_width("WWW", 13.0); + assert!(angosto > 0.0 && ancho > 0.0); + assert!( + ancho > angosto * 1.5, + "WWW ({ancho}) debe ser bastante más ancho que iii ({angosto})" + ); + } + + #[test] + fn text_width_vacio_es_cero_y_crece_monotono() { + assert_eq!(text_width("", 13.0), 0.0); + let a = text_width("a", 13.0); + let ab = text_width("ab", 13.0); + let abc = text_width("abc", 13.0); + assert!(a > 0.0 && ab > a && abc > ab, "el ancho crece al agregar chars"); + } + #[test] fn clamp_limita_el_numero_de_lineas() { let mut ts = Typesetter::new(); @@ -1055,7 +1196,7 @@ mod tests { // "Hola" cabe en una línea: pedir 3 no debe inventar truncado. let lay = ts.layout_clamped( "Hola", 14.0, Some(200.0), Alignment::Start, 1.2, false, None, 400.0, Some(3), true, - false, false, 0.0, 0.0, false, + false, false, 0.0, 0.0, false, "", "", 100.0, ); assert_eq!(lay.lines().count(), 1); } @@ -1166,6 +1307,9 @@ mod tests { letter_bits: 0, word_bits: 0, overflow_wrap: false, + features: String::new(), + variations: String::new(), + font_width_bits: 100.0_f32.to_bits(), }; // Layouts vacíos como valores (sólo nos importa la presencia de claves). let dummy = parley::Layout::<()>::default; diff --git a/llimphi-text/tests/mono_ancho_fijo.rs b/llimphi-text/tests/mono_ancho_fijo.rs new file mode 100644 index 0000000..7ef16d1 --- /dev/null +++ b/llimphi-text/tests/mono_ancho_fijo.rs @@ -0,0 +1,71 @@ +//! La familia monoespaciada embebida tiene que dar **ancho fijo de verdad**. +//! +//! Es un test de regresión con evidencia numérica: si el registro de +//! Liberation Mono falla (o alguien le cambia el nombre de familia), parley +//! resuelve la proporcional y las tablas de la consola se tuercen sin que +//! nada compile mal. Acá se ve en números. + +use llimphi_text::{Typesetter, TextBlock, MONOSPACE}; +use vello::peniko::Color; + +fn ancho(ts: &mut Typesetter, texto: &str, familia: Option<&str>) -> f32 { + let mut b = TextBlock::simple(texto, 12.0, Color::WHITE, (0.0, 0.0)); + b.font_family = familia.map(str::to_string); + llimphi_text::measure(ts, &b).width +} + +#[test] +fn mono_da_el_mismo_ancho_para_anchas_y_angostas() { + let mut ts = Typesetter::new(); + let m = ancho(&mut ts, "MMMMMMMMMM", Some(MONOSPACE)); + let i = ancho(&mut ts, "iiiiiiiiii", Some(MONOSPACE)); + println!("mono M×10={m:.2} i×10={i:.2}"); + assert!( + (m - i).abs() < 0.5, + "la familia mono no da ancho fijo: M×10={m:.2} vs i×10={i:.2}" + ); +} + +#[test] +fn la_proporcional_si_difiere_el_test_anterior_no_es_trivial() { + let mut ts = Typesetter::new(); + let m = ancho(&mut ts, "MMMMMMMMMM", None); + let i = ancho(&mut ts, "iiiiiiiiii", None); + println!("sans M×10={m:.2} i×10={i:.2}"); + assert!((m - i).abs() > 2.0, "la sans mide igual: el test de mono no prueba nada"); +} + +/// El agujero real: `layout_runs` (texto con color por tramos) no recibía +/// familia, así que shapeaba con la sans aunque la vista pidiera `.mono()`. +/// Nada compilaba mal — la consola simplemente salía torcida. +#[test] +fn el_camino_multicolor_respeta_la_familia_mono() { + use llimphi_text::Alignment; + let mut ts = Typesetter::new(); + let tramos = |t: &str| -> Vec<(usize, usize, Color)> { vec![(0, t.len(), Color::WHITE)] }; + let mut medir = |t: &str| { + ts.layout_runs(t, 12.0, Color::WHITE, &tramos(t), Alignment::Start, 1.2, 400.0, false, false, Some(MONOSPACE)) + .width() + }; + let m = medir("MMMMMMMMMM"); + let i = medir("iiiiiiiiii"); + println!("runs M×10={m:.2} i×10={i:.2}"); + assert!( + (m - i).abs() < 0.5, + "el texto con tramos de color perdió el ancho fijo: M×10={m:.2} vs i×10={i:.2}" + ); +} + +#[test] +fn los_caracteres_de_caja_no_se_caen_a_otra_fuente() { + // Si Liberation Mono no tiene el glifo, parley busca en el fallback y ese + // no es mono: la tabla se tuerce justo donde más se nota. + let mut ts = Typesetter::new(); + let ascii = ancho(&mut ts, "++++++++++", Some(MONOSPACE)); + let caja = ancho(&mut ts, "┌┬┐├┼┤└┴┘─", Some(MONOSPACE)); + println!("mono +×10={ascii:.2} caja×10={caja:.2}"); + assert!( + (ascii - caja).abs() < 0.5, + "los caracteres de caja no salen en la mono: +×10={ascii:.2} vs caja×10={caja:.2}" + ); +} diff --git a/llimphi-theme/Cargo.toml b/llimphi-theme/Cargo.toml index 907715c..f04325f 100644 --- a/llimphi-theme/Cargo.toml +++ b/llimphi-theme/Cargo.toml @@ -5,9 +5,9 @@ edition.workspace = true license.workspace = true authors.workspace = true publish.workspace = true -repository.workspace = true description = "llimphi-theme — paleta compartida entre apps Llimphi. Define los slots semánticos (bg_app, fg_text, accent, etc.) en `peniko::Color`; cada widget toma su paleta del Theme vía `Palette::from_theme(&theme)`." +repository.workspace = true [dependencies] # Reexporta peniko::Color para que las apps consuman sin pull-in directo. llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" } diff --git a/llimphi-theme/src/lib.rs b/llimphi-theme/src/lib.rs index b005adc..63d88e5 100644 --- a/llimphi-theme/src/lib.rs +++ b/llimphi-theme/src/lib.rs @@ -187,7 +187,7 @@ pub mod radius { /// Paleta de la app. Slots semánticos que cubren los casos comunes /// (fondo, texto, hover, foco, acento). Los widgets reusables toman su -/// `Palette` específico desde acá vía `Palette::from_theme(&theme)`. +/// `Palette` específico desde aquí vía `Palette::from_theme(&theme)`. #[derive(Debug, Clone, Copy)] pub struct Theme { /// Nombre legible del preset — alimenta `Theme::by_name`, @@ -263,7 +263,7 @@ impl Theme { /// screenshots. Decisiones de paleta: /// /// - **Base negro cálido, no azul marino.** A diferencia de `dark()` (un - /// navy genérico, R) -> Self::Model { match msg { Msg::Zoom { factor, fx, fy } => { - // Zoom hacia el cursor: mantené fijo el punto de mundo bajo + // Zoom hacia el cursor: mantén fijo el punto de mundo bajo // (fx, fy) reajustando el paneo. new_pan = focal - rf·(focal - pan). let new_zoom = (model.zoom * factor).clamp(0.15, 12.0); let rf = new_zoom / model.zoom; // factor real tras el clamp diff --git a/llimphi-ui/examples/selectable_text.rs b/llimphi-ui/examples/selectable_text.rs index dbbc3c5..cfba050 100644 --- a/llimphi-ui/examples/selectable_text.rs +++ b/llimphi-ui/examples/selectable_text.rs @@ -1,4 +1,4 @@ -//! Texto seleccionable **fuera del editor**: arrastrá el mouse sobre los +//! Texto seleccionable **fuera del editor**: arrastra el mouse sobre los //! párrafos para resaltar y Ctrl/Cmd+C para copiar al portapapeles. La //! selección la maneja el runtime (`View::selectable(key)`) — la app no //! guarda estado de selección en su `Model`. @@ -16,7 +16,7 @@ use llimphi_ui::{App, Handle, View}; struct Demo; const PARRAFOS: [&str; 3] = [ - "Arrastrá el cursor sobre este texto para seleccionarlo. La selección \ + "Arrastra el cursor sobre este texto para seleccionarlo. La selección \ vive en el runtime de Llimphi, no en el Model de la app.", "Cada párrafo tiene su propia key estable; empezar a arrastrar en otro \ reemplaza la selección anterior. Ctrl+C (o Cmd+C en macOS) copia el \ @@ -46,7 +46,7 @@ impl App for Demo { ..Default::default() }) .text_aligned( - "Texto seleccionable (arrastrá + Ctrl/Cmd+C)", + "Texto seleccionable (arrastra + Ctrl/Cmd+C)", 22.0, Color::from_rgba8(230, 240, 250, 255), Alignment::Start, diff --git a/llimphi-ui/examples/stress_boundary.rs b/llimphi-ui/examples/stress_boundary.rs new file mode 100644 index 0000000..ff8e7ab --- /dev/null +++ b/llimphi-ui/examples/stress_boundary.rs @@ -0,0 +1,182 @@ +//! stress_boundary — Bloque 23 (RepaintBoundary). Contraparte de `stress_grid`: +//! el mismo grid grande, pero **estático** y envuelto en `.repaint_boundary(1)`, +//! con un header que SÍ cambia cada frame (fuerza el repintado del frame). +//! +//! Con el boundary, el subárbol estático se rasteriza una vez y se **reusa** +//! (append de la sub-escena cacheada) — así el `paint` (walk + encode en CPU, +//! la etapa dominante a muchos nodos) se desploma, mientras el header se pinta +//! fresco. `LLIMPHI_PERF` muestra `boundary N hits / 1 miss`. +//! +//! ```bash +//! # sin caché (todo se re-encodea cada frame): +//! LLIMPHI_PERF=1 cargo run -p llimphi-ui --example stress_grid --release -- --nodes 50000 +//! # con caché (el grid estático se reusa): +//! LLIMPHI_PERF=1 cargo run -p llimphi-ui --example stress_boundary --release -- --nodes 50000 +//! ``` +//! Comparás la etapa `paint µs`: mucho más baja en stress_boundary. + +use llimphi_ui::llimphi_layout::taffy::prelude::*; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{App, Handle, View}; +use std::time::Duration; + +#[derive(Clone)] +enum Msg { + Tick, +} + +struct Stress; + +struct Model { + frame: u64, + nodes: usize, + cols: usize, + quit_after: u64, + /// Frames REALMENTE renderizados (incrementado en `view`). El auto-quit se + /// basa en esto, no en los Ticks: a muchos nodos el render es más lento que + /// el tick de 16 ms, y contar Ticks cerraría la app antes de que el + /// perfilador (que reporta cada N frames renderizados) imprima una línea. + renders: std::cell::Cell, +} + +fn arg_u64(name: &str, default: u64) -> u64 { + let mut args = std::env::args(); + while let Some(a) = args.next() { + if a == name { + if let Some(v) = args.next() { + if let Ok(n) = v.parse::() { + return n; + } + } + } + } + default +} + +/// El grid **estático** (colores fijos por índice, NO por frame): idéntico entre +/// frames → cache-hit del RepaintBoundary tras el primero. +fn grid_estatico(nodes: usize, cols: usize) -> View { + let rows = nodes.div_ceil(cols); + let mut row_views = Vec::with_capacity(rows); + let mut made = 0usize; + for _r in 0..rows { + let mut cells = Vec::with_capacity(cols); + for _c in 0..cols { + if made >= nodes { + break; + } + let hue = (made % 255) as u8; + cells.push( + View::new(Style { + size: Size { + width: length(6.0_f32), + height: length(6.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(hue, 90, 200u8.wrapping_sub(hue), 255)) + .radius(1.0), + ); + made += 1; + } + row_views.push( + View::new(Style { + flex_direction: FlexDirection::Row, + gap: Size { + width: length(1.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(cells), + ); + } + View::new(Style { + flex_direction: FlexDirection::Column, + gap: Size { + width: length(0.0_f32), + height: length(1.0_f32), + }, + ..Default::default() + }) + .children(row_views) + // ← el límite de repintado: subárbol estático, cacheable. + .repaint_boundary(1) +} + +impl App for Stress { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · stress_boundary" + } + + fn init(handle: &Handle) -> Self::Model { + let nodes = arg_u64("--nodes", 5_000).max(1) as usize; + let cols = (nodes as f64).sqrt().ceil() as usize; + let quit_after = arg_u64("--frames", 240); + handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick); + eprintln!( + "[stress_boundary] nodes={nodes} cols={cols} — LLIMPHI_PERF=1; \ + el grid va en un repaint_boundary (cache-hit tras el 1er frame)" + ); + Model { + frame: 0, + nodes, + cols, + quit_after, + renders: std::cell::Cell::new(0), + } + } + + fn update(mut model: Self::Model, msg: Self::Msg, handle: &Handle) -> Self::Model { + match msg { + Msg::Tick => { + model.frame = model.frame.wrapping_add(1); + if model.quit_after != 0 && model.renders.get() >= model.quit_after { + handle.quit(); + } + } + } + model + } + + fn view(model: &Self::Model) -> View { + model.renders.set(model.renders.get() + 1); + // Header que CAMBIA cada frame → obliga a repintar el frame (si no, + // la retención de frame entero saltearía todo). Va FUERA del boundary. + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(30, 34, 44, 255)) + .text( + format!("frame {}", model.frame), + 16.0, + Color::from_rgba8(210, 214, 222, 255), + ); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(4.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(16, 18, 24, 255)) + .children(vec![header, grid_estatico(model.nodes, model.cols)]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-ui/examples/stress_grid.rs b/llimphi-ui/examples/stress_grid.rs new file mode 100644 index 0000000..4c7e936 --- /dev/null +++ b/llimphi-ui/examples/stress_grid.rs @@ -0,0 +1,158 @@ +//! stress_grid — Bloque 21 (PLAN-NUCLEO-RETENIDO.md): app sintética de N nodos +//! para medir el camino completo de redraw con el perfilador `LLIMPHI_PERF`. +//! +//! Cada tick (~16 ms) muta el modelo → invalida `last_render` → fuerza un frame +//! de camino COMPLETO (mount + layout + paint), que es justo lo que el +//! perfilador muestrea. El color de cada celda depende del frame, así el árbol +//! cambia de verdad cada cuadro (nada de cache-hit). +//! +//! Uso (baseline del Bloque 21): +//! ```bash +//! LLIMPHI_PERF=1 cargo run -p llimphi-ui --example stress_grid --release -- --nodes 5000 +//! LLIMPHI_PERF=1 cargo run -p llimphi-ui --example stress_grid --release -- --nodes 50000 +//! LLIMPHI_PERF=1 cargo run -p llimphi-ui --example stress_grid --release -- --nodes 200000 +//! ``` + +use llimphi_ui::llimphi_layout::taffy::prelude::*; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{App, Handle, View}; +use std::time::Duration; + +#[derive(Clone)] +enum Msg { + Tick, +} + +struct Stress; + +struct Model { + frame: u64, + nodes: usize, + cols: usize, + /// Se cierra solo tras estos frames RENDERIZADOS (para no imprimir + /// infinito). `0` = nunca (corre hasta cerrar la ventana). Ver `--frames`. + quit_after: u64, + /// Frames realmente renderizados (incrementado en `view`). El auto-quit se + /// basa en esto, no en los Ticks: a muchos nodos el render es más lento que + /// el tick de 16 ms y contar Ticks cerraría antes de que el perfilador + /// (reporta cada N renders) imprima una línea. + renders: std::cell::Cell, +} + +/// Lee un flag `-- ` de la línea de comando. +fn arg_u64(name: &str, default: u64) -> u64 { + let mut args = std::env::args(); + while let Some(a) = args.next() { + if a == name { + if let Some(v) = args.next() { + if let Ok(n) = v.parse::() { + return n; + } + } + } + } + default +} + +impl App for Stress { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · stress_grid" + } + + fn init(handle: &Handle) -> Self::Model { + let nodes = arg_u64("--nodes", 5_000).max(1) as usize; + let cols = (nodes as f64).sqrt().ceil() as usize; + // Default: se cierra solo tras 240 frames (~4 reportes de 60). `--frames 0` + // corre indefinido (cierras la ventana a mano). + let quit_after = arg_u64("--frames", 240); + // Feed continuo: garantiza frames de camino completo para muestrear. + handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick); + eprintln!( + "[stress_grid] nodes={nodes} cols={cols} — LLIMPHI_PERF=1; \ + sale solo a los {quit_after} frames (--frames 0 = infinito)" + ); + Model { + frame: 0, + nodes, + cols, + quit_after, + renders: std::cell::Cell::new(0), + } + } + + fn update(mut model: Self::Model, msg: Self::Msg, handle: &Handle) -> Self::Model { + match msg { + Msg::Tick => { + model.frame = model.frame.wrapping_add(1); + if model.quit_after != 0 && model.renders.get() >= model.quit_after { + handle.quit(); + } + } + } + model + } + + fn view(model: &Self::Model) -> View { + model.renders.set(model.renders.get() + 1); + let cols = model.cols.max(1); + let rows = model.nodes.div_ceil(cols); + let f = model.frame; + let mut row_views = Vec::with_capacity(rows); + let mut made = 0usize; + for _r in 0..rows { + let mut cells = Vec::with_capacity(cols); + for _c in 0..cols { + if made >= model.nodes { + break; + } + let i = made as u64; + // Color animado por frame → el árbol cambia de verdad cada cuadro. + let hue = ((i.wrapping_add(f)) % 255) as u8; + cells.push( + View::new(Style { + size: Size { + width: length(6.0_f32), + height: length(6.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(hue, 90, 200u8.wrapping_sub(hue), 255)) + .radius(1.0), + ); + made += 1; + } + row_views.push( + View::new(Style { + flex_direction: FlexDirection::Row, + gap: Size { + width: length(1.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(cells), + ); + } + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(1.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(16, 18, 24, 255)) + .children(row_views) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-ui/examples/stress_memo.rs b/llimphi-ui/examples/stress_memo.rs new file mode 100644 index 0000000..e489991 --- /dev/null +++ b/llimphi-ui/examples/stress_memo.rs @@ -0,0 +1,186 @@ +//! stress_memo — Bloque 25 (memo). Como `stress_boundary`, pero el grid estático +//! va en un `.memo(key, deps, builder)` en vez de `.repaint_boundary()`. Con +//! `deps` constante, tras el primer frame el builder NO se llama: el `View` del +//! grid no se reconstruye, no se re-monta y no se re-layoutea (se reusa el +//! `Mounted` cacheado). Así se desploman `view`, `mount` Y `layout` para el +//! subárbol — no sólo `paint` (que era lo del Bloque 23). +//! +//! ```bash +//! LLIMPHI_PERF=20 cargo run -p llimphi-ui --example stress_grid --release -- --nodes 50000 --frames 0 +//! LLIMPHI_PERF=20 cargo run -p llimphi-ui --example stress_memo --release -- --nodes 50000 --frames 0 +//! ``` +//! En stress_memo, `view`/`mount`/`layout` deberían ser mucho más bajos (el grid +//! sólo se construye una vez). + +use llimphi_ui::llimphi_layout::taffy::prelude::*; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{App, Handle, View}; +use std::time::Duration; + +#[derive(Clone)] +enum Msg { + Tick, +} + +struct Stress; + +struct Model { + frame: u64, + nodes: usize, + cols: usize, + quit_after: u64, + renders: std::cell::Cell, +} + +fn arg_u64(name: &str, default: u64) -> u64 { + let mut args = std::env::args(); + while let Some(a) = args.next() { + if a == name { + if let Some(v) = args.next() { + if let Ok(n) = v.parse::() { + return n; + } + } + } + } + default +} + +/// Grid estático (colores fijos por índice). Lo produce el builder del memo, que +/// sólo se llama cuando `deps` cambia (aquí nunca) → se construye una sola vez. +fn grid_estatico(nodes: usize, cols: usize) -> View { + let rows = nodes.div_ceil(cols); + let mut row_views = Vec::with_capacity(rows); + let mut made = 0usize; + for _r in 0..rows { + let mut cells = Vec::with_capacity(cols); + for _c in 0..cols { + if made >= nodes { + break; + } + let hue = (made % 255) as u8; + cells.push( + View::new(Style { + size: Size { + width: length(6.0_f32), + height: length(6.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(hue, 90, 200u8.wrapping_sub(hue), 255)) + .radius(1.0), + ); + made += 1; + } + row_views.push( + View::new(Style { + flex_direction: FlexDirection::Row, + gap: Size { + width: length(1.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(cells), + ); + } + View::new(Style { + flex_direction: FlexDirection::Column, + gap: Size { + width: length(0.0_f32), + height: length(1.0_f32), + }, + ..Default::default() + }) + .children(row_views) +} + +impl App for Stress { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · stress_memo" + } + + fn init(handle: &Handle) -> Self::Model { + let nodes = arg_u64("--nodes", 5_000).max(1) as usize; + let cols = (nodes as f64).sqrt().ceil() as usize; + let quit_after = arg_u64("--frames", 240); + handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick); + eprintln!( + "[stress_memo] nodes={nodes} cols={cols} — LLIMPHI_PERF=1; \ + el grid va en un .memo (se construye UNA vez)" + ); + Model { + frame: 0, + nodes, + cols, + quit_after, + renders: std::cell::Cell::new(0), + } + } + + fn update(mut model: Self::Model, msg: Self::Msg, handle: &Handle) -> Self::Model { + match msg { + Msg::Tick => { + model.frame = model.frame.wrapping_add(1); + if model.quit_after != 0 && model.renders.get() >= model.quit_after { + handle.quit(); + } + } + } + model + } + + fn view(model: &Self::Model) -> View { + model.renders.set(model.renders.get() + 1); + // Header que cambia cada frame (fuerza el repintado). Fuera del memo. + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(30, 34, 44, 255)) + .text( + format!("frame {}", model.frame), + 16.0, + Color::from_rgba8(210, 214, 222, 255), + ); + + let nodes = model.nodes; + let cols = model.cols; + // Contenedor del grid con tamaño DEFINIDO (contrato del memo) + `.memo` + // con deps constante (0) → el grid se construye una sola vez. + let grid = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(520.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .memo(1, 0, move || grid_estatico(nodes, cols)); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(4.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(16, 18, 24, 255)) + .children(vec![header, grid]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-ui/src/a11y.rs b/llimphi-ui/src/a11y.rs index b7ad22a..2db1f47 100644 --- a/llimphi-ui/src/a11y.rs +++ b/llimphi-ui/src/a11y.rs @@ -124,7 +124,7 @@ pub fn build_tree( node.add_action(Action::Click); } - // Hijos: rango [idx+1, subtree_end) — pero acá necesitamos sólo los + // Hijos: rango [idx+1, subtree_end) — pero aquí necesitamos sólo los // hijos DIRECTOS, no descendientes. Los hijos directos son los nodos // cuyo padre es este: en el orden pre-orden con `subtree_end`, los // hijos directos del nodo idx son los nodos h tales que h.parent == idx. diff --git a/llimphi-ui/src/alloc.rs b/llimphi-ui/src/alloc.rs new file mode 100644 index 0000000..8acb6f8 --- /dev/null +++ b/llimphi-ui/src/alloc.rs @@ -0,0 +1,130 @@ +// alloc.rs — contador de asignaciones de heap, para el perfilador del Bloque 21 +// (`PLAN-NUCLEO-RETENIDO.md`). Responde la pregunta que el contador de µs no +// puede responder: *cuántas veces por frame tocamos el allocator, y cuántos +// bytes pedimos*. Es la línea base honesta antes de discutir arenas, pools o +// reuso de buffers — sin este número, "bajamos las asignaciones" es fe. +// +// Cómo se enciende (dos caminos, ninguno cuesta nada apagado): +// +// 1. **Feature `perf-alloc`** de este crate: instala `ContadorAlloc` como +// `#[global_allocator]` del programa entero. No hay que tocar la app. +// +// cargo run -p llimphi-ui --example stress_grid --release \ +// --features perf-alloc +// +// 2. **A mano**, si la app ya elige su allocator (jemalloc, mimalloc…) o si +// preferís no meter una feature del runtime en el binario: +// +// #[global_allocator] +// static A: llimphi_ui::alloc::ContadorAlloc = llimphi_ui::alloc::ContadorAlloc; +// +// Sin ninguno de los dos, los contadores quedan en cero y `LLIMPHI_PERF` +// reporta `alloc n/a` en vez de mentir con ceros. +// +// Costo cuando SÍ está instalado: dos `fetch_add` relajados por asignación. No +// es gratis — por eso va detrás de feature y no en el camino por defecto. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; + +static ASIGNACIONES: AtomicU64 = AtomicU64::new(0); +static BYTES: AtomicU64 = AtomicU64::new(0); +static LIBERACIONES: AtomicU64 = AtomicU64::new(0); + +/// Allocator que cuenta y delega en el del sistema. Ver el encabezado del +/// módulo para las dos formas de instalarlo. +pub struct ContadorAlloc; + +unsafe impl GlobalAlloc for ContadorAlloc { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + ASIGNACIONES.fetch_add(1, Relaxed); + BYTES.fetch_add(l.size() as u64, Relaxed); + unsafe { System.alloc(l) } + } + + unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { + ASIGNACIONES.fetch_add(1, Relaxed); + BYTES.fetch_add(l.size() as u64, Relaxed); + unsafe { System.alloc_zeroed(l) } + } + + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + LIBERACIONES.fetch_add(1, Relaxed); + unsafe { System.dealloc(p, l) } + } + + unsafe fn realloc(&self, p: *mut u8, l: Layout, nuevo: usize) -> *mut u8 { + // Un `realloc` es un viaje al allocator igual que un `alloc` (el + // `Vec::push` que duplica capacidad pasa por acá), así que cuenta como + // asignación. En bytes contamos sólo el CRECIMIENTO: si no, un `Vec` + // que se duplica N veces inflaría el total con memoria que ya teníamos. + ASIGNACIONES.fetch_add(1, Relaxed); + BYTES.fetch_add(nuevo.saturating_sub(l.size()) as u64, Relaxed); + unsafe { System.realloc(p, l, nuevo) } + } +} + +#[cfg(feature = "perf-alloc")] +#[global_allocator] +static CONTADOR_GLOBAL: ContadorAlloc = ContadorAlloc; + +/// Lectura acumulada del proceso: `(asignaciones, bytes pedidos, liberaciones)`. +/// El perfilador toma dos y resta; el delta es lo que costó el tramo. +#[inline] +pub fn snapshot() -> (u64, u64, u64) { + ( + ASIGNACIONES.load(Relaxed), + BYTES.load(Relaxed), + LIBERACIONES.load(Relaxed), + ) +} + +/// `true` si el contador está realmente instalado como allocator global. Se +/// deduce de que haya contado aunque sea una asignación: para cuando corre el +/// primer frame ya van miles. Sin esto no podríamos distinguir "cero +/// asignaciones" (imposible hoy) de "nadie está contando". +#[inline] +pub fn instalado() -> bool { + ASIGNACIONES.load(Relaxed) > 0 +} + +// Los tests usan el contador COMO objeto (no como allocator global) y miden +// DELTAS de los contadores. Van en un solo `#[test]` porque los contadores son +// estáticos del proceso: dos tests en paralelo se pisarían los deltas. Por lo +// mismo se saltan cuando la feature `perf-alloc` está encendida — ahí el +// allocator es global y todo el resto del proceso suma en medio de la medición. +#[cfg(all(test, not(feature = "perf-alloc")))] +mod tests { + use super::*; + + #[test] + fn cuenta_alloc_dealloc_y_realloc() { + let l = Layout::from_size_align(4096, 8).unwrap(); + let (a0, b0, d0) = snapshot(); + unsafe { + let p = ContadorAlloc.alloc(l); + assert!(!p.is_null()); + ContadorAlloc.dealloc(p, l); + } + let (a1, b1, d1) = snapshot(); + assert_eq!(a1 - a0, 1, "una asignación"); + assert_eq!(d1 - d0, 1, "una liberación"); + assert_eq!(b1 - b0, 4096, "bytes pedidos"); + + let l = Layout::from_size_align(1024, 8).unwrap(); + unsafe { + let p = ContadorAlloc.alloc(l); + let (a0, b0, _) = snapshot(); + let p = ContadorAlloc.realloc(p, l, 3072); + let (a1, b1, _) = snapshot(); + assert_eq!(a1 - a0, 1, "el realloc es un viaje al allocator"); + assert_eq!(b1 - b0, 2048, "sólo el crecimiento, no los 3072"); + // Encoger no debe restar ni desbordar el contador de bytes. + let l3 = Layout::from_size_align(3072, 8).unwrap(); + let p = ContadorAlloc.realloc(p, l3, 512); + let (_, b2, _) = snapshot(); + assert_eq!(b2, b1, "encoger no suma bytes"); + ContadorAlloc.dealloc(p, Layout::from_size_align(512, 8).unwrap()); + } + } +} diff --git a/llimphi-ui/src/eventloop/helpers.rs b/llimphi-ui/src/eventloop/helpers.rs index 38bc059..cf638cb 100644 --- a/llimphi-ui/src/eventloop/helpers.rs +++ b/llimphi-ui/src/eventloop/helpers.rs @@ -257,15 +257,16 @@ pub(super) fn key_is_char(key: &Key, c: char) -> bool { /// Copia texto al portapapeles del sistema (best-effort). Con la feature /// `clipboard` usa `arboard`; sin backend (headless) o sin la feature es no-op -/// silencioso — nunca panica. -#[cfg(feature = "clipboard")] +/// silencioso — nunca panica. En Android no hay arboard (la dep va +/// target-gated en Cargo.toml): siempre no-op. +#[cfg(all(feature = "clipboard", not(target_os = "android")))] pub(super) fn copy_to_clipboard(text: &str) { if let Ok(mut cb) = arboard::Clipboard::new() { let _ = cb.set_text(text.to_string()); } } -#[cfg(not(feature = "clipboard"))] +#[cfg(any(not(feature = "clipboard"), target_os = "android"))] pub(super) fn copy_to_clipboard(_text: &str) {} /// Resuelve los [`View::layout_builder`] del árbol de la app en dos pasadas diff --git a/llimphi-ui/src/eventloop/input.rs b/llimphi-ui/src/eventloop/input.rs index 8b5cd34..ff5cdfa 100644 --- a/llimphi-ui/src/eventloop/input.rs +++ b/llimphi-ui/src/eventloop/input.rs @@ -39,6 +39,92 @@ pub(super) fn lookup_click_hit( }) } +/// ¿Diag de click prendido? (`MEDIA_CLICK_DIAG=1`). Cacheado: un `env::var` +/// una sola vez, no por click. +fn click_diag_enabled() -> bool { + use std::sync::OnceLock; + static D: OnceLock = OnceLock::new(); + *D.get_or_init(|| std::env::var("MEDIA_CLICK_DIAG").is_ok()) +} + +/// Reconstruye un `RenderCache` fresco desde el modelo vigente (mount + layout +/// del árbol principal y del overlay, **sin pintar**) y lo deja en +/// `state.last_render`. Se llama al tope de cada handler de press cuando el +/// cache llegó invalidado (`None`). +/// +/// Por qué: `last_render` se anula en CADA `Msg` (el tick de video incluido) y +/// sólo vuelve a `Some` tras un redraw completo. Con video tickeando + redraw +/// throttled, la ventana en que `last_render` está `None` es enorme, y un click +/// que cae ahí **se saltaba todos los hit-tests** de `handle_*_press` (sólo el +/// `on_click` plano tenía fallback; `on_click_screen`/foco/selección/gestos no) +/// → click no-op silencioso: el engranaje de config de media no abría, F2 sí. +/// Reconstruir acá hace que TODOS los caminos de hit-test vean el árbol del +/// modelo vigente, sin depender de que exista un frame recién pintado. +/// +/// Anula `state.retained`: montamos el árbol pero NO lo pintamos, así que la +/// invariante de retención ("`last_render` Some ⇒ nada visual cambió desde el +/// paint") ya no vale — el próximo redraw debe repintar completo el modelo +/// vigente en vez de re-presentar el frame viejo. +pub(super) fn build_hit_cache(state: &mut crate::RuntimeState) { + let (w, h) = state.surface.size(); + let vp = (w as f32, h as f32); + // Mismo resolve de LayoutBuilder que el redraw, para que el hit-test vea + // los hijos producidos por builders diferidos. + let view = resolve_layout_builders::( + state.model.as_ref().expect("model"), + vp, + &mut state.typesetter, + ); + let overlay_view = A::view_overlay(state.model.as_ref().expect("model")); + // `LayoutTree` efímero propio: no tocamos `state.layout`/`retained_layout` + // (los usa el redraw retenido) para no ensuciar su reuso incremental. + let mut layout = LayoutTree::new(); + let mounted: Mounted = mount(&mut layout, view); + let computed = { + let ts = &mut state.typesetter; + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, vp, |nid, known, avail| match tmap.get(&nid) { + Some(tm) => measure_text_node(ts, tm, known, avail), + None => llimphi_layout::taffy::Size::ZERO, + }) + .expect("layout") + }; + let overlay = overlay_view.map(|ov| { + let mut olay = LayoutTree::new(); + let omounted: Mounted = mount(&mut olay, ov); + let ocomputed = { + let ts = &mut state.typesetter; + let tmap = &omounted.text_measures; + olay.compute_with_measure(omounted.root, vp, |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(ts, tm, known, avail), + None => llimphi_layout::taffy::Size::ZERO, + } + }) + .expect("layout overlay") + }; + let ohover = + hit_test_hover(&omounted, &ocomputed, state.cursor.x as f32, state.cursor.y as f32); + crate::OverlayCache { mounted: omounted, computed: ocomputed, hover_idx: ohover } + }); + let hover_idx = if overlay.is_some() { + None + } else { + hit_test_hover(&mounted, &computed, state.cursor.x as f32, state.cursor.y as f32) + }; + state.last_render = Some(crate::RenderCache { + mounted, + computed, + hover_idx, + drop_hover_idx: None, + overlay, + }); + // Montamos sin pintar → el frame retenido ya no representa el modelo + // vigente. Invalidar la retención fuerza un redraw completo (correcto). + state.retained = None; +} + impl Runtime { /// Maneja un `WindowEvent` de la ventana primaria. La delegación a /// secciones internas sigue el orden de frecuencia (más común primero). @@ -89,9 +175,56 @@ impl Runtime { WindowEvent::CursorMoved { position, .. } => { self.handle_cursor_moved(position); } + WindowEvent::Focused(focused) => { + // Perder el foco con un drag activo casi siempre significa un + // release perdido (soltado fuera de la ventana / grab que el + // compositor no entregó). Cerrarlo aquí evita el drag colgado + // que se come todos los CursorMoved para siempre. + if !focused { + if let Some(drag) = state.drag.take() { + let end_msg = match &drag.handler { + DragHandlerKind::Delta(h) => h(DragPhase::End, 0.0, 0.0), + DragHandlerKind::DeltaAt(h, lx0, ly0) => { + h(DragPhase::End, 0.0, 0.0, *lx0, *ly0) + } + DragHandlerKind::Velocity(h) => { + h(DragPhase::End, 0.0, 0.0, 0.0, 0.0) + } + }; + if let Some(msg) = end_msg { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + } + state.last_render = None; + state.window.request_redraw(); + } + } + // El foco de la ventana entró/salió. La app puede replegarse a + // una barra, pausar animaciones, etc. (distinto del foco de nodo + // que maneja `on_focus`). + if let Some(msg) = + A::on_window_focus(state.model.as_ref().expect("model"), focused) + { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } WindowEvent::ModifiersChanged(mods) => { let Some(state) = self.state.as_mut() else { return }; state.modifiers = mods.state().into(); + // La app puede querer saberlo (Shift+click / Ctrl+click de una + // lista: el click no lleva los modificadores, así que se + // guardan en el modelo). `None` = no le importa, sin repintar. + if let Some(msg) = + A::on_modifiers(state.model.as_ref().expect("model"), state.modifiers) + { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } } WindowEvent::Ime(ime) if A::ime_allowed() => { self.handle_ime(ime); @@ -153,11 +286,94 @@ impl Runtime { self.state.as_mut().expect("state en redraw"), &self.handle, ); + // El redraw pudo detectar un DeviceLost (Iris Xe reset de output, + // cambio de VT). No lo puede resolver solo — rehacer el device + // toca también las surfaces secundarias, que viven en `Runtime`. + if self + .state + .as_ref() + .is_some_and(|s| s.needs_device_rebuild) + { + self.rebuild_gpu_after_device_loss(); + } } _ => {} } } + /// Reconstruye **todo el stack GPU** tras un [`llimphi_hal::SurfaceError::DeviceLost`]: + /// nuevo `Hal` (instance+adapter+device+queue) + `WinitSurface` primaria, + /// renderer vello y los tres compositores; avisa a la app + /// ([`App::on_gpu_reset`]) para que rehaga sus recursos propios; rehace las + /// surfaces de todas las ventanas secundarias contra el device nuevo (su + /// `WinitSurface` cacheaba un clone del device muerto); invalida las caches + /// de frame y pide redraw. Es la recuperación real que reemplaza el bandaid + /// de forzar backend GL: sirve para CUALQUIER GPU/driver que pierda el device + /// (Iris Xe reset de output, TDR de Windows, cambio de VT, suspend/resume), + /// no sólo esta máquina. Si el rebuild falla (GPU aún no lista), deja la + /// marca y reintenta en el próximo frame. + pub(super) fn rebuild_gpu_after_device_loss(&mut self) { + let Some(state) = self.state.as_mut() else { return }; + state.needs_device_rebuild = false; + let window = state.window.clone(); + let (hal, surface) = match llimphi_hal::Hal::recreate_for_window(window) { + Ok(pair) => pair, + Err(e) => { + eprintln!( + "llimphi-ui: rebuild de device tras DeviceLost falló: {e}; reintento al próximo frame" + ); + state.needs_device_rebuild = true; + state.window.request_redraw(); + return; + } + }; + let renderer = match llimphi_raster::Renderer::new(&hal) { + Ok(r) => r, + Err(e) => { + eprintln!( + "llimphi-ui: renderer vello no reinicializó tras DeviceLost: {e}; reintento al próximo frame" + ); + state.needs_device_rebuild = true; + state.window.request_redraw(); + return; + } + }; + state.overlay_compositor = llimphi_hal::OverlayCompositor::new(&hal.device); + state.blur_compositor = llimphi_hal::BlurCompositor::new(&hal.device); + state.color_filter_compositor = llimphi_hal::ColorFilterCompositor::new(&hal.device); + state.surface = surface; + state.renderer = renderer; + state.hal = hal; + // La app rehace SUS recursos GPU (video/voxel/pipelines directos) contra + // el device nuevo. UI pura (vello) = no-op por defecto. + if let Some(model) = state.model.as_mut() { + A::on_gpu_reset(model, &state.hal); + } + // Invalidar toda cache de frame: el contenido se re-pinta desde cero. + state.last_render = None; + state.retained = None; + state.window.request_redraw(); + // Rehacer las surfaces secundarias contra el device nuevo (campos + // disjuntos: `self.state` en shared, `self.secondaries` en mut). + let new_hal = &self.state.as_ref().expect("state recién puesto").hal; + for sec in &mut self.secondaries { + match llimphi_hal::WinitSurface::new(new_hal, sec.window.clone()) { + Ok(s) => { + sec.surface = s; + sec.last_render = None; + sec.window.request_redraw(); + } + Err(e) => eprintln!( + "llimphi-ui: no se pudo rehacer la surface de una ventana secundaria tras DeviceLost: {e}" + ), + } + } + eprintln!( + "llimphi-ui: stack GPU reconstruido tras DeviceLost (device+surface+renderer+compositores+{} secundaria/s).", + self.secondaries.len() + ); + } + // ── Cursor moved ───────────────────────────────────────────────────────── fn handle_cursor_moved( @@ -167,6 +383,21 @@ impl Runtime { let Some(state) = self.state.as_mut() else { return }; let prev_cursor = state.cursor; state.cursor = position; + // Señal cruda de actividad de mouse: se dispara SIEMPRE, sin depender + // del hit-test de nodos ni de que haya un frame cacheado (a diferencia + // de `on_pointer_move_at`, que requiere `last_render` válido y puede + // caer en el hueco donde un Tick lo invalidó). Es lo que un OSC estilo + // mpv necesita para revelarse de forma fiable al mover el mouse. + if let Some(msg) = A::on_cursor_moved( + state.model.as_ref().expect("model"), + position.x as f32, + position.y as f32, + ) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } // Selección de texto en curso: extender el foco al punto actual. if let Some(tsel) = state.selection.filter(|s| s.dragging) { let info = state @@ -192,6 +423,15 @@ impl Runtime { state.pending_long_press = None; } } + // `on_click` armado: si el cursor se alejó del origen del press más que + // el umbral, el usuario quiso arrastrar/barrer, no cliquear → cancelar. + if let Some(p) = state.pending_click.as_ref() { + let dx = position.x - p.origin.x; + let dy = position.y - p.origin.y; + if (dx * dx + dy * dy).sqrt() > CLICK_MOVE_CANCEL { + state.pending_click = None; + } + } // Drag activo: dispatchear delta al handler + actualizar // tracking del drop target hovereado (solo si hay payload). if let Some(drag) = state.drag.as_mut() { @@ -208,7 +448,7 @@ impl Runtime { } DragHandlerKind::Velocity(h) => { // Durante Move, vx=vy=0 — la velocidad sólo - // tiene sentido al End. Acá registramos el + // tiene sentido al End. Aquí registramos el // sample para esa medición. let now = std::time::Instant::now(); drag.samples.push_back((now, dx as f64, dy as f64)); @@ -319,35 +559,38 @@ impl Runtime { // re-render (p.ej. el submenú que se abre/cierra). state.last_render = None; } - // Movimiento posicional: dispatch `on_pointer_move_at` en CADA move - // mientras el cursor está sobre el nodo (no sólo al entrar, como - // `on_pointer_enter`). Base del thumbnail/drawer que sigue al - // cursor. Extraemos el handler (Arc) + rect en un scope para soltar - // el borrow del cache antes de mutar el modelo. - let move_call: Option<(ClickAtFn, f32, f32, f32, f32)> = - state.last_render.as_ref().and_then(|cache| { - let (mounted, computed) = match cache.overlay.as_ref() { - Some(ov) => (&ov.mounted, &ov.computed), - None => (&cache.mounted, &cache.computed), - }; - let px = position.x as f32; - let py = position.y as f32; - let i = hit_test_pointer_move(mounted, computed, px, py)?; - let node = &mounted.nodes[i]; - let h = node.on_pointer_move_at.clone()?; - let r = computed.get(node.id)?; - Some((h, px - r.x, py - r.y, r.w, r.h)) - }); - if let Some((h, lx, ly, w, hh)) = move_call { - if let Some(msg) = h(lx, ly, w, hh) { - let model = state.model.take().expect("model"); - state.model = Some(A::update(model, msg, &self.handle)); - state.last_render = None; - state.window.request_redraw(); - } - } let _ = prev_cursor; } + // Movimiento posicional: dispatch `on_pointer_move_at` en CADA move + // mientras el cursor está sobre el nodo (no sólo al entrar, como + // `on_pointer_enter`). Base del thumbnail/drawer que sigue al + // cursor. Corre CON o SIN drag activo: un drag colgado (release + // perdido fuera de la ventana) no debe comerse la señal de + // actividad del mouse (p. ej. el reveal del OSC de media). + // Extraemos el handler (Arc) + rect en un scope para soltar el + // borrow del cache antes de mutar el modelo. + let move_call: Option<(ClickAtFn, f32, f32, f32, f32)> = + state.last_render.as_ref().and_then(|cache| { + let (mounted, computed) = match cache.overlay.as_ref() { + Some(ov) => (&ov.mounted, &ov.computed), + None => (&cache.mounted, &cache.computed), + }; + let px = position.x as f32; + let py = position.y as f32; + let i = hit_test_pointer_move(mounted, computed, px, py)?; + let node = &mounted.nodes[i]; + let h = node.on_pointer_move_at.clone()?; + let r = computed.get(node.id)?; + Some((h, px - r.x, py - r.y, r.w, r.h)) + }); + if let Some((h, lx, ly, w, hh)) = move_call { + if let Some(msg) = h(lx, ly, w, hh) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } } // ── IME ────────────────────────────────────────────────────────────────── @@ -376,6 +619,43 @@ impl Runtime { event: llimphi_hal::winit::event::KeyEvent, ) { let Some(state) = self.state.as_mut() else { return }; + // ── Auto-repeat sintético: seguimiento de la tecla sostenida ── + // winit no siempre genera los eventos repetidos en Wayland; el runtime los + // sintetiza en `about_to_wait`. Aquí sólo se ARMA/DESARMA el seguimiento. + { + use llimphi_hal::winit::keyboard::{Key, NamedKey}; + if event.repeat { + // winit YA repite: apagamos la síntesis propia (no duplicar). + state.winit_repeats = true; + state.held_key = None; + } else if event.state == ElementState::Released { + if state + .held_key + .as_ref() + .is_some_and(|h| h.event.logical_key == event.logical_key) + { + state.held_key = None; + } + } else if !state.winit_repeats { + // Sólo repiten las teclas de escribir/editar (no modificadores, Tab…). + let repetible = event.text.is_some() + || matches!( + event.logical_key, + Key::Named( + NamedKey::Backspace + | NamedKey::Delete + | NamedKey::ArrowLeft + | NamedKey::ArrowRight + | NamedKey::ArrowUp + | NamedKey::ArrowDown + ) + ); + state.held_key = repetible.then(|| crate::HeldKey { + event: event.clone(), + next: std::time::Instant::now() + crate::KEY_REPEAT_DELAY, + }); + } + } // Tab / Shift+Tab mueven el foco entre nodos `focusable`, // que administra el runtime. Sólo intercepta si hay // enfocables y en Pressed; si no, cae al `on_key` normal @@ -448,6 +728,7 @@ impl Runtime { &mut self, delta: MouseScrollDelta, ) { + let handle = self.handle.clone(); let Some(state) = self.state.as_mut() else { return }; // Convención winit: LineDelta es líneas; PixelDelta es // píxeles físicos (touchpads). En CSS y aquí, positivo @@ -462,6 +743,30 @@ impl Runtime { y: -(p.y as f32) / 20.0, }, }; + // Caché de hit-test invalidada (un update recién corrió y el redraw + // aún no la reconstruyó): **diferir** — se acumula el delta y se + // procesa al final del próximo redraw, contra el árbol fresco. Sin + // esto, una app con ticks frecuentes (media a 30/s) invalida la caché + // antes de casi cada rueda y el scroll por wheel queda muerto + // (chain=0 sistemático, cazado con LLIMPHI_DEBUG_WHEEL en vivo). + if state.last_render.is_none() { + let acc = state.pending_wheel.get_or_insert(WheelDelta { x: 0.0, y: 0.0 }); + acc.x += wd.x; + acc.y += wd.y; + state.window.request_redraw(); + return; + } + Self::process_wheel(state, &handle, wd); + } + + /// Procesa una rueda contra el árbol de hit-test vigente. Asociada (sin + /// `self`) para que también el redraw pueda **replay-ear** la rueda + /// diferida (ver `pending_wheel`) con el árbol recién armado. + pub(super) fn process_wheel( + state: &mut crate::RuntimeState, + handle: &crate::Handle, + wd: WheelDelta, + ) { let cursor = (state.cursor.x as f32, state.cursor.y as f32); // Ctrl+rueda = **pinch-to-zoom sintético** (camino universal de // desktop: Wayland/Windows no emiten el gesto de pinch del @@ -481,7 +786,7 @@ impl Runtime { let factor = 1.1_f32.powf(-wd.y); if let Some(msg) = h(GesturePhase::Update, factor, fx, fy) { let model = state.model.take().expect("model"); - state.model = Some(A::update(model, msg, &self.handle)); + state.model = Some(A::update(model, msg, handle)); state.last_render = None; state.window.request_redraw(); } @@ -498,37 +803,34 @@ impl Runtime { // El overlay tiene prioridad, igual que con clicks. Se // extraen los handlers en un scope para soltar el borrow // del cache antes de mutar el modelo. - let scroll_chain: Vec> = - if let Some(cache) = state.last_render.as_ref() { - let (m, c) = match cache.overlay.as_ref() { - Some(ov) => (&ov.mounted, &ov.computed), - None => (&cache.mounted, &cache.computed), - }; - hit_test_scroll_chain(m, c, cursor.0, cursor.1) - .into_iter() - .filter_map(|i| m.nodes[i].on_scroll.clone()) - .collect() - } else { - Vec::new() + let mut msgs: Vec = if let Some(cache) = state.last_render.as_ref() { + let (m, c) = match cache.overlay.as_ref() { + Some(ov) => (&ov.mounted, &ov.computed), + None => (&cache.mounted, &cache.computed), }; - let mut msg: Option = None; - for h in &scroll_chain { - if let Some(m) = h(wd.x, wd.y) { - msg = Some(m); - break; - } - } - if msg.is_none() { - msg = A::on_wheel( + let chain = hit_test_scroll_chain(m, c, cursor.0, cursor.1); + // Reparto parcial: el hijo consume hasta su tope y el sobrante + // sigue al ancestro — puede producir VARIOS Msg por evento. + consume_scroll_chain(m, &chain, wd.x, wd.y) + } else { + Vec::new() + }; + if msgs.is_empty() { + if let Some(m) = A::on_wheel( state.model.as_ref().expect("model"), wd, cursor, state.modifiers, - ); + ) { + msgs.push(m); + } } - if let Some(msg) = msg { - let model = state.model.take().expect("model"); - state.model = Some(A::update(model, msg, &self.handle)); + if !msgs.is_empty() { + let mut model = state.model.take().expect("model"); + for msg in msgs { + model = A::update(model, msg, handle); + } + state.model = Some(model); state.last_render = None; state.window.request_redraw(); } @@ -618,7 +920,101 @@ impl Runtime { fn handle_left_press(&mut self) { let Some(state) = self.state.as_mut() else { return }; + // Cache frío (típico durante reproducción de video: el tick anula + // `last_render` en cada frame y el redraw throttled lo deja `None` al + // momento del click): lo reconstruimos desde el modelo vigente ANTES de + // resolver el click. Sin esto, todos los hit-tests de abajo se saltaban + // y el click era un no-op silencioso (el engranaje de config no abría; + // F2 sí, porque el teclado no depende de `last_render`). Ver + // `build_hit_cache`. + let was_cold = state.last_render.is_none(); + if was_cold { + build_hit_cache::(state); + } let cursor = state.cursor; + // ── DIAG (opt-in `MEDIA_CLICK_DIAG=1`): por qué un click no llega al + // nodo que se ve arriba. Reporta si el cache llegó frío (reconstruido), + // el nodo que GANA el hit-test (rect + handlers) y TODAS las capas + // `over` bajo el cursor — si el gear (capa `over`) está bajo el cursor + // pero no gana, es la asimetría paint/hit. ─ + if click_diag_enabled() { + let (cx, cy) = (cursor.x as f32, cursor.y as f32); + eprintln!( + "[click-diag] handle_left_press cursor=({cx:.0},{cy:.0}) cache={}", + if was_cold { "frío→reconstruido" } else { "vigente" }, + ); + if let Some(cache) = state.last_render.as_ref() { + let (m, c) = match cache.overlay.as_ref() { + Some(ov) => (&ov.mounted, &ov.computed), + None => (&cache.mounted, &cache.computed), + }; + let win = hit_test_click(m, c, cx, cy); + match win { + Some(i) => { + let n = &m.nodes[i]; + let r = c.get(n.id); + eprintln!( + "[click-diag] cursor=({cx:.0},{cy:.0}) GANA idx={i} rect={r:?} \ + on_click={} on_click_screen={} over_layer={}", + n.on_click.is_some(), + n.on_click_screen.is_some(), + n.over_layer, + ); + } + None => eprintln!("[click-diag] cursor=({cx:.0},{cy:.0}) sin hit"), + } + // Capas `over` cuyo rect contiene el cursor (candidatas visibles + // "arriba" que el hit-test plano puede estar ignorando). + for (i, n) in m.nodes.iter().enumerate() { + if !n.over_layer { + continue; + } + if let Some(r) = c.get(n.id) { + let inside = cx >= r.x && cx < r.x + r.w && cy >= r.y && cy < r.y + r.h; + if inside { + eprintln!( + "[click-diag] over idx={i} subtree_end={} rect=({:.0},{:.0},{:.0},{:.0}) \ + → contiene el cursor", + n.subtree_end, r.x, r.y, r.w, r.h, + ); + } + } + } + } + } + // ── Select/dropdown: `on_click_screen` (rect absoluto del nodo) ───── + // Un nodo con `on_click_screen` (el disparador de un select) abre su + // popup. El handler recibe el RECT ABSOLUTO del nodo `(x, y, w, h)` en + // coords de ventana — así el menú puede anclarse al borde inferior del + // disparador y copiar su ancho. Aditivo y con prioridad de overlay; si + // dispara, cortamos (el disparador no lleva on_click). + let screen_hit = state.last_render.as_ref().and_then(|cache| { + let (m, c) = match cache.overlay.as_ref() { + Some(ov) => (&ov.mounted, &ov.computed), + None => (&cache.mounted, &cache.computed), + }; + hit_test_click(m, c, cursor.x as f32, cursor.y as f32).and_then(|i| { + let node = &m.nodes[i]; + let handler = node.on_click_screen.clone()?; + // El rect ABSOLUTO del nodo. Si el lookup falla o sale degenerado + // (w≈0), caemos al cursor (posición conocida-buena) en vez de a + // (0,0) —que anclaba el popup a la esquina de la ventana—. + let r = match c.get(node.id) { + Some(r) if r.w > 0.5 => (r.x, r.y, r.w, r.h), + _ => (cursor.x as f32, cursor.y as f32, 0.0, 0.0), + }; + Some((handler, r)) + }) + }); + if let Some((handler, (rx, ry, rw, rh))) = screen_hit { + if let Some(msg) = handler(rx, ry, rw, rh) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + return; + } // Click-to-focus: si el click cae sobre un nodo enfocable, // el runtime le da el foco ANTES de procesar la acción de // click. Extraemos el id en un scope (suelta el borrow del @@ -853,10 +1249,12 @@ impl Runtime { state.window.request_redraw(); } } else if let Some((_, _, _, _, Some(msg), _, _)) = idx_and_action { - let model = state.model.take().expect("model"); - state.model = Some(A::update(model, msg, &self.handle)); - state.last_render = None; - state.window.request_redraw(); + // `on_click` plano: NO se dispara en el press. Semántica de + // escritorio — se arma y se dispara al soltar (`handle_left_release`) + // si el cursor no se alejó más que `CLICK_MOVE_CANCEL`. Así el click + // no salta en mousedown y un arrastre minúsculo accidental sigue + // contando como click. No invalidamos el cache: nada visual cambió. + state.pending_click = Some(PendingClick { msg, origin: cursor }); } } @@ -864,6 +1262,11 @@ impl Runtime { fn handle_middle_press(&mut self) { let Some(state) = self.state.as_mut() else { return }; + // Cache frío (ver `build_hit_cache` / `handle_left_press`): reconstruir + // antes de hit-testear para que el click no se pierda durante video. + if state.last_render.is_none() { + build_hit_cache::(state); + } // Middle-click: dispatcha `on_middle_click` del nodo // bajo cursor si lo declaró. La capa overlay tiene // prioridad (mismo razonamiento que el left/right click). @@ -894,14 +1297,21 @@ impl Runtime { fn handle_right_press(&mut self) { let Some(state) = self.state.as_mut() else { return }; + // Cache frío (ver `build_hit_cache` / `handle_left_press`): reconstruir + // antes de hit-testear para que el menú contextual no se pierda durante + // video. + if state.last_render.is_none() { + build_hit_cache::(state); + } // Right-click: dispatcheamos `on_right_click` o // `on_right_click_at` del nodo bajo cursor. La capa // overlay tiene prioridad (mismo razonamiento que el // left-click). Nodos sin handler de right-click no // reaccionan — no "filtramos" al left. let cursor = state.cursor; + #[allow(clippy::type_complexity)] let lookup = - |m: &Mounted, c: &ComputedLayout| -> Option<(Option, Option>, (f32, f32, f32, f32))> { + |m: &Mounted, c: &ComputedLayout| -> Option<(Option, Option>, Option>, (f32, f32, f32, f32))> { hit_test_right_click(m, c, cursor.x as f32, cursor.y as f32).map(|i| { let node = &m.nodes[i]; let rect = c @@ -911,6 +1321,7 @@ impl Runtime { ( node.on_right_click.clone(), node.on_right_click_at.clone(), + node.on_right_click_screen.clone(), rect, ) }) @@ -924,14 +1335,14 @@ impl Runtime { } else { None }; - if let Some((msg_opt, at_opt, (ox, oy, rw, rh))) = hit { - let msg = if let Some(handler) = at_opt { - handler( - cursor.x as f32 - ox, - cursor.y as f32 - oy, - rw, - rh, - ) + if let Some((msg_opt, at_opt, screen_opt, (ox, oy, rw, rh))) = hit { + // `on_right_click_screen` (coords absolutas de ventana, para menús + // anclados al puntero) gana sobre `on_right_click_at` (coords locales + // al nodo, para grillas/canvas), y ambos sobre el `on_right_click` plano. + let msg = if let Some(handler) = screen_opt { + handler(cursor.x as f32, cursor.y as f32, rw, rh) + } else if let Some(handler) = at_opt { + handler(cursor.x as f32 - ox, cursor.y as f32 - oy, rw, rh) } else { msg_opt }; @@ -949,8 +1360,18 @@ impl Runtime { fn handle_left_release(&mut self) { let Some(state) = self.state.as_mut() else { return }; // El botón se soltó antes de vencer el long-press → no era un - // long-press (fue un click/drag); cancelá el gesto armado. + // long-press (fue un click/drag); cancela el gesto armado. state.pending_long_press = None; + // `on_click` armado en el press y no cancelado por movimiento → este es + // el click real: se dispara ahora, al soltar. Es mutuamente excluyente + // con `drag` (rama else-if en el press), así que no colisiona con el + // cierre del drag de más abajo. + if let Some(pending) = state.pending_click.take() { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, pending.msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } // Fin del arrastre de selección: la selección queda viva (para // Ctrl/Cmd+C) pero deja de extenderse con el cursor. if let Some(tsel) = state.selection.as_mut() { diff --git a/llimphi-ui/src/eventloop/mod.rs b/llimphi-ui/src/eventloop/mod.rs index 601bd8d..0d0d21a 100644 --- a/llimphi-ui/src/eventloop/mod.rs +++ b/llimphi-ui/src/eventloop/mod.rs @@ -15,11 +15,72 @@ mod a11y_rt; mod helpers; mod input; +mod perf; mod redraw; mod secondary; use super::*; +/// Traza un hito de arranque con su epoch-ms si `LLIMPHI_TIMING` está puesto. +/// Sirve para perfilar el camino frío hasta el primer frame (init de wgpu/Hal, +/// surface, renderer) sin afectar a nadie en condiciones normales. Off por +/// defecto: una sola lectura de env, cero costo si no está. +pub(crate) fn timing_hito(hito: &str) { + if std::env::var_os("LLIMPHI_TIMING").is_some() { + let ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + eprintln!("[llimphi-timing] {hito} epoch_ms={ms}"); + } +} + +/// Avisa a winit que estamos por presentar. **Es lo que engancha la app al +/// ritmo del compositor**, y hay que llamarlo justo antes de cada `present`. +/// +/// En Wayland, winit sólo pide un `wl_surface.frame` si la app llama a esto +/// (`window/mod.rs::pre_present_notify` → `request_frame_callback`). Y su +/// bucle (`event_loop/mod.rs:486`) suprime el `RedrawRequested` mientras haya +/// un callback pedido sin llegar. Sin esta llamada, `frame_callback_state()` +/// nunca vale `Requested`, el gate **nunca** se activa y la app repinta contra +/// su propio temporizador **ignorando por completo al compositor**: sigue +/// gastando GPU en otro escritorio, tapada o con la pantalla apagada. +/// +/// Hallado 2026-07-21 midiendo `media-app`: oculto en otro escritorio rendía +/// los mismos 4 fps y el mismo 10% de un core que visible. El gate de frames +/// de `mirada` no tenía efecto porque **ningún cliente llimphi escuchaba**. +/// +/// No bloquea ni cambia el modo de presentación (seguimos en `Mailbox`, ver +/// `choose_present_mode`): sólo adjunta el callback para que winit pueda +/// pacear. Y no pierde repintados — winit consulta el gate **antes** de +/// consumir el flag de `request_redraw`, así que un redraw suprimido queda +/// pendiente y sale en cuanto llega el callback. +/// +/// Escotilla: `LLIMPHI_FRAME_CALLBACK=0` vuelve al comportamiento viejo (libre, +/// sin pacing) sin recompilar. Está porque esto afecta a TODAS las apps del +/// escritorio y el modo de falla —una ventana visible que no repinta— se +/// arregla más rápido con una variable de entorno que con un build. +pub(crate) fn notificar_present(window: &llimphi_hal::winit::window::Window) { + use std::sync::OnceLock; + static ACTIVO: OnceLock = OnceLock::new(); + if *ACTIVO.get_or_init(|| { + !matches!( + std::env::var("LLIMPHI_FRAME_CALLBACK").as_deref(), + Ok("0") | Ok("no") | Ok("off") + ) + }) { + window.pre_present_notify(); + } +} + +/// Marca el PRIMER present (cualquiera de los dos caminos) — el frame que de +/// verdad llega a la pantalla. `Once` para que sólo dispare una vez. +pub(crate) fn timing_primer_present() { + use std::sync::Once; + static UNA_VEZ: Once = Once::new(); + UNA_VEZ.call_once(|| timing_hito("primer-present")); +} + pub(crate) fn build_window_attributes() -> WindowAttributes { let (w, h) = A::initial_size(); let attrs = WindowAttributes::default() @@ -68,6 +129,7 @@ impl ApplicationHandler> for Runtime { if self.state.is_some() { return; } + timing_hito("resumed:entrada"); let window = event_loop .create_window(build_window_attributes::()) .expect("create window"); @@ -91,9 +153,24 @@ impl ApplicationHandler> for Runtime { let a11y_adapter = accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, a11y_proxy); let a11y_tree_id = accesskit::TreeId(uuid::Uuid::new_v4()); - let hal = pollster::block_on(Hal::new(None)).expect("hal"); - let surface = WinitSurface::new(&hal, window.clone()).expect("surface"); + timing_hito("resumed:antes-de-Hal"); + // Desktop: adapter primero, surface después (la secuencia clásica). + #[cfg(not(target_os = "android"))] + let (hal, surface) = { + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + timing_hito("resumed:Hal-listo"); + let surface = WinitSurface::new(&hal, window.clone()).expect("surface"); + (hal, surface) + }; + // Android: surface-first obligatorio. La surface debe existir ANTES + // del request_adapter(compatible_surface=Some(...)) y no puede + // recrearse sobre la misma ANativeWindow (ERROR_NATIVE_WINDOW_IN_USE_KHR). + // `recreate_for_window` ya implementa exactamente ese orden. + #[cfg(target_os = "android")] + let (hal, surface) = + Hal::recreate_for_window(window.clone()).expect("hal+surface android"); let renderer = Renderer::new(&hal).expect("renderer"); + timing_hito("resumed:renderer-listo"); let overlay_compositor = llimphi_hal::OverlayCompositor::new(&hal.device); let blur_compositor = llimphi_hal::BlurCompositor::new(&hal.device); let color_filter_compositor = llimphi_hal::ColorFilterCompositor::new(&hal.device); @@ -108,7 +185,12 @@ impl ApplicationHandler> for Runtime { overlay_compositor, blur_compositor, color_filter_compositor, - model: Some(A::init(&self.handle)), + // Primer arranque → `A::init`; re-entrada tras un `suspended` + // (Android background→foreground) → el modelo parkeado sigue vivo. + model: Some(match self.parked_model.take() { + Some(m) => m, + None => A::init(&self.handle), + }), cursor: PhysicalPosition::new(0.0, 0.0), modifiers: Modifiers::default(), typesetter, @@ -123,12 +205,20 @@ impl ApplicationHandler> for Runtime { size_anim_registry: llimphi_compositor::SizeAnimRegistry::new(), hero_registry: llimphi_compositor::HeroRegistry::new(), ripple_registry: llimphi_compositor::RippleRegistry::new(), + repaint_cache: llimphi_compositor::RepaintCache::new(), + retained_layout: None, + memo_registry: llimphi_compositor::MemoRegistry::new(), last_tap: None, pending_long_press: None, + pending_click: None, + pending_wheel: None, + held_key: None, + winit_repeats: false, retained: None, selection: None, a11y_adapter, a11y_tree_id, + needs_device_rebuild: false, }); // Sincroniza el factor de escala inicial (el de la ventana recién // creada) ANTES del primer render: así una app que dependa del DPI @@ -163,12 +253,31 @@ impl ApplicationHandler> for Runtime { self.secondaries.remove(pos); } } + UserEvent::SetMinimized(min) => { + if let Some(state) = self.state.as_ref() { + state.window.set_minimized(min); + } + } UserEvent::A11y(ev) => { self.handle_a11y_event(ev); } } } + /// Android/iOS/web: la app pasó a background y la NativeWindow deja de + /// ser válida — TODO el estado GPU (surface, device, texturas, renderer) + /// debe soltarse. El modelo de la app se parquea y `resumed` lo retoma + /// al volver, recreando el stack gráfico desde cero contra la ventana + /// nueva. En desktop winit nunca llama esto. + fn suspended(&mut self, _event_loop: &ActiveEventLoop) { + if let Some(mut st) = self.state.take() { + self.parked_model = st.model.take(); + } + // Las secundarias también cuelgan de la NativeWindow/device viejos. + self.secondaries.clear(); + self.active_touch = None; + } + fn window_event( &mut self, event_loop: &ActiveEventLoop, @@ -181,6 +290,55 @@ impl ApplicationHandler> for Runtime { self.handle_secondary_event(idx, event); return; } + // Touch → puntero: en móvil los eventos llegan como `Touch`, no como + // CursorMoved/MouseInput. Traducimos el PRIMER dedo a la secuencia de + // mouse equivalente (move+press / move / move+release) y lo ruteamos + // por el mismo handler primario — tap=click, arrastre=drag, sin + // duplicar ninguna lógica de hit-test/gestos. Dedos extra se ignoran + // hasta que el activo suelte (multi-touch real queda para después). + if let WindowEvent::Touch(t) = &event { + use llimphi_hal::winit::event::{ElementState, MouseButton, TouchPhase}; + let es_activo = match self.active_touch { + Some(id) => id == t.id, + None => t.phase == TouchPhase::Started, + }; + if !es_activo { + return; + } + let device_id = t.device_id; + let position = t.location; + let moved = WindowEvent::CursorMoved { device_id, position }; + match t.phase { + TouchPhase::Started => { + self.active_touch = Some(t.id); + self.handle_primary_window_event(event_loop, moved); + self.handle_primary_window_event( + event_loop, + WindowEvent::MouseInput { + device_id, + state: ElementState::Pressed, + button: MouseButton::Left, + }, + ); + } + TouchPhase::Moved => { + self.handle_primary_window_event(event_loop, moved); + } + TouchPhase::Ended | TouchPhase::Cancelled => { + self.active_touch = None; + self.handle_primary_window_event(event_loop, moved); + self.handle_primary_window_event( + event_loop, + WindowEvent::MouseInput { + device_id, + state: ElementState::Released, + button: MouseButton::Left, + }, + ); + } + } + return; + } self.handle_primary_window_event(event_loop, event); } @@ -197,21 +355,44 @@ impl ApplicationHandler> for Runtime { let Some(state) = self.state.as_mut() else { return; }; - match state.pending_long_press.as_ref() { - Some(p) => { - if std::time::Instant::now() >= p.deadline { - let handler = state.pending_long_press.take().expect("pending").handler; - event_loop.set_control_flow(ControlFlow::Wait); - if let Some(msg) = handler.invoke() { - let model = state.model.take().expect("model"); - state.model = Some(A::update(model, msg, &self.handle)); - state.last_render = None; - state.window.request_redraw(); - } - } else { - event_loop.set_control_flow(ControlFlow::WaitUntil(p.deadline)); - } + let now = std::time::Instant::now(); + // 1. Auto-repeat de tecla: si la sostenida venció, re-emitíla (repeat=true) + // y reprograma el próximo disparo al intervalo. Sólo si winit no repite él. + if !state.winit_repeats && state.held_key.as_ref().is_some_and(|h| now >= h.next) { + let winit_ev = state.held_key.as_ref().expect("held").event.clone(); + state.held_key.as_mut().expect("held").next = now + KEY_REPEAT_INTERVAL; + let ev = crate::KeyEvent { + key: winit_ev.logical_key.clone(), + state: crate::KeyState::Pressed, + text: winit_ev.text.as_ref().map(|t| t.to_string()), + modifiers: state.modifiers, + repeat: true, + }; + if let Some(msg) = A::on_key(state.model.as_ref().expect("model"), &ev) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); } + } + // 2. Long-press: si venció, dispará su `Msg` (lógica existente). + if state.pending_long_press.as_ref().is_some_and(|p| now >= p.deadline) { + let handler = state.pending_long_press.take().expect("pending").handler; + if let Some(msg) = handler.invoke() { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + // 3. Dormir hasta el deadline pendiente MÁS CERCANO (repeat o long-press); + // sin ninguno, `Wait` (hasta el próximo evento). + let lp = state.pending_long_press.as_ref().map(|p| p.deadline); + let hk = (!state.winit_repeats) + .then(|| state.held_key.as_ref().map(|h| h.next)) + .flatten(); + match [lp, hk].into_iter().flatten().min() { + Some(d) => event_loop.set_control_flow(ControlFlow::WaitUntil(d)), None => event_loop.set_control_flow(ControlFlow::Wait), } } diff --git a/llimphi-ui/src/eventloop/perf.rs b/llimphi-ui/src/eventloop/perf.rs new file mode 100644 index 0000000..43d045b --- /dev/null +++ b/llimphi-ui/src/eventloop/perf.rs @@ -0,0 +1,228 @@ +// eventloop/perf.rs — Bloque 21 (PLAN-NUCLEO-RETENIDO.md): perfilador por frame +// del camino COMPLETO de redraw (view → mount → layout → paint → raster → gpu → +// present). Off por defecto. Se enciende con `LLIMPHI_PERF=1` (o `=N` para +// reportar cada N frames; default 60). Cero costo apagado: una sola lectura de +// env cacheada, y ni siquiera se toman timestamps. +// +// El camino `cache_hit` (retención de frame entero) NO se muestrea a propósito: +// este contador mide sólo el trabajo que los Bloques 22–25 buscan reducir, así +// que la regla del repo (§8: certificar con números, no screenshots) tiene una +// base honesta de antes/después. + +use std::cell::RefCell; +use std::sync::OnceLock; +use std::time::Instant; + +/// Intervalo de reporte en frames. `None` = perfilador apagado. Cacheado: una +/// sola lectura de env por proceso. +fn interval() -> Option { + static CELL: OnceLock> = OnceLock::new(); + *CELL.get_or_init(|| match std::env::var("LLIMPHI_PERF") { + // El valor es el intervalo de reporte en frames. `1`/no-numérico ⇒ 60 + // (default): así `LLIMPHI_PERF=1` significa "encendido, reporta cada 60" + // y no floodea una línea por frame. `=120` = cada 120; `=0` = apagado. + Ok(v) if !v.is_empty() && v != "0" => { + Some(v.parse::().ok().filter(|&n| n >= 2).unwrap_or(60)) + } + _ => None, + }) +} + +/// `true` si el perfilador está activo. +#[inline] +pub(crate) fn enabled() -> bool { + interval().is_some() +} + +/// Toma un timestamp sólo si el perfilador está activo (`on`). Cuando está +/// apagado devuelve `None` y no toca el reloj. +#[inline] +pub(crate) fn mark(on: bool) -> Option { + if on { + Some(Instant::now()) + } else { + None + } +} + +/// Microsegundos entre dos marcas. `0.0` si alguna es `None` (perfilador off). +#[inline] +pub(crate) fn us(a: Option, b: Option) -> f64 { + match (a, b) { + (Some(a), Some(b)) => b.saturating_duration_since(a).as_secs_f64() * 1e6, + _ => 0.0, + } +} + +/// Lectura del contador de asignaciones (`crate::alloc`) en un punto del frame. +#[derive(Clone, Copy, Default)] +pub(crate) struct AllocMark { + asignaciones: u64, + bytes: u64, +} + +/// Toma una lectura del contador de heap sólo si el perfilador está activo. +/// Cuando está apagado no toca ni los atómicos. +#[inline] +pub(crate) fn alloc_mark(on: bool) -> Option { + if on { + let (asignaciones, bytes, _libres) = crate::alloc::snapshot(); + Some(AllocMark { + asignaciones, + bytes, + }) + } else { + None + } +} + +/// Asignaciones entre dos lecturas. `0` si alguna es `None` (perfilador off) o +/// si nadie instaló el contador (entonces ambas leen el mismo cero). +#[inline] +pub(crate) fn allocs(a: Option, b: Option) -> u64 { + match (a, b) { + (Some(a), Some(b)) => b.asignaciones.saturating_sub(a.asignaciones), + _ => 0, + } +} + +/// Bytes pedidos al heap entre dos lecturas (crecimiento neto en los realloc). +#[inline] +pub(crate) fn alloc_bytes(a: Option, b: Option) -> u64 { + match (a, b) { + (Some(a), Some(b)) => b.bytes.saturating_sub(a.bytes), + _ => 0, + } +} + +/// Una muestra de frame: microsegundos por etapa + conteo de nodos. +#[derive(Clone, Copy, Default)] +pub(crate) struct FrameSample { + pub view_us: f64, + pub mount_us: f64, + pub layout_us: f64, + /// Incluye reconciles (anim/hero/size), mount+layout del overlay, hit-tests + /// y el paint de la escena principal (fills/text/ghosts/ripple/overlay). + pub paint_us: f64, + /// Rasterización vello de la escena principal (`Renderer::render`). + pub raster_us: f64, + /// Pasadas GPU directas + backdrop/filter + composites + submit. + pub gpu_us: f64, + /// Desglose del tramo `gpu` para la capa `over` (subárboles `View::over`), + /// que es el camino caro cuando una app tiene fondo/OSC encima del canvas: + /// crear la scratch del tamaño del frame · rasterizarla con vello · el + /// `submit` final. Se reportan sólo si alguno es no-cero. + /// Construir la escena vello de la capa over: corre los `paint_with` de + /// la app (p. ej. el fondo animado de media). Puro CPU. + pub over_paint_us: f64, + pub over_tex_us: f64, + pub over_raster_us: f64, + pub submit_us: f64, + pub present_us: f64, + pub total_us: f64, + pub n_nodes: usize, + /// Aciertos/fallos ACUMULADOS del caché de RepaintBoundary (Bloque 23) al + /// cierre del frame. El reporte muestra el delta del lote (hits/misses en + /// esta ventana) — sólo si hubo actividad de boundary. + pub boundary_hits: u64, + pub boundary_misses: u64, + /// `true` si este frame reusó el layout retenido (Bloque 24) en vez de + /// hacer `clear`+rebuild. El reporte muestra cuántos frames del lote lo + /// hicieron. + pub layout_reused: bool, + /// Asignaciones de heap del frame completo y desglose por las mismas etapas + /// que los µs (`view` incluye el `view()` de la app + los builders diferidos + /// + el size-anim; `paint` incluye reconciles, overlay, hit-tests y la + /// construcción de la `Scene`). Todo cero si nadie instaló + /// [`crate::alloc::ContadorAlloc`] — el reporte lo distingue con `n/a`. + pub allocs: u64, + pub allocs_view: u64, + pub allocs_mount: u64, + pub allocs_layout: u64, + pub allocs_paint: u64, + /// Bytes pedidos al heap en el frame (crecimiento neto en los `realloc`). + pub alloc_bytes: u64, +} + +thread_local! { + static BUF: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Acumula una muestra y, cada `interval()` frames, imprime media + p95 por +/// etapa a stderr. No-op si el perfilador está apagado. +pub(crate) fn submit(s: FrameSample) { + let Some(n) = interval() else { return }; + BUF.with(|b| { + let mut b = b.borrow_mut(); + b.push(s); + if b.len() as u64 >= n { + report(&b); + b.clear(); + } + }); +} + +fn report(samples: &[FrameSample]) { + let n = samples.len(); + if n == 0 { + return; + } + let stat = |get: fn(&FrameSample) -> f64| -> (f64, f64) { + let mut v: Vec = samples.iter().map(get).collect(); + let mean = v.iter().sum::() / n as f64; + v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let idx = (((n as f64) * 0.95) as usize).min(n - 1); + (mean, v[idx]) + }; + let nodes = samples.iter().map(|s| s.n_nodes).max().unwrap_or(0); + let (t_m, t_p) = stat(|s| s.total_us); + let (v_m, v_p) = stat(|s| s.view_us); + let (mo_m, mo_p) = stat(|s| s.mount_us); + let (l_m, l_p) = stat(|s| s.layout_us); + let (p_m, p_p) = stat(|s| s.paint_us); + let (r_m, r_p) = stat(|s| s.raster_us); + let (g_m, g_p) = stat(|s| s.gpu_us); + let (pr_m, pr_p) = stat(|s| s.present_us); + // Delta de boundary-cache en la ventana del lote (acumulado último − primero). + let bh = samples.last().unwrap().boundary_hits - samples.first().unwrap().boundary_hits; + let bm = samples.last().unwrap().boundary_misses - samples.first().unwrap().boundary_misses; + let boundary = if bh + bm > 0 { + format!(" | boundary {bh} hits / {bm} miss") + } else { + String::new() + }; + let reused = samples.iter().filter(|s| s.layout_reused).count(); + // Desglose del tramo `gpu`: sólo si la capa over estuvo activa en el lote. + let (op_m, _) = stat(|s| s.over_paint_us); + let (ot_m, _) = stat(|s| s.over_tex_us); + let (or_m, _) = stat(|s| s.over_raster_us); + let (sb_m, _) = stat(|s| s.submit_us); + let over = if op_m + ot_m + or_m + sb_m > 0.0 { + format!(" | over-paint {op_m:.0} over-tex {ot_m:.0} over-raster {or_m:.0} submit {sb_m:.0}") + } else { + String::new() + }; + // Asignaciones por frame. Sin contador instalado no inventamos ceros: `n/a` + // + cómo encenderlo. + let alloc = if crate::alloc::instalado() { + let (a_m, a_p) = stat(|s| s.allocs as f64); + let (b_m, _) = stat(|s| s.alloc_bytes as f64); + let (av_m, _) = stat(|s| s.allocs_view as f64); + let (am_m, _) = stat(|s| s.allocs_mount as f64); + let (al_m, _) = stat(|s| s.allocs_layout as f64); + let (ap_m, _) = stat(|s| s.allocs_paint as f64); + format!( + " | alloc {a_m:.0}/{a_p:.0} ({:.0} KB) [view {av_m:.0} mount {am_m:.0} \ + layout {al_m:.0} paint {ap_m:.0}]", + b_m / 1024.0 + ) + } else { + " | alloc n/a (--features llimphi-ui/perf-alloc)".to_string() + }; + eprintln!( + "[llimphi-perf] n={n} nodes={nodes} | total {t_m:.0}/{t_p:.0} | \ + view {v_m:.0}/{v_p:.0} mount {mo_m:.0}/{mo_p:.0} layout {l_m:.0}/{l_p:.0} \ + paint {p_m:.0}/{p_p:.0} raster {r_m:.0}/{r_p:.0} gpu {g_m:.0}/{g_p:.0} \ + present {pr_m:.0}/{pr_p:.0} µs (media/p95) | layout-reuso {reused}/{n}{boundary}{over}{alloc}" + ); +} diff --git a/llimphi-ui/src/eventloop/redraw.rs b/llimphi-ui/src/eventloop/redraw.rs index 112e8c2..7eefe42 100644 --- a/llimphi-ui/src/eventloop/redraw.rs +++ b/llimphi-ui/src/eventloop/redraw.rs @@ -41,6 +41,7 @@ pub(super) fn handle_redraw( !r.animating && !r.rippling && !r.has_overlay + && !r.has_gpu_paint && (r.w, r.h) == state.surface.size() }); if cache_hit { @@ -51,11 +52,19 @@ pub(super) fn handle_redraw( .render(&state.hal, &state.scene, &frame, palette::css::BLACK) .is_ok() { + super::timing_primer_present(); + super::notificar_present(&state.window); state.surface.present(frame, &state.hal); return; } // render falló → cae al camino completo } + Err(llimphi_hal::SurfaceError::DeviceLost) => { + // Device irrecuperable: marca y salí. El dispatcher rehace el + // stack GPU tras el redraw (`rebuild_gpu_after_device_loss`). + state.needs_device_rebuild = true; + return; + } Err(_) => { /* surface efímera → camino completo */ } } } @@ -68,7 +77,7 @@ pub(super) fn handle_redraw( state.last_title = Some(t); } } - // Posicioná la ventana de candidatos del IME junto al caret + // Posiciona la ventana de candidatos del IME junto al caret // (sólo con IME activo y si la app reporta el área). if A::ime_allowed() { if let Some((x, y, w, h)) = @@ -85,6 +94,12 @@ pub(super) fn handle_redraw( } let frame = match state.surface.acquire() { Ok(f) => f, + Err(llimphi_hal::SurfaceError::DeviceLost) => { + // Device irrecuperable: marca y salí; el dispatcher rehace el stack + // GPU tras el redraw (`rebuild_gpu_after_device_loss`). + state.needs_device_rebuild = true; + return; + } Err(_) => { let (w, h) = state.surface.size(); state.surface.resize(w, h); @@ -93,6 +108,13 @@ pub(super) fn handle_redraw( } }; let (w, h) = frame.size(); + // Bloque 21 — perfilador por frame (`LLIMPHI_PERF`). Cero costo apagado. + let pf = super::perf::enabled(); + let m_start = super::perf::mark(pf); + // Contador de asignaciones de heap: se muestrea en los MISMOS puntos que los + // µs, así el desglose de allocs es comparable etapa por etapa. Cero si nadie + // instaló `crate::alloc::ContadorAlloc` (ver feature `perf-alloc`). + let a_start = super::perf::alloc_mark(pf); // LayoutBuilder: resuelve los constructores diferidos en dos // pasadas (coste cero si no hay ninguno). Necesita el typesetter // para medir, así que va antes de tomar `model_ref` para el overlay. @@ -114,11 +136,28 @@ pub(super) fn handle_redraw( ); let model_ref = state.model.as_ref().expect("model"); let overlay_view = A::view_overlay(model_ref); - // Reusamos los árboles de layout del runtime: `clear()` + - // `mount` evita re-allocar el slotmap de taffy por frame. - state.layout.clear(); - let mut mounted: Mounted = mount(&mut state.layout, view); - let computed = { + // Bloque 24 — layout retenido. `mount_incremental` reusa los `NodeId` de + // taffy del frame anterior cuando la FORMA del árbol no cambió (sólo re-estila + // /re-mide lo que cambió), dejando limpio el resto para que taffy saltee su + // re-solve. Si la forma cambió, cae a `clear()`+rebuild histórico → cero + // regresión. Correctitud probada por el arnés de fuzz (retenido==fresco). + // Bloque 25 — extrae los builders de los nodos `memo` (dejándolos como + // marcadores) ANTES del mount. Sin nodos memo, `memo_pending` queda vacío y + // todo lo de abajo es no-op. Debe correr tras el size-anim (que muta el view). + let mut memo_pending = Vec::new(); + llimphi_compositor::take_memo_builders(&mut view, &mut memo_pending); + let m_view = super::perf::mark(pf); + let a_view = super::perf::alloc_mark(pf); + let (mut mounted, retained, layout_reused): (Mounted, _, bool) = + llimphi_compositor::mount_incremental( + &mut state.layout, + view, + state.retained_layout.as_ref(), + ); + state.retained_layout = Some(retained); + let m_mount = super::perf::mark(pf); + let a_mount = super::perf::alloc_mark(pf); + let mut computed = { let ts = &mut state.typesetter; let tmap = &mounted.text_measures; state @@ -131,12 +170,31 @@ pub(super) fn handle_redraw( }) .expect("layout") }; + // Bloque 25 — splice de los subárboles memoizados en el Mounted/computed ya + // calculados: reusa el cacheado (sin reconstruir el View) o lo reconstruye si + // deps/tamaño cambiaron. No-op sin memos. + if !memo_pending.is_empty() { + let ts = &mut state.typesetter; + llimphi_compositor::splice_memos( + &mut mounted, + &mut computed, + &memo_pending, + &mut state.memo_registry, + |tm, k, a| measure_text_node(ts, tm, k, a), + ); + } + let m_layout = super::perf::mark(pf); + let a_layout = super::perf::alloc_mark(pf); // Animaciones implícitas (`View::animated`): reconcilia el árbol // con el estado retenido DESPUÉS del layout y ANTES del paint — // interpola fill/radius de los nodos con `anim`. Si alguna sigue // viva pedimos otro frame al final (ticker autodetenido). let now = frame_now; let anim_active = state.anim_registry.reconcile(&mut mounted, now); + // Scene-split de fantasmas: el contenido viejo de un switch con + // `exit_under` se hornea como `underlay` del nodo — el paint lo apendea + // DEBAJO del contenido nuevo, en su posición z (no encima de todo). + state.anim_registry.prepare_underlays(&mut mounted, now); // Heroes (`View::hero`): si la misma key cambió de rect entre // frames, escribe en `transform` la afín que "vuela" del rect // anterior al actual. Independiente del anim_registry — sólo @@ -220,24 +278,33 @@ pub(super) fn handle_redraw( overlay_built.is_some() && has_gpu_painter(&mounted); state.scene.reset(); - paint( + // Bloque 23 — pintura con caché de RepaintBoundary. Sin nodos boundary en el + // árbol, `paint_cached` delega en `paint` (coste ~0); con ellos, reusa la + // sub-escena rasterizada de los subárboles estáticos. + paint_cached( &mut state.scene, &mounted, &computed, &mut state.typesetter, hover_idx, drop_hover_idx, + &mut state.repaint_cache, ); // Animación de salida (fade-out). 1) Capturá la subescena de // cada nodo `exit` presente (snapshot para cuando desaparezca). - // 2) Reproducí los fantasmas de los que ya se fueron, con + // 2) Reproduce los fantasmas de los que ya se fueron, con // opacidad decreciente — por encima del contenido, debajo del // overlay. Coste cero si ningún nodo usa `animated_exit`. for (idx, end, key) in state.anim_registry.live_exit_nodes(&mounted) { - let (dur, easing) = { - let a = mounted.nodes[idx].anim.expect("nodo exit lleva anim"); - (a.duration, a.easing) - }; + let a = mounted.nodes[idx].anim.expect("nodo exit lleva anim"); + // Centro del rect del nodo: origen de la afín de salida del fantasma. + let centro = computed + .get(mounted.nodes[idx].id) + .map(|r| (r.x + r.w * 0.5, r.y + r.h * 0.5)) + .unwrap_or((0.0, 0.0)); + // El underlay (fantasma ANTERIOR) no debe entrar a la captura del + // contenido vivo — si no, el próximo fantasma arrastraría al previo. + let saved_underlay = mounted.nodes[idx].underlay.take(); let mut sub = vello::Scene::new(); paint_range( &mut sub, @@ -250,7 +317,8 @@ pub(super) fn handle_redraw( end, vello::kurbo::Affine::IDENTITY, ); - state.anim_registry.store_live_exit(key, sub, dur, easing); + mounted.nodes[idx].underlay = saved_underlay; + state.anim_registry.store_live_exit(key, sub, centro, &a); } state .anim_registry @@ -298,6 +366,8 @@ pub(super) fn handle_redraw( ); } } + let m_paint = super::perf::mark(pf); + let a_paint = super::perf::alloc_mark(pf); if let Err(e) = state.renderer.render( &state.hal, &state.scene, @@ -306,6 +376,7 @@ pub(super) fn handle_redraw( ) { eprintln!("render error: {e}"); } + let m_raster = super::perf::mark(pf); let (vw, vh) = frame.size(); // Capa de overlay aparte (camino composite): vello la // rasteriza con fondo transparente en `frame.overlay_view()`. @@ -347,6 +418,11 @@ pub(super) fn handle_redraw( label: Some("llimphi-ui-gpu-paint"), }, ); + // Desglose del tramo `gpu` (sólo con el perfilador encendido; quedan en 0 + // si la capa over no se activa en este frame). + let mut over_paint_us = 0.0_f64; + let mut over_tex_us = 0.0_f64; + let mut over_raster_us = 0.0_f64; let viewport = frame.size(); // Backdrop blur (Bloque 11): post-pasada Gauss separable sobre // la intermediate, restringida al rect de cada nodo @@ -455,6 +531,7 @@ pub(super) fn handle_redraw( .map(|ov| has_over_painter(&ov.mounted)) .unwrap_or(false); if over_active { + let m_over_paint = super::perf::mark(pf); // Escena vello aparte (no pisamos `state.scene`, que el caller // retiene/reusa). Fondo transparente: sólo lo pintado por los // `over_painter` lleva alpha. @@ -465,6 +542,17 @@ pub(super) fn handle_redraw( &computed, &mut state.typesetter, ); + // Subárboles de Views marcados `View::over` (UI encima de contenido + // gpu_paint — p. ej. el OSC del reproductor): se pintan aquí, con la + // maquinaria completa (fills/texto/hover), en la misma escena over. + any_over |= llimphi_compositor::paint_over_layers( + &mut over_scene, + &mounted, + &computed, + &mut state.typesetter, + hover_idx, + drop_hover_idx, + ); if let Some(ov) = overlay_built.as_ref() { any_over |= paint_over( &mut over_scene, @@ -472,7 +560,17 @@ pub(super) fn handle_redraw( &ov.computed, &mut state.typesetter, ); + any_over |= llimphi_compositor::paint_over_layers( + &mut over_scene, + &ov.mounted, + &ov.computed, + &mut state.typesetter, + ov.hover_idx, + None, + ); } + let m_over_0 = super::perf::mark(pf); + over_paint_us = super::perf::us(m_over_paint, m_over_0); if any_over { // Scratch transparente del tamaño del frame (mismo formato // que la intermedia: Rgba8Unorm). Vello escribe via compute @@ -499,6 +597,7 @@ pub(super) fn handle_redraw( ); let over_view = over_tex.create_view(&llimphi_hal::wgpu::TextureViewDescriptor::default()); + let m_over_1 = super::perf::mark(pf); // Vello rasteriza la escena over a la scratch (su propio // submit; limpia con TRANSPARENT). Independiente de la // intermedia. @@ -512,6 +611,9 @@ pub(super) fn handle_redraw( ) { eprintln!("render over-layer error: {e}"); } + let m_over_2 = super::perf::mark(pf); + over_tex_us = super::perf::us(m_over_0, m_over_1); + over_raster_us = super::perf::us(m_over_1, m_over_2); // Composite alpha de la scratch sobre la intermedia, grabado // en `gpu_encoder` DESPUÉS de los pases GPU directos. state.overlay_compositor.composite( @@ -535,13 +637,49 @@ pub(super) fn handle_redraw( ); any_gpu = true; } + let m_submit_0 = super::perf::mark(pf); if any_gpu { state .hal .queue .submit(std::iter::once(gpu_encoder.finish())); } + let m_gpu = super::perf::mark(pf); + let submit_us = super::perf::us(m_submit_0, m_gpu); + super::timing_primer_present(); + super::notificar_present(&state.window); state.surface.present(frame, &state.hal); + let m_present = super::perf::mark(pf); + let a_end = super::perf::alloc_mark(pf); + // Bloque 21 — emití la muestra del frame (camino completo). `mounted` + // sigue vivo (se mueve recién a `last_render`, abajo). El camino + // `cache_hit` no llega aquí: no se muestrea a propósito. + if pf { + super::perf::submit(super::perf::FrameSample { + view_us: super::perf::us(m_start, m_view), + mount_us: super::perf::us(m_view, m_mount), + layout_us: super::perf::us(m_mount, m_layout), + paint_us: super::perf::us(m_layout, m_paint), + raster_us: super::perf::us(m_paint, m_raster), + gpu_us: super::perf::us(m_raster, m_gpu), + over_paint_us, + over_tex_us, + over_raster_us, + submit_us, + present_us: super::perf::us(m_gpu, m_present), + total_us: super::perf::us(m_start, m_present), + n_nodes: mounted.nodes.len(), + boundary_hits: state.repaint_cache.hits, + boundary_misses: state.repaint_cache.misses, + layout_reused, + allocs: super::perf::allocs(a_start, a_end), + allocs_view: super::perf::allocs(a_start, a_view), + allocs_mount: super::perf::allocs(a_view, a_mount), + allocs_layout: super::perf::allocs(a_mount, a_layout), + allocs_paint: super::perf::allocs(a_layout, a_paint), + alloc_bytes: super::perf::alloc_bytes(a_start, a_end), + }); + } // Ticker de animaciones implícitas: si quedó alguna en curso, // pedí el próximo frame. Cuando todas se asientan, `animating` // queda false y el loop de redraws se detiene solo (sin render @@ -555,6 +693,7 @@ pub(super) fn handle_redraw( animating, rippling, has_overlay: overlay_built.is_some(), + has_gpu_paint: any_gpu, }); state.last_render = Some(RenderCache { mounted, @@ -568,7 +707,12 @@ pub(super) fn handle_redraw( // tecnología asistiva escuchando — coste cero en ese caso. push_a11y_tree::(state); - // `handle` se recibe pero el redraw no lo necesita directamente; - // se pasa para mantener la firma consistente con el caller. - let _ = handle; + // Rueda **diferida**: llegaron ruedas mientras `last_render` estaba + // invalidado (un update recién había corrido). Ahora la caché de hit-test + // está fresca — se procesa el delta acumulado contra ella. `process_wheel` + // puede volver a invalidar `last_render` y pedir otro redraw (el ciclo se + // autolimita: sin ruedas nuevas, `pending_wheel` queda en None). + if let Some(wd) = state.pending_wheel.take() { + crate::Runtime::::process_wheel(state, handle, wd); + } } diff --git a/llimphi-ui/src/eventloop/secondary.rs b/llimphi-ui/src/eventloop/secondary.rs index 14d3adc..1f086ef 100644 --- a/llimphi-ui/src/eventloop/secondary.rs +++ b/llimphi-ui/src/eventloop/secondary.rs @@ -17,7 +17,7 @@ impl Runtime { prim.last_render = None; prim.window.request_redraw(); } - // OJO: NO repintamos las secundarias acá. `dispatch_model` corre en + // OJO: NO repintamos las secundarias aquí. `dispatch_model` corre en // cada Msg (incluido el tick ~33 fps), y repintar una secundaria por // tick serializaba dos `acquire()` de swapchain en Wayland FIFO → // ralentización y cuelgue. Cada secundaria se repinta sola al @@ -114,6 +114,19 @@ impl Runtime { let frame = match sec.surface.acquire() { Ok(f) => f, + Err(llimphi_hal::SurfaceError::DeviceLost) => { + // El device (compartido con la primaria) murió. La secundaria no + // puede rehacerlo sola; marca la primaria y pedile un redraw: su + // dispatcher corre `rebuild_gpu_after_device_loss`, que rehace el + // device Y las surfaces de TODAS las secundarias (incluida ésta). + let _ = (hal, renderer); + sec.window.request_redraw(); + if let Some(prim) = self.state.as_mut() { + prim.needs_device_rebuild = true; + prim.window.request_redraw(); + } + return; + } Err(_) => { let (w, h) = sec.surface.size(); sec.surface.resize(w, h); @@ -173,6 +186,7 @@ impl Runtime { if any { hal.queue.submit(std::iter::once(enc.finish())); } + super::notificar_present(&sec.window); sec.surface.present(frame, hal); let _ = (hover_idx, drop_hover_idx); // se usaron al pintar; no se cachean sec.last_render = Some(SecRenderCache { mounted, computed }); @@ -399,31 +413,24 @@ impl Runtime { }, }; let cursor = self.secondaries[idx].cursor; - let chain: Vec> = { + let msgs: Vec = { let sec = &self.secondaries[idx]; sec.last_render .as_ref() .map(|c| { - hit_test_scroll_chain( + let chain = hit_test_scroll_chain( &c.mounted, &c.computed, cursor.x as f32, cursor.y as f32, - ) - .into_iter() - .filter_map(|i| c.mounted.nodes[i].on_scroll.clone()) - .collect() + ); + // Reparto parcial (ver el main): varios Msg por + // evento cuando hijo y ancestro comparten el delta. + consume_scroll_chain(&c.mounted, &chain, wd.x, wd.y) }) .unwrap_or_default() }; - let mut msg: Option = None; - for h in &chain { - if let Some(m) = h(wd.x, wd.y) { - msg = Some(m); - break; - } - } - if let Some(msg) = msg { + for msg in msgs { self.dispatch_and_render_secondary(idx, msg); } } diff --git a/llimphi-ui/src/lib.rs b/llimphi-ui/src/lib.rs index 1adf996..6e0ebc7 100644 --- a/llimphi-ui/src/lib.rs +++ b/llimphi-ui/src/lib.rs @@ -13,6 +13,10 @@ use std::sync::Arc; pub mod a11y; +/// Contador de asignaciones de heap para el perfilador (`LLIMPHI_PERF`). Ver +/// [`alloc`] para las dos formas de instalarlo (feature `perf-alloc` o +/// `#[global_allocator]` a mano). +pub mod alloc; use llimphi_hal::winit::application::ApplicationHandler; use llimphi_hal::winit::dpi::{LogicalSize, PhysicalPosition}; @@ -166,6 +170,57 @@ pub trait App: 'static { None } + /// La **ventana** ganó (`true`) o perdió (`false`) el foco del sistema — + /// el `WindowEvent::Focused` de winit. Distinto de [`App::on_focus`], que + /// es el foco de un nodo enfocable *dentro* de la app (navegación Tab). + /// Útil para apps que cambian de forma según tengan o no el foco (p. ej. + /// shuma replegándose a una barra al perder el foco). Devolver `Some(Msg)` + /// dispara un update; `None` (default) lo ignora. + fn on_window_focus(_model: &Self::Model, _focused: bool) -> Option { + None + } + + /// El estado de los **modificadores** cambió (Shift/Ctrl/Alt/Super pulsados + /// o soltados) — el `WindowEvent::ModifiersChanged` de winit. + /// + /// Existe para que la app pueda saber qué modificadores están puestos + /// **cuando llega un click**: los handlers de click llevan un `Msg` plano y + /// no los reciben (agregarlos obligaría a versionar el callback de cada + /// widget con filas). El patrón es guardarlos en el modelo acá y + /// consultarlos en el `update` del click — así se implementan el + /// Shift+click (rango) y el Ctrl+click (selección aditiva) de cualquier + /// lista. Devolver `Some(Msg)` dispara un update; `None` (default) lo + /// ignora. Conviene devolver `None` cuando el cambio no le importa a la + /// app: si no, cada roce de Shift repinta. + fn on_modifiers(_model: &Self::Model, _mods: Modifiers) -> Option { + None + } + + /// El cursor se movió sobre la ventana — `(x, y)` en píxeles físicos, el + /// `WindowEvent::CursorMoved` crudo. Se invoca en **cada** movimiento, sin + /// depender del hit-test de nodos ni de que haya un frame cacheado: es la + /// señal fiable de "hay actividad de mouse" para cosas como revelar una + /// barra estilo OSC (mpv) o resetear un temporizador de inactividad. Para + /// reaccionar a la posición *sobre un nodo concreto* usa + /// `View::on_pointer_move_at` en su lugar. Devolver `Some(Msg)` dispara un + /// update; `None` (default) lo ignora. + fn on_cursor_moved(_model: &Self::Model, _x: f32, _y: f32) -> Option { + None + } + + /// El stack GPU se **reconstruyó tras una pérdida de dispositivo** + /// ([`llimphi_hal::SurfaceError::DeviceLost`]): el runtime ya rehízo + /// instance/adapter/device/surface/renderer/compositores contra un `Hal` + /// nuevo. Este gancho avisa a la app para que reconstruya SUS propios + /// recursos GPU —los que el runtime no puede alcanzar porque los posee la + /// app: pipelines directos (`GpuPipelines`), texturas de video + /// (`ExternalSurface`), renderers `llimphi-3d`, atlas propios— contra el + /// `hal` nuevo. Las apps de **UI pura** (sólo vello) no tienen recursos + /// propios en GPU: el no-op por defecto les basta y no pagan nada. Se llama + /// con el modelo mutable y el `Hal` recién creado, antes del siguiente + /// redraw. Ver [`Hal::recreate_for_window`]. + fn on_gpu_reset(_model: &mut Self::Model, _hal: &Hal) {} + /// Título de la ventana (sólo se lee al arrancar). Es el título inicial; /// para uno que cambie en runtime, ver [`App::window_title`]. fn title() -> &'static str { @@ -239,6 +294,10 @@ pub enum UserEvent { }, /// Pide cerrar la ventana secundaria con esa `key`. No afecta a la primaria. CloseWindow { key: u64 }, + /// Minimiza (o restaura) la ventana **primaria**. Útil para apps que deben + /// quitarse de en medio un instante — p. ej. una captura de pantalla que no + /// quiere salir en su propia toma. Es una *petición* al compositor. + SetMinimized(bool), /// Evento del adapter AccessKit: el lector de pantalla solicitó el árbol /// inicial, pidió ejecutar una acción (focus, click, etc.) o se desactivó. /// El adapter usa el `EventLoopProxy` para enviarlos al hilo del runtime. @@ -254,7 +313,7 @@ impl From for UserEvent { } } -/// Asa al runtime de Llimphi. Clonable y enviable entre hilos: la usás para +/// Asa al runtime de Llimphi. Clonable y enviable entre hilos: la usas para /// pedir cerrar la ventana o para lanzar trabajo (PAM, IO, etc.) que al /// terminar reentra con un Msg al `update`. /// @@ -303,6 +362,26 @@ impl Handle { } } + /// Handle de tests que **entrega** cada `Msg` a `sink` en vez de tirarlo. + /// Es lo que necesita un test para ejercitar el camino asíncrono real de la + /// app: `spawn` corre su closure en un hilo (como en producción) y el `Msg` + /// resultante llega al `sink` — típicamente el extremo de un canal que el + /// test drena y realimenta a `update`. Con [`Self::for_test`] ese `Msg` se + /// descarta, así que un test que necesitaba el efecto tenía que volver a + /// correr el trabajo a mano: dos ejecuciones del mismo job y carrera entre + /// ellas. + /// + /// El `sink` se llama desde el hilo del `spawn`, de ahí el `Send + Sync` + /// (un `mpsc::Sender` no es `Sync`: envolverlo en `Mutex` alcanza). + pub fn for_test_with(sink: F) -> Self + where + F: Fn(Msg) + Send + Sync + 'static, + { + Self { + inner: HandleInner::Lifted(Arc::new(sink)), + } + } + /// Cierra la ventana y termina el bucle. La transición en curso (si la /// hay) se completa antes de salir. pub fn quit(&self) { @@ -352,13 +431,22 @@ impl Handle { } /// Cierra la ventana secundaria con esa `key` (no-op si no existe). La - /// ventana primaria nunca se cierra por acá — para eso está [`Self::quit`]. + /// ventana primaria nunca se cierra por aquí — para eso está [`Self::quit`]. pub fn close_window(&self, key: u64) { if let HandleInner::Real(p) = &self.inner { let _ = p.send_event(UserEvent::CloseWindow { key }); } } + /// Minimiza (`true`) o restaura (`false`) la ventana primaria. Es una + /// petición al compositor (en Wayland, `xdg_toplevel.set_minimized`); honrarla + /// depende del WM. Pensado para apartarse un instante (captura de pantalla). + pub fn set_minimized(&self, minimized: bool) { + if let HandleInner::Real(p) = &self.inner { + let _ = p.send_event(UserEvent::SetMinimized(minimized)); + } + } + /// Encola un Msg para procesarse en el próximo turno del bucle. Útil /// para que un callback externo reentre al update. pub fn dispatch(&self, msg: Msg) { @@ -450,6 +538,40 @@ impl Handle { } } } + + /// Igual que [`spawn_periodic`](Self::spawn_periodic) pero con **periodo + /// variable**: antes de cada espera se llama `period()` para decidir cuánto + /// dormir hasta el próximo `f()`. Pensado para feeds cuya cadencia depende + /// del estado — p. ej. un reproductor que tiquea a ~30 Hz en play pero baja + /// a unos pocos Hz en pausa (cada Msg fuerza un repintado completo, así que + /// bajar la cadencia en pausa recorta la CPU en reposo sin tocar el core). + pub fn spawn_periodic_dyn(&self, period: P, f: F) + where + P: Fn() -> std::time::Duration + Send + 'static, + F: Fn() -> Msg + Send + 'static, + { + match &self.inner { + HandleInner::Real(p) => { + let proxy = p.clone(); + std::thread::spawn(move || loop { + std::thread::sleep(period()); + if proxy.send_event(UserEvent::Msg(f())).is_err() { + break; + } + }); + } + HandleInner::Test => { + let _ = (period, f); + } + HandleInner::Lifted(lift) => { + let lift = lift.clone(); + std::thread::spawn(move || loop { + std::thread::sleep(period()); + lift(f()); + }); + } + } + } } /// Evento de teclado normalizado. @@ -532,6 +654,16 @@ struct Runtime { /// cada una lleva su propia surface + caches de interacción. Vacío en la /// inmensa mayoría de las apps (monoventana) — coste cero. secondaries: Vec>, + /// Modelo preservado a través de un `suspended` (Android/iOS/web): al + /// pasar a background la NativeWindow se invalida y hay que soltar TODO + /// el estado GPU (`RuntimeState`), pero el modelo de la app debe + /// sobrevivir. `resumed` lo retoma en vez de llamar `A::init` de nuevo. + /// En desktop winit nunca emite `suspended` — queda `None` siempre. + parked_model: Option, + /// Dedo activo (id del touch) cuando los eventos llegan por + /// `WindowEvent::Touch` en vez de mouse. Sólo se sigue UN dedo: el + /// primero que baja; los demás se ignoran hasta que suelte. + active_touch: Option, } /// Estado por **ventana secundaria**. Espeja los campos de interacción de @@ -592,6 +724,16 @@ struct RuntimeState { /// colisionar dentro del mismo frame). layout: LayoutTree, overlay_layout: LayoutTree, + /// Estado del layout retenido (Bloque 24). `mount_incremental` lo lee para + /// reusar los `NodeId` de taffy del frame anterior cuando la forma del árbol + /// no cambió, y lo re-emite para el próximo. `None` = primer frame (o tras un + /// cambio de forma, se rehace). Opaco: sólo se guarda y se pasa de vuelta. + retained_layout: Option, + /// Registro de subárboles memoizados (`View::memo`, Bloque 25). Vive entre + /// frames: por key retiene el `Mounted` + `LayoutTree` aislado del subárbol, + /// y lo reusa (sin reconstruir el `View`) mientras `deps` y el tamaño del + /// slot no cambien. Sin nodos `memo`, `splice_memos` es no-op (coste ~0). + memo_registry: llimphi_compositor::MemoRegistry, /// Último frame renderizado: árbol montado + rects absolutos + /// nodo con hover. Lo consume el handler de click para hit-testear /// sin reconstruir `view` + layout, y CursorMoved para detectar si @@ -646,6 +788,11 @@ struct RuntimeState { /// mientras alguna siga viva, pide otro frame (ticker autodetenido). Ver /// [`llimphi_compositor::RippleRegistry`]. ripple_registry: llimphi_compositor::RippleRegistry, + /// Caché de rasterización de los `View::repaint_boundary` (Bloque 23). Vivo + /// entre frames: cada subárbol-boundary paint-puro y estático se rasteriza + /// una vez y se reusa (append trasladado) mientras su contenido no cambie. + /// Sin boundaries en el árbol, `paint_cached` delega en `paint` (coste ~0). + repaint_cache: llimphi_compositor::RepaintCache, /// Último tap (press izquierdo) sobre un nodo con `on_double_tap`: instante /// + posición. El próximo press que caiga cerca y a tiempo dispara el /// doble-tap. `None` cuando no hay un primer tap pendiente. @@ -653,6 +800,24 @@ struct RuntimeState { /// Long-press armado (ver [`PendingLongPress`]). El runtime lo vence por /// tiempo en `about_to_wait` y lo cancela en movimiento/release. pending_long_press: Option>, + /// `on_click` plano armado (ver [`PendingClick`]). Se dispara en el release + /// y se cancela si el cursor se aleja del origen más que [`CLICK_MOVE_CANCEL`]. + pending_click: Option>, + /// Rueda **diferida**: delta acumulado de ruedas que llegaron con + /// `last_render` invalidado (un update recién corrió). Se procesa al final + /// del próximo redraw contra el árbol fresco — sin esto, una app con ticks + /// frecuentes invalida la caché antes de casi cada rueda y el scroll por + /// wheel queda muerto (chain=0 sistemático). + pending_wheel: Option, + /// Tecla sostenida para el **auto-repeat sintético**: winit no siempre genera + /// los eventos repetidos en Wayland (deja `KeyEvent.repeat` pero no re-emite), + /// así que el runtime los sintetiza — `about_to_wait` re-dispara la tecla al + /// rate del compositor. El release o pisar otra tecla la limpia. `None` si no + /// hay ninguna sostenida (o si winit ya repite, ver [`Self::winit_repeats`]). + held_key: Option, + /// `true` si winit YA entrega eventos con `repeat = true` (los sintetiza él). + /// Al ver el primero apagamos la síntesis propia para no duplicar la tecla. + winit_repeats: bool, /// **Retención de frame entero**. Tras un paint exitoso, guardamos las /// dimensiones del viewport y los flags de animación del frame. Si en el /// próximo `RedrawRequested` ningún sitio invalidó `last_render` (la @@ -668,6 +833,13 @@ struct RuntimeState { /// Selección de texto activa fuera del editor (drag para resaltar, Ctrl/Cmd+C /// para copiar). `None` = nada seleccionado. Ver [`TextSelection`]. selection: Option, + /// El `acquire()` reportó [`llimphi_hal::SurfaceError::DeviceLost`]: el + /// device quedó irrecuperable y hay que rehacer todo el stack GPU. El redraw + /// no puede hacerlo solo (debe rehacer también las surfaces de las ventanas + /// secundarias, que viven en `Runtime`, no en `RuntimeState`), así que sólo + /// **marca** aquí y el dispatcher de `Runtime` lo atiende tras el redraw + /// llamando `rebuild_gpu_after_device_loss`. Ver esa función. + needs_device_rebuild: bool, } /// Metadata del frame retenido — qué pintó la `state.scene` para validar que @@ -679,6 +851,13 @@ struct RetainedScene { animating: bool, rippling: bool, has_overlay: bool, + /// El frame tenía painters GPU directos (`gpu_paint_with`) o post-pasadas + /// (backdrop blur / filter). Esas primitivas se dibujan en vivo sobre la + /// intermedia cada frame y **no** quedan en `state.scene` (la escena vello + /// retenida). Re-presentar la scene tal cual las perdería → no se retiene + /// (un expose del compositor debe re-correr el paint GPU). Sin esto, p.ej. + /// la esfera 3D de cosmos desaparecía al tapar/destapar la ventana. + has_gpu_paint: bool, } /// Selección de texto activa fuera del editor (ver [`crate::View::selectable`]). @@ -760,11 +939,40 @@ struct PendingLongPress { handler: GestureResolved, } +/// `on_click` **armado**: el press cayó sobre un nodo con `on_click` plano (sin +/// drag ni `on_click_at`). El click NO se dispara en el press — semántica de +/// escritorio: se dispara al **soltar** (`handle_left_release`), salvo que antes +/// el cursor se aleje de `origin` más que [`CLICK_MOVE_CANCEL`] (el gesto pasó a +/// arrastre/barrido y se cancela). Esto evita el disparo prematuro en mousedown +/// y tolera arrastres minúsculos accidentales sobre el botón. +struct PendingClick { + msg: Msg, + origin: PhysicalPosition, +} + +/// Tecla sostenida para el auto-repeat sintético: el evento winit a re-emitir +/// (con `repeat = true`) y el próximo instante de disparo. +struct HeldKey { + event: llimphi_hal::winit::event::KeyEvent, + next: std::time::Instant, +} + +/// Cuánto hay que MANTENER una tecla antes de que empiece a repetir. Espeja el +/// estándar del compositor (mirada usa 600 ms). +const KEY_REPEAT_DELAY: std::time::Duration = std::time::Duration::from_millis(600); +/// Intervalo entre repeticiones una vez arrancado (25 Hz → 40 ms). +const KEY_REPEAT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(40); + /// Umbral de duración para que un press se convierta en long-press. const LONG_PRESS_DELAY: std::time::Duration = std::time::Duration::from_millis(500); /// Si el cursor se aleja más que esto (px físicos) del origen del press, deja /// de ser long-press (pasó a drag/scroll) y se cancela. const LONG_PRESS_MOVE_CANCEL: f64 = 8.0; +/// Si el cursor se aleja más que esto (px físicos) del origen del press, el +/// `on_click` armado deja de contar como click (el usuario quiso arrastrar / +/// barrer, no cliquear) y se cancela. Por debajo del umbral, un arrastre +/// minúsculo accidental **sigue valiendo como click** al soltar. +const CLICK_MOVE_CANCEL: f64 = 6.0; /// Ventana temporal máxima entre los dos taps de un doble-tap. const DOUBLE_TAP_WINDOW: std::time::Duration = std::time::Duration::from_millis(400); /// Distancia máxima (px físicos) entre los dos taps de un doble-tap. @@ -841,9 +1049,23 @@ fn compute_drag_velocity( /// Punto de entrada: corre el bucle Elm hasta que el usuario cierre la /// ventana (o la app llame [`Handle::quit`]). pub fn run() { - let event_loop = EventLoop::>::with_user_event() - .build() - .expect("event loop"); + eventloop::timing_hito("run:entrada"); + // Crear el event loop puede fallar cuando NO hay compositor/display + // accesible (p. ej. el socket Wayland no es alcanzable por este usuario, o + // el compositor se cayó): winit devuelve `NoCompositor`. Antes esto + // `.expect()`eaba → panic → exit 101, y bajo un supervisor de respawn eso + // es un crash-loop de panics. Salimos limpio (código 3) con un mensaje + // claro: no hay display, no es un bug de la app. + let event_loop = match EventLoop::>::with_user_event().build() { + Ok(el) => el, + Err(e) => { + eprintln!( + "llimphi-ui · no pude crear el event loop ({e}): no hay compositor/display \ + accesible. Salgo sin panichear." + ); + std::process::exit(3); + } + }; event_loop.set_control_flow(ControlFlow::Wait); let handle = Handle { inner: HandleInner::Real(event_loop.create_proxy()), @@ -852,8 +1074,61 @@ pub fn run() { handle, state: None, secondaries: Vec::new(), + parked_model: None, + active_touch: None, }; - event_loop.run_app(&mut runtime).expect("run app"); + if let Err(e) = event_loop.run_app(&mut runtime) { + eprintln!("llimphi-ui · el event loop terminó con error: {e}"); + std::process::exit(3); + } +} + +/// Punto de entrada **Android**: como [`run`], pero construye el event loop +/// sobre el `AndroidApp` que `android-activity` entrega en `android_main`. +/// El ciclo de vida móvil difiere del desktop en dos cosas que este runtime +/// ya absorbe: la ventana/surface se crea recién en `resumed` (lazy, igual +/// que aquí) y se DESTRUYE en `suspended` (el modelo se parquea y el estado +/// GPU se suelta — ver `Runtime::parked_model`). +/// +/// El crate final (la app) debe activar la feature +/// `winit/android-native-activity` y exportar: +/// +/// ```ignore +/// #[no_mangle] +/// fn android_main(app: AndroidApp) { +/// llimphi_ui::run_android::(app); +/// } +/// ``` +#[cfg(target_os = "android")] +pub fn run_android( + android_app: llimphi_hal::winit::platform::android::activity::AndroidApp, +) { + use llimphi_hal::winit::platform::android::EventLoopBuilderExtAndroid; + eventloop::timing_hito("run_android:entrada"); + let event_loop = match EventLoop::>::with_user_event() + .with_android_app(android_app) + .build() + { + Ok(el) => el, + Err(e) => { + log::error!("llimphi-ui · no pude crear el event loop android: {e}"); + return; + } + }; + event_loop.set_control_flow(ControlFlow::Wait); + let handle = Handle { + inner: HandleInner::Real(event_loop.create_proxy()), + }; + let mut runtime: Runtime = Runtime { + handle, + state: None, + secondaries: Vec::new(), + parked_model: None, + active_touch: None, + }; + if let Err(e) = event_loop.run_app(&mut runtime) { + log::error!("llimphi-ui · el event loop terminó con error: {e}"); + } } #[cfg(test)] diff --git a/llimphi-video-plane/Cargo.toml b/llimphi-video-plane/Cargo.toml new file mode 100644 index 0000000..d3fba8e --- /dev/null +++ b/llimphi-video-plane/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "llimphi-video-plane" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish = false +description = "Fase 0 (spike): atachar un wl_subsurface real sobre la wl_surface de winit para que mirada lo escanee directo, sin pasar por vello. Valida el muro winit↔wayland-client ajeno." + +[[bin]] +name = "subsurface-spike" +path = "src/main.rs" + +[dependencies] +wgpu = { workspace = true } +winit = { workspace = true } +raw-window-handle = { workspace = true } +pollster = { workspace = true } +wayland-client = { workspace = true } +wayland-backend = { version = "0.3", features = ["client_system"] } +wayland-protocols = { version = "0.32", features = ["client"] } +# Fase 1b: buffers dmabuf (scanout-capable) por gbm → mirada puede promover el +# subsurface a un plano de overlay del DRM (a diferencia de shm, que jamás +# escanea). default-features=false: no queremos import-wayland/egl ni drm-support. +gbm = { version = "0.18", default-features = false } +libc = { workspace = true } +media-core = { path = "../../media/media-core" } +foreign-av = { path = "../../../shared/foreign-av" } diff --git a/llimphi-video-plane/LEEME.md b/llimphi-video-plane/LEEME.md new file mode 100644 index 0000000..41b8529 --- /dev/null +++ b/llimphi-video-plane/LEEME.md @@ -0,0 +1,34 @@ +# llimphi-video-plane + +*Read this in English: [README.md](README.md).* + +Spike de "atachá video en llimphi": video como `wl_subsurface` real sobre +la `wl_surface` de winit, para que mirada lo componga (Fase 1) y a futuro lo +escanee en un plano de overlay, **sin pasar por la textura intermedia de +vello**. + +Muro que valida (Fase 0, ya OK): winit no expone subsurfaces, así que se +monta una **segunda** conexión `wayland-client` sobre el MISMO `wl_display` +ajeno (`Backend::from_foreign_display`), se envuelve la `wl_surface` de winit +por `ObjectId::from_ptr`, y se le crea una `wl_subsurface` hija. `set_desync` +→ el hijo commitea solo; posición/orden se latchean en el commit del padre +(que hace winit al presentar; el subsurface trackea al padre al mover). + +Dos modos: + - **sin argumentos** → cuadro de color sólido (valida el subsurface). + - **`subsurface-spike `** → frames reales de ese video (Fase 1a), + decodificados por `foreign-av`, subidos al subsurface por shm (3 buffers + round-robin, RGBA→ARGB8888). La UI winit sólo limpia el fondo. + +Teclas: B = subsurface debajo/encima del padre · T = fondo UI +transparente/opaco (el "hueco" donde asoma el plano) · ESC = salir. + +## Uso + +```sh +cargo run --release -p llimphi-video-plane --bin subsurface-spike +``` + +--- + +Parte de **llimphi** — ver [llimphi](../LEEME.md). diff --git a/llimphi-video-plane/README.md b/llimphi-video-plane/README.md new file mode 100644 index 0000000..1cb5312 --- /dev/null +++ b/llimphi-video-plane/README.md @@ -0,0 +1,33 @@ +# llimphi-video-plane + +A spike for "attach video in llimphi": video as a real `wl_subsurface` over +winit's `wl_surface`, so mirada composites it (Phase 1) and eventually scans it +out on an overlay plane, **without going through vello's intermediate texture**. + +The wall it validates (Phase 0, already OK): winit does not expose subsurfaces, so +a **second** `wayland-client` connection is mounted over the SAME foreign +`wl_display` (`Backend::from_foreign_display`), winit's `wl_surface` is wrapped +through `ObjectId::from_ptr`, and a child `wl_subsurface` is created on it. +`set_desync` → the child commits on its own; position and ordering are latched on +the parent's commit (which winit does on present; the subsurface tracks the +parent when moved). + +Two modes: + +- **no arguments** → a solid colour quad (validates the subsurface). +- **`subsurface-spike `** → real frames from that video (Phase 1a), decoded + by `foreign-av`, uploaded to the subsurface through shm (3 round-robin buffers, + RGBA→ARGB8888). The winit UI only clears the background. + +Keys: `B` = subsurface below/above the parent · `T` = UI background +transparent/opaque (the "hole" the plane peeks through) · `ESC` = quit. + +## Use + +```sh +cargo run --release -p llimphi-video-plane --bin subsurface-spike +``` + +--- + +Part of **llimphi** — see [llimphi](../README.md). diff --git a/llimphi-video-plane/src/main.rs b/llimphi-video-plane/src/main.rs new file mode 100644 index 0000000..5adf883 --- /dev/null +++ b/llimphi-video-plane/src/main.rs @@ -0,0 +1,913 @@ +//! Spike de "atachá video en llimphi": video como `wl_subsurface` real sobre +//! la `wl_surface` de winit, para que mirada lo componga (Fase 1) y a futuro lo +//! escanee en un plano de overlay, **sin pasar por la textura intermedia de +//! vello**. +//! +//! Muro que valida (Fase 0, ya OK): winit no expone subsurfaces, así que se +//! monta una **segunda** conexión `wayland-client` sobre el MISMO `wl_display` +//! ajeno (`Backend::from_foreign_display`), se envuelve la `wl_surface` de winit +//! por `ObjectId::from_ptr`, y se le crea una `wl_subsurface` hija. `set_desync` +//! → el hijo commitea solo; posición/orden se latchean en el commit del padre +//! (que hace winit al presentar; el subsurface trackea al padre al mover). +//! +//! Dos modos: +//! - **sin argumentos** → cuadro de color sólido (valida el subsurface). +//! - **`subsurface-spike `** → frames reales de ese video (Fase 1a), +//! decodificados por `foreign-av`, subidos al subsurface por shm (3 buffers +//! round-robin, RGBA→ARGB8888). La UI winit sólo limpia el fondo. +//! +//! Teclas: B = subsurface debajo/encima del padre · T = fondo UI +//! transparente/opaco (el "hueco" donde asoma el plano) · ESC = salir. + +use std::os::fd::{AsFd, BorrowedFd}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gbm::{BufferObject, BufferObjectFlags, Device as GbmDevice, Format as GbmFormat}; +use media_core::FrameSource; +use raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle}; +use wayland_client::backend::{Backend, ObjectId}; +use wayland_client::protocol::{ + wl_buffer::WlBuffer, + wl_compositor::WlCompositor, + wl_region::WlRegion, + wl_registry, + wl_shm::{self, WlShm}, + wl_shm_pool::WlShmPool, + wl_subcompositor::WlSubcompositor, + wl_subsurface::WlSubsurface, + wl_surface::WlSurface, +}; +use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle}; +use wayland_protocols::wp::linux_dmabuf::zv1::client::{ + zwp_linux_buffer_params_v1::{self, ZwpLinuxBufferParamsV1}, + zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1, +}; +use winit::application::ApplicationHandler; +use winit::event::{ElementState, KeyEvent, WindowEvent}; +use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::keyboard::{Key, NamedKey}; +use winit::window::{Window, WindowId}; + +// Modo cuadro sólido (sin archivo). +const SOLID_W: i32 = 400; +const SOLID_H: i32 = 300; +const SOLID_X: i32 = 200; +const SOLID_Y: i32 = 150; +const DEFAULT_WIN: (u32, u32) = (800, 600); + +/// Cuántos buffers shm rotamos. Con 3 en round-robin nunca reescribimos uno que +/// el compositor todavía tenga tomado (a 30 fps lo libera en ~1 frame ≪ 3), así +/// que no hace falta rastrear `wl_buffer.release`. +const RING: usize = 3; + +// --------------------------------------------------------------------------- +// Estado wayland del lado del subsurface (segunda conexión, display ajeno). +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct WlGlobals { + compositor: Option, + subcompositor: Option, + shm: Option, + dmabuf: Option, +} + +impl Dispatch for WlGlobals { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { name, interface, version } = event { + match &interface[..] { + "wl_compositor" => { + state.compositor = Some(registry.bind(name, version.min(4), qh, ())); + } + "wl_subcompositor" => { + state.subcompositor = Some(registry.bind(name, 1, qh, ())); + } + "wl_shm" => { + state.shm = Some(registry.bind(name, 1, qh, ())); + } + "zwp_linux_dmabuf_v1" => { + // v3+ para `add` con modifier; v4 trae feedback (no lo usamos). + state.dmabuf = Some(registry.bind(name, version.min(4), qh, ())); + } + _ => {} + } + } + } +} + +/// Impls `Dispatch` vacíos: no nos interesan los eventos de estos objetos. +macro_rules! empty_dispatch { + ($($t:ty),* $(,)?) => { $( + impl Dispatch<$t, ()> for WlGlobals { + fn event( + _: &mut Self, + _: &$t, + _: <$t as Proxy>::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) {} + } + )* }; +} +empty_dispatch!( + WlCompositor, + WlSubcompositor, + WlShm, + WlShmPool, + WlBuffer, + WlSurface, + WlSubsurface, + WlRegion, + ZwpLinuxDmabufV1, + ZwpLinuxBufferParamsV1, +); + +// --------------------------------------------------------------------------- +// Alimentador de frames de video al subsurface por shm (round-robin). +// --------------------------------------------------------------------------- + +/// Backend de almacenamiento de los frames: shm (CPU, jamás escanea a plano) o +/// dmabuf (buffers `gbm` LINEAR scanout-capable → mirada los puede promover a un +/// plano de overlay del DRM). En ambos el `Feeder` escribe el frame por CPU; la +/// diferencia es qué puede hacer el compositor con el resultado. +enum Ring { + /// Mapeo único del pool shm (RING × frame contiguos). Puntero crudo: la app + /// es mono-hilo, no cruza threads. + Shm { map: *mut u8, frame_bytes: usize }, + /// Un `BufferObject` gbm por slot del ring. El `Device` se retiene (los bo + /// comparten su `gbm_device` por refcount ffi, pero el Rust `Device` posee el + /// destructor). Se escribe por `map_mut` (sync ioctls incluidos). + Dmabuf { + _device: GbmDevice, + bos: Vec>, + }, +} + +struct Feeder { + source: Box, + child: WlSurface, + conn: Connection, + ring: Ring, + w: i32, + h: i32, + buffers: Vec, + next: usize, + rgba: Vec, + last: Instant, + interval: Duration, + /// Contador de throughput real (frames commiteados por ventana de ~1s). + frames_win: u32, + fps_t0: Instant, +} + +impl Feeder { + /// Tira del próximo frame (si toca por cadencia) y lo commitea al hijo. + fn pump(&mut self, now: Instant) { + let dt = now.saturating_duration_since(self.last); + self.last = now; + let Some((w, h)) = self.source.tick(dt, &mut self.rgba) else { + return; + }; + if w as i32 != self.w || h as i32 != self.h { + // El spike fija tamaño al abrir; un cambio de resolución en vuelo no + // se maneja acá. + return; + } + let idx = self.next; + self.next = (self.next + 1) % self.buffers.len(); + + // RGBA (del FrameSource) → XRGB8888 (bytes B,G,R,X en memoria LE). + let px = (w * h) as usize; + match &mut self.ring { + Ring::Shm { map, frame_bytes } => { + let base = unsafe { map.add(idx * *frame_bytes) as *mut u32 }; + for i in 0..px { + let r = self.rgba[i * 4] as u32; + let g = self.rgba[i * 4 + 1] as u32; + let b = self.rgba[i * 4 + 2] as u32; + unsafe { base.add(i).write((r << 16) | (g << 8) | b) }; + } + } + Ring::Dmabuf { bos, .. } => { + // `map_mut` respeta el stride del mapeo (puede diferir de w*4). + let rgba = &self.rgba; + let _ = bos[idx].map_mut(0, 0, w, h, |m| { + let stride = m.stride() as usize; + let buf = m.buffer_mut(); + for y in 0..h as usize { + let dst_row = y * stride; + let src_row = y * w as usize * 4; + for x in 0..w as usize { + let s = src_row + x * 4; + let r = rgba[s] as u32; + let g = rgba[s + 1] as u32; + let b = rgba[s + 2] as u32; + let word = (r << 16) | (g << 8) | b; + let d = dst_row + x * 4; + buf[d..d + 4].copy_from_slice(&word.to_ne_bytes()); + } + } + }); + } + } + + self.child.attach(Some(&self.buffers[idx]), 0, 0); + self.child.damage_buffer(0, 0, self.w, self.h); + self.child.commit(); + let _ = self.conn.flush(); + + // Throughput real (gateado por VIDEO_PLANE_FPS): frames commiteados/s. + self.frames_win += 1; + if self.fps_t0.elapsed() >= Duration::from_secs(1) { + if std::env::var_os("VIDEO_PLANE_FPS").is_some() { + eprintln!("[spike] pump: {} fps reales", self.frames_win); + } + self.frames_win = 0; + self.fps_t0 = Instant::now(); + } + } +} + +// --------------------------------------------------------------------------- +// El plano: subsurface + su contenido (cuadro sólido o alimentador de video). +// --------------------------------------------------------------------------- + +enum Content { + Solid(#[allow(dead_code)] WlBuffer), + Video(Feeder), +} + +struct VideoPlane { + conn: Connection, + queue: EventQueue, + globals: WlGlobals, + parent: WlSurface, + _child: WlSurface, + subsurface: WlSubsurface, + content: Content, + below: bool, +} + +impl VideoPlane { + /// Monta la segunda conexión sobre el display de winit y crea el subsurface. + /// `video` = fuente ya abierta (y sus dims/fps) para el modo video; `None` + /// = cuadro sólido. + unsafe fn atachar( + window: &Window, + video: Option<(Box, u32, u32, f32)>, + ) -> Result { + let RawDisplayHandle::Wayland(dh) = window + .display_handle() + .map_err(|e| format!("display_handle: {e}"))? + .as_raw() + else { + return Err("no estamos bajo Wayland (display handle no-Wayland)".into()); + }; + let RawWindowHandle::Wayland(wh) = window + .window_handle() + .map_err(|e| format!("window_handle: {e}"))? + .as_raw() + else { + return Err("no estamos bajo Wayland (window handle no-Wayland)".into()); + }; + let display_ptr = dh.display.as_ptr(); + let surface_ptr = wh.surface.as_ptr(); + + let backend = unsafe { Backend::from_foreign_display(display_ptr.cast()) }; + let conn = Connection::from_backend(backend); + let mut queue: EventQueue = conn.new_event_queue(); + let qh = queue.handle(); + + let display = conn.display(); + let _registry = display.get_registry(&qh, ()); + let mut globals = WlGlobals::default(); + queue + .roundtrip(&mut globals) + .map_err(|e| format!("roundtrip registry: {e}"))?; + let compositor = globals.compositor.clone().ok_or("falta wl_compositor")?; + let subcompositor = globals.subcompositor.clone().ok_or("falta wl_subcompositor")?; + let shm = globals.shm.clone().ok_or("falta wl_shm")?; + + let parent_id = unsafe { + ObjectId::from_ptr(WlSurface::interface(), surface_ptr.cast()) + .map_err(|e| format!("ObjectId::from_ptr(parent): {e}"))? + }; + let parent = WlSurface::from_id(&conn, parent_id) + .map_err(|e| format!("WlSurface::from_id(parent): {e}"))?; + + let child = compositor.create_surface(&qh, ()); + let subsurface = subcompositor.get_subsurface(&child, &parent, &qh, ()); + subsurface.set_desync(); + + let (content, sw, sh, sx, sy) = match video { + Some((source, w, h, fps)) => { + // Backend: dmabuf (default, F1b) salvo VIDEO_PLANE_SHM=1 o que el + // compositor no exponga linux-dmabuf. + let forzar_shm = std::env::var_os("VIDEO_PLANE_SHM").is_some(); + let (ring, buffers) = match (forzar_shm, globals.dmabuf.clone()) { + (false, Some(dmabuf)) => match crear_ring_dmabuf(&dmabuf, &qh, w as i32, h as i32) { + Ok(rb) => { + eprintln!("[spike] backend = DMABUF (gbm LINEAR, scanout-capable)"); + rb + } + Err(e) => { + eprintln!("[spike] dmabuf falló ({e}) — caigo a shm"); + let (map, frame_bytes, buffers) = + crear_ring_shm(&shm, &qh, w as i32, h as i32)?; + (Ring::Shm { map, frame_bytes }, buffers) + } + }, + _ => { + eprintln!("[spike] backend = SHM (jamás promueve a plano)"); + let (map, frame_bytes, buffers) = + crear_ring_shm(&shm, &qh, w as i32, h as i32)?; + (Ring::Shm { map, frame_bytes }, buffers) + } + }; + let fps = if fps.is_finite() && fps > 1.0 { fps } else { 30.0 }; + let feeder = Feeder { + source, + child: child.clone(), + conn: conn.clone(), + ring, + w: w as i32, + h: h as i32, + buffers, + next: 0, + rgba: Vec::new(), + last: Instant::now(), + interval: Duration::from_secs_f32(1.0 / fps), + frames_win: 0, + fps_t0: Instant::now(), + }; + eprintln!("[spike] video {w}x{h} @ {fps:.1}fps atachado al subsurface"); + (Content::Video(feeder), w as i32, h as i32, 0, 0) + } + None => { + let buffer = crear_buffer_solido(&shm, &qh, SOLID_W, SOLID_H)?; + child.attach(Some(&buffer), 0, 0); + child.damage_buffer(0, 0, SOLID_W, SOLID_H); + child.commit(); + (Content::Solid(buffer), SOLID_W, SOLID_H, SOLID_X, SOLID_Y) + } + }; + + subsurface.set_position(sx, sy); + // Región opaca: condición para que mirada lo mande a un plano de overlay + // (Fase 1b). No cambia la visibilidad ahora. + let region = compositor.create_region(&qh, ()); + region.add(0, 0, sw, sh); + child.set_opaque_region(Some(®ion)); + region.destroy(); + child.commit(); + + conn.flush().map_err(|e| format!("flush: {e}"))?; + + Ok(Self { + conn, + queue, + globals, + parent, + _child: child, + subsurface, + content, + below: false, + }) + } + + fn frame_interval(&self) -> Option { + match &self.content { + Content::Video(f) => Some(f.interval), + Content::Solid(_) => None, + } + } + + fn set_below(&mut self, below: bool) { + self.below = below; + if below { + self.subsurface.place_below(&self.parent); + } else { + self.subsurface.place_above(&self.parent); + } + let _ = self.conn.flush(); + } + + /// Empuja frames de video (si aplica) y drena eventos de la queue. + fn pump(&mut self, now: Instant) { + if let Content::Video(f) = &mut self.content { + f.pump(now); + } + let _ = self.queue.dispatch_pending(&mut self.globals); + let _ = self.conn.flush(); + } +} + +// --------------------------------------------------------------------------- +// Helpers shm +// --------------------------------------------------------------------------- + +/// Crea un memfd de `size` bytes y lo mapea. Devuelve (fd, puntero). +fn memfd_map(size: usize) -> Result<(i32, *mut u8), String> { + let fd = unsafe { libc::memfd_create(c"llimphi-video-plane".as_ptr(), 0) }; + if fd < 0 { + return Err("memfd_create falló".into()); + } + if unsafe { libc::ftruncate(fd, size as libc::off_t) } != 0 { + unsafe { libc::close(fd) }; + return Err("ftruncate falló".into()); + } + let map = unsafe { + libc::mmap( + std::ptr::null_mut(), + size, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + fd, + 0, + ) + }; + if map == libc::MAP_FAILED { + unsafe { libc::close(fd) }; + return Err("mmap falló".into()); + } + Ok((fd, map as *mut u8)) +} + +/// Pool shm con `RING` buffers XRGB8888 de `w×h` contiguos. Devuelve el mapeo, +/// el tamaño de un frame, y los `wl_buffer`. +fn crear_ring_shm( + shm: &WlShm, + qh: &QueueHandle, + w: i32, + h: i32, +) -> Result<(*mut u8, usize, Vec), String> { + let stride = w * 4; + let frame_bytes = (stride * h) as usize; + let total = frame_bytes * RING; + let (fd, map) = memfd_map(total)?; + let borrowed = unsafe { BorrowedFd::borrow_raw(fd) }; + let pool = shm.create_pool(borrowed, total as i32, qh, ()); + let buffers = (0..RING) + .map(|i| { + pool.create_buffer( + (i * frame_bytes) as i32, + w, + h, + stride, + wl_shm::Format::Xrgb8888, + qh, + (), + ) + }) + .collect(); + unsafe { libc::close(fd) }; + pool.destroy(); + Ok((map, frame_bytes, buffers)) +} + +/// Ring de `RING` buffers **dmabuf** (gbm LINEAR, XRGB8888) de `w×h`, envueltos +/// como `wl_buffer` por `zwp_linux_dmabuf`. A diferencia del shm, son +/// scanout-capable: mirada los puede promover a un plano de overlay del DRM. +/// Devuelve el `Ring::Dmabuf` (retiene device+bos vivos) y los `wl_buffer`. +fn crear_ring_dmabuf( + dmabuf: &ZwpLinuxDmabufV1, + qh: &QueueHandle, + w: i32, + h: i32, +) -> Result<(Ring, Vec), String> { + // Nodo de render de la GPU: el mismo Intel que usa mirada, así el buffer + // LINEAR es importable para composición y escaneable en un plano. + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/dri/renderD128") + .map_err(|e| format!("abrir renderD128: {e}"))?; + let device = GbmDevice::new(file).map_err(|e| format!("gbm device: {e}"))?; + + // SIN `SCANOUT`: un render node (renderD128) no puede asignar buffers de + // scanout (EACCES — eso es del card node con DRM master, que tiene mirada). + // No hace falta: mirada importa el dmabuf y forja SU framebuffer en card1; lo + // que lo hace escaneable es el modifier **LINEAR**, no el flag del cliente. + // `WRITE` da EINVAL en Intel (es para gbm_bo_write, no para map); para mapear + // por CPU basta `LINEAR`. Probamos combos en orden hasta que uno asigne. + let combos: [(&str, BufferObjectFlags); 3] = [ + ("LINEAR|RENDERING", BufferObjectFlags::LINEAR | BufferObjectFlags::RENDERING), + ("LINEAR", BufferObjectFlags::LINEAR), + ("RENDERING", BufferObjectFlags::RENDERING), + ]; + let fourcc = GbmFormat::Xrgb8888 as u32; + + let mut bos: Vec> = Vec::with_capacity(RING); + let mut buffers: Vec = Vec::with_capacity(RING); + for i in 0..RING { + let bo = { + let mut hecho: Option> = None; + let mut ultimo = String::new(); + for (nombre, flags) in combos { + match device.create_buffer_object::<()>(w as u32, h as u32, GbmFormat::Xrgb8888, flags) { + Ok(b) => { + if i == 0 { + eprintln!("[spike] gbm flags = {nombre}"); + } + hecho = Some(b); + break; + } + Err(e) => ultimo = format!("{nombre}: {e}"), + } + } + hecho.ok_or_else(|| format!("gbm bo[{i}] (ningún combo): {ultimo}"))? + }; + let fd = bo.fd().map_err(|_| format!("gbm bo[{i}].fd inválido"))?; + let modifier: u64 = bo.modifier().into(); + let params = dmabuf.create_params(qh, ()); + params.add( + fd.as_fd(), + 0, + bo.offset(0), + bo.stride(), + (modifier >> 32) as u32, + (modifier & 0xffff_ffff) as u32, + ); + // create_immed (v2+): sin roundtrip. El compositor dupea el fd; si el + // formato/modifier no le sirve manda un protocol error (lo veríamos). + let buffer = params.create_immed( + w, + h, + fourcc, + zwp_linux_buffer_params_v1::Flags::empty(), + qh, + (), + ); + params.destroy(); + drop(fd); // ya dupeado en create_immed; el bo mantiene el buffer real. + if i == 0 { + eprintln!( + "[spike] dmabuf bo {w}x{h} stride={} offset={} modifier={modifier:#x} fourcc={fourcc:#x}", + bo.stride(), + bo.offset(0), + ); + } + bos.push(bo); + buffers.push(buffer); + } + Ok((Ring::Dmabuf { _device: device, bos }, buffers)) +} + +/// Buffer shm de color sólido (modo sin archivo). +fn crear_buffer_solido( + shm: &WlShm, + qh: &QueueHandle, + w: i32, + h: i32, +) -> Result { + let stride = w * 4; + let size = (stride * h) as usize; + let (fd, map) = memfd_map(size)?; + let px = map as *mut u32; + for i in 0..(w * h) as isize { + unsafe { px.offset(i).write(0x00FF_7800) }; // naranja + } + let borrowed = unsafe { BorrowedFd::borrow_raw(fd) }; + let pool = shm.create_pool(borrowed, size as i32, qh, ()); + let buffer = pool.create_buffer(0, w, h, stride, wl_shm::Format::Xrgb8888, qh, ()); + unsafe { libc::close(fd) }; + let _ = map; + pool.destroy(); + Ok(buffer) +} + +// --------------------------------------------------------------------------- +// UI winit + wgpu (limpia el fondo; el "hueco" = fondo transparente) +// --------------------------------------------------------------------------- + +struct Gpu { + surface: wgpu::Surface<'static>, + device: wgpu::Device, + queue: wgpu::Queue, + config: wgpu::SurfaceConfiguration, +} + +impl Gpu { + fn new(window: Arc) -> Result { + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::PRIMARY, + ..Default::default() + }); + let surface = instance + .create_surface(window.clone()) + .map_err(|e| format!("create_surface: {e}"))?; + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + force_fallback_adapter: false, + compatible_surface: Some(&surface), + })) + .map_err(|e| format!("request_adapter: {e}"))?; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("video-plane-spike"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), + experimental_features: wgpu::ExperimentalFeatures::default(), + trace: wgpu::Trace::Off, + })) + .map_err(|e| format!("request_device: {e}"))?; + + let caps = surface.get_capabilities(&adapter); + let format = caps + .formats + .iter() + .copied() + .find(|f| matches!(f, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm)) + .unwrap_or(caps.formats[0]); + let alpha_mode = caps + .alpha_modes + .iter() + .copied() + .find(|a| matches!(a, wgpu::CompositeAlphaMode::PreMultiplied)) + .unwrap_or(caps.alpha_modes[0]); + eprintln!("[spike] wgpu formato={format:?} alpha={alpha_mode:?}"); + let size = window.inner_size(); + let config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: size.width.max(1), + height: size.height.max(1), + present_mode: wgpu::PresentMode::Fifo, + desired_maximum_frame_latency: 2, + alpha_mode, + view_formats: vec![], + }; + surface.configure(&device, &config); + Ok(Self { surface, device, queue, config }) + } + + fn resize(&mut self, w: u32, h: u32) { + self.config.width = w.max(1); + self.config.height = h.max(1); + self.surface.configure(&self.device, &self.config); + } + + fn render(&self, transparent: bool) { + let frame = match self.surface.get_current_texture() { + Ok(f) => f, + Err(_) => return, + }; + let view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let mut enc = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + let clear = if transparent { + wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } + } else { + wgpu::Color { r: 0.05, g: 0.07, b: 0.12, a: 1.0 } + }; + { + let _pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor { + label: None, + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(clear), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + } + self.queue.submit(Some(enc.finish())); + frame.present(); + } +} + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- + +struct SpikeApp { + /// Fuente de video ya abierta (o None para el cuadro sólido). Se consume al + /// crear el plano. + pending_video: Option<(Box, u32, u32, f32)>, + win_size: (u32, u32), + window: Option>, + gpu: Option, + plane: Option, + transparent: bool, + interval: Option, +} + +impl ApplicationHandler for SpikeApp { + fn resumed(&mut self, el: &ActiveEventLoop) { + if self.window.is_some() { + return; + } + let attrs = Window::default_attributes() + .with_title("llimphi-video-plane · subsurface spike") + .with_transparent(true) + .with_inner_size(winit::dpi::LogicalSize::new(self.win_size.0, self.win_size.1)); + let window = Arc::new(el.create_window(attrs).expect("create_window")); + + let gpu = match Gpu::new(window.clone()) { + Ok(g) => g, + Err(e) => { + eprintln!("[spike] GPU falló: {e}"); + el.exit(); + return; + } + }; + // Primer frame: crea/commitea el padre antes de atachar el subsurface. + gpu.render(self.transparent); + + let video = self.pending_video.take(); + match unsafe { VideoPlane::atachar(&window, video) } { + Ok(p) => { + self.interval = p.frame_interval(); + eprintln!("[spike] subsurface OK. B=abajo/arriba, T=transparente, ESC=salir."); + self.plane = Some(p); + } + Err(e) => eprintln!("[spike] atachar subsurface FALLÓ: {e}"), + } + + self.gpu = Some(gpu); + self.window = Some(window); + // NO pedimos redraw: el padre es un host transparente estático. Redibujarlo + // por frame bloquea el loop en `present` (Fifo) cuando mirada no manda + // frame-callbacks (ventana no visible). El video lo bombea `about_to_wait` + // (Poll) sobre el subsurface, que commitea independiente (desync). + } + + fn window_event(&mut self, el: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + match event { + WindowEvent::CloseRequested => el.exit(), + WindowEvent::Resized(size) => { + if let Some(g) = &mut self.gpu { + g.resize(size.width, size.height); + } + if let Some(w) = &self.window { + w.request_redraw(); + } + } + WindowEvent::RedrawRequested => { + // Sólo si el compositor lo pide (frame-callback real): repintamos el + // padre. NO re-pedimos redraw (evita el bloqueo en `present`). + if let Some(g) = &self.gpu { + g.render(self.transparent); + } + } + WindowEvent::KeyboardInput { + event: KeyEvent { logical_key, state: ElementState::Pressed, .. }, + .. + } => match logical_key { + Key::Named(NamedKey::Escape) => el.exit(), + Key::Character(c) if c.eq_ignore_ascii_case("b") => { + if let Some(p) = &mut self.plane { + let below = !p.below; + p.set_below(below); + eprintln!("[spike] subsurface {}", if below { "DEBAJO" } else { "ENCIMA" }); + } + if let Some(w) = &self.window { + w.request_redraw(); + } + } + Key::Character(c) if c.eq_ignore_ascii_case("t") => { + self.transparent = !self.transparent; + eprintln!("[spike] fondo {}", if self.transparent { "TRANSPARENTE" } else { "opaco" }); + if let Some(w) = &self.window { + w.request_redraw(); + } + } + _ => {} + }, + _ => {} + } + } + + /// Despertado por el hilo timer (via `EventLoopProxy::send_event`) a cadencia + /// de video: bombea un frame al subsurface. Este es el ÚNICO driver del pump — + /// ni el `WaitUntil` ni los frame-callbacks del padre despiertan el loop bajo + /// mirada, pero `send_event` sí. Mono-core, sin busy-loop → el decoder respira. + fn user_event(&mut self, _el: &ActiveEventLoop, _event: ()) { + if let Some(p) = &mut self.plane { + p.pump(Instant::now()); + } + } +} + +/// Fuente **sintética**: patrón animado (tablero diagonal en movimiento + +/// gradiente) a `w×h`. Decoupla la medición del pipeline dmabuf de foreign-av, +/// que se estanca al manejarlo sin el sistema de clock/sync de media-app. Emite +/// un frame por tick (el hilo timer del loop pacea la cadencia real). +struct SyntheticSource { + w: u32, + h: u32, + t: f32, +} + +impl FrameSource for SyntheticSource { + fn tick(&mut self, dt: Duration, buf: &mut Vec) -> Option<(u32, u32)> { + self.t += dt.as_secs_f32(); + let (w, h) = (self.w as usize, self.h as usize); + buf.resize(w * h * 4, 0); + let phase = (self.t * 120.0) as i32; // scroll ~120 px/s + for y in 0..h { + let gy = ((y * 255) / h) as u8; + for x in 0..w { + let band = (((x as i32 + y as i32 + phase) / 32) & 1) as u8; + let i = (y * w + x) * 4; + buf[i] = if band == 0 { 40 } else { 220 }; + buf[i + 1] = ((x * 255) / w) as u8; + buf[i + 2] = gy; + buf[i + 3] = 255; + } + } + Some((self.w, self.h)) + } +} + +/// Abre `path` por foreign-av y arma un `FrameSource` + dims + fps. +fn abrir_video(path: &str) -> Result<(Box, u32, u32, f32), String> { + let info = foreign_av::probe(path).map_err(|e| format!("probe: {e:?}"))?; + let session = foreign_av::MediaSession::open(info).map_err(|e| format!("open: {e:?}"))?; + let src = foreign_av::FfmpegVideoSource::from_session(session) + .map_err(|e| format!("video source: {e:?}"))?; + let (w, h) = src.dimensions(); + let fps = src.fps(); + Ok((Box::new(src), w, h, fps)) +} + +fn main() { + let arg = std::env::args().nth(1); + let (pending_video, win_size) = match arg.as_deref() { + // Modo medición: fuente sintética a 30 fps, sin foreign-av. + Some("test") => { + let (w, h, fps) = (1024u32, 576u32, 30.0f32); + let src: Box = Box::new(SyntheticSource { w, h, t: 0.0 }); + eprintln!("[spike] modo TEST: fuente sintética {w}x{h} @ {fps}fps"); + (Some((src, w, h, fps)), (w, h)) + } + Some(path) => match abrir_video(path) { + Ok(v) => { + let (_, w, h, _) = &v; + let win = ((*w).min(1600).max(320), (*h).min(900).max(240)); + (Some(v), win) + } + Err(e) => { + eprintln!("[spike] no pude abrir {path}: {e} — caigo a cuadro sólido"); + (None, DEFAULT_WIN) + } + }, + None => { + eprintln!("[spike] sin archivo → cuadro sólido. Uso: subsurface-spike