diff --git a/Cargo.lock b/Cargo.lock index e4a5010..9937464 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,14 +24,23 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e25ae84c0260bdf5df07796d7cc4882460de26a2b406ec0e6c42461a723b271b" +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + [[package]] name = "accesskit_atspi_common" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29bd41de2e54451a8ca0dd95ebf45b54d349d29ebceb7f20be264eee14e3d477" dependencies = [ - "accesskit", - "accesskit_consumer", + "accesskit 0.19.0", + "accesskit_consumer 0.28.0", "atspi-common", "serde", "thiserror 1.0.69", @@ -44,31 +53,69 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bfae7c152994a31dc7d99b8eeac7784a919f71d1b306f4b83217e110fd3824c" dependencies = [ - "accesskit", + "accesskit 0.19.0", "hashbrown 0.15.5", ] +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit 0.24.1", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_ios" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "750c4e9f6ce888dfe8a10c0f1b5ceb646a7854fd46579d74919219d1bb314083" +dependencies = [ + "accesskit 0.24.1", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit", +] + [[package]] name = "accesskit_macos" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "692dd318ff8a7a0ffda67271c4bd10cf32249656f4e49390db0b26ca92b095f2" dependencies = [ - "accesskit", - "accesskit_consumer", + "accesskit 0.19.0", + "accesskit_consumer 0.28.0", "hashbrown 0.15.5", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit 0.24.1", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "accesskit_unix" version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5f7474c36606d0fe4f438291d667bae7042ea2760f506650ad2366926358fc8" dependencies = [ - "accesskit", + "accesskit 0.19.0", "accesskit_atspi_common", "async-channel", "async-executor", @@ -86,24 +133,52 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70a042b62c9c05bf7b616f015515c17d2813f3ba89978d6f4fc369735d60700a" dependencies = [ - "accesskit", - "accesskit_consumer", + "accesskit 0.19.0", + "accesskit_consumer 0.28.0", "hashbrown 0.15.5", "static_assertions", "windows 0.61.3", "windows-core 0.61.2", ] +[[package]] +name = "accesskit_windows" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" +dependencies = [ + "accesskit 0.24.1", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "accesskit_winit" version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1f0d3d13113d8857542a4f8d1a1c24d1dc1527b77aee8426127f4901588708" dependencies = [ - "accesskit", - "accesskit_macos", + "accesskit 0.19.0", + "accesskit_macos 0.20.0", "accesskit_unix", - "accesskit_windows", + "accesskit_windows 0.27.0", + "raw-window-handle", + "winit", +] + +[[package]] +name = "accesskit_winit" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5b41e63a69f36d9f1f41e70464c7e5f72eee485ef26aab19f0b4f86e6c0a84c" +dependencies = [ + "accesskit 0.24.1", + "accesskit_ios", + "accesskit_macos 0.26.3", + "accesskit_windows 0.34.0", "raw-window-handle", "winit", ] @@ -127,6 +202,21 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-activity" version = "0.6.1" @@ -152,6 +242,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -161,6 +257,17 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.12.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a5b0a5ab8561c603f16c3b78c24a386b1f70e7fc1b1b1e8c15738828e4a176" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + [[package]] name = "anstream" version = "1.0.0" @@ -217,6 +324,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arboard" version = "3.7.0" @@ -234,6 +350,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.61.2", + "wl-clipboard-rs", "x11rb", ] @@ -264,6 +381,17 @@ dependencies = [ "libloading", ] +[[package]] +name = "assert_type_match" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -302,6 +430,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.6.0" @@ -383,6 +522,9 @@ name = "async-task" version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +dependencies = [ + "portable-atomic", +] [[package]] name = "async-trait" @@ -400,6 +542,19 @@ name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "atomicow" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301801c08259e328a1c7da556608c0c22687708831b22024dbd3a57ea741e6de" +dependencies = [ + "portable-atomic", + "portable-atomic-util", +] [[package]] name = "atspi" @@ -463,6 +618,340 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "bevy" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd0e4ea5ec677698955f9f285221eab78606d8c27b57b889def199c16ce36e6" +dependencies = [ + "bevy_internal", +] + +[[package]] +name = "bevy_a11y" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1070b9b7f087a5073be2da713367f29a831c85cd3391afcfc95bc0498125704" +dependencies = [ + "accesskit 0.24.1", + "bevy_app", + "bevy_derive", + "bevy_ecs", + "bevy_reflect", +] + +[[package]] +name = "bevy_android" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17fd56a4708085b36f8aa1a011e347f8bd9334f0343bb3d5d9c3742897968a55" +dependencies = [ + "android-activity", +] + +[[package]] +name = "bevy_app" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2489da2972d57cccd19060d7faa44c4c4cfb296a657c84ee6ab8bde2b1c0115" +dependencies = [ + "bevy_derive", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "console_error_panic_hook", + "ctrlc", + "downcast-rs 2.0.2", + "log", + "thiserror 2.0.21", + "variadics_please", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "bevy_asset" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b88b3767a2c3f5a017b2febca08a670819ea2acb46846e4d8cd02980aeb0ed3e" +dependencies = [ + "async-broadcast", + "async-channel", + "async-fs", + "async-io", + "async-lock", + "atomicow", + "bevy_android", + "bevy_app", + "bevy_asset_macros", + "bevy_diagnostic", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "bitflags 2.13.2", + "blake3", + "crossbeam-channel", + "derive_more", + "disqualified", + "downcast-rs 2.0.2", + "either", + "futures-io", + "futures-lite", + "futures-util", + "js-sys", + "ron 0.12.2", + "serde", + "stackfuture", + "thiserror 2.0.21", + "tracing", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "bevy_asset_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875b4fd236612744dbd61fb0a77ffde0aee37b8b780cf8a89caa308511c70646" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_camera" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f32c0d8436fb9373dc3b64fa30c3dec51d6a7cfda61de3c70fd1f62f8a903887" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_reflect", + "bevy_shape", + "bevy_transform", + "bevy_utils", + "bevy_window", + "derive_more", + "downcast-rs 2.0.2", + "serde", + "smallvec", + "thiserror 2.0.21", + "wgpu-types 30.0.1", +] + +[[package]] +name = "bevy_clipboard" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0267b0beafd7e587a1b25b5ce14b271577f5fc1d8fd7828b2b66f0cf5dd649b" +dependencies = [ + "arboard", + "bevy_app", + "bevy_ecs", + "bevy_log", + "bevy_platform", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "bevy_color" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e57bfc1a0da1c018235fb98bb0e15ab61f1d0253122020ab6e1450e6cfbcf9" +dependencies = [ + "bevy_curve", + "bevy_math", + "bevy_reflect", + "bytemuck", + "derive_more", + "encase", + "serde", + "thiserror 2.0.21", + "wgpu-types 30.0.1", +] + +[[package]] +name = "bevy_core_pipeline" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a077b301be4b373a4cb224c54988e1f8385ecf3ab9e60bd3727a65b3d6901a" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_extract", + "bevy_image", + "bevy_light", + "bevy_log", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_transform", + "bevy_utils", + "bevy_window", + "bitflags 2.13.2", + "indexmap", + "nonmax", +] + +[[package]] +name = "bevy_curve" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ae28ed95c1c8a1bfc8930471840ee5a4b99b6b31138b17598e7ee6f4291b64" +dependencies = [ + "approx", + "bevy_math", + "bevy_reflect", + "itertools 0.14.0", + "serde", + "thiserror 2.0.21", + "variadics_please", +] + +[[package]] +name = "bevy_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd59e9e600d792e60df17b4d52719b28c288bf047884ac89ad29d9c7a6592d2c" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_diagnostic" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8851dc1b253b350bf941695457007e6e44ad4b8f075bf52a1ef202e6866b6a" +dependencies = [ + "atomic-waker", + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_tasks", + "bevy_time", + "const-fnv1a-hash", + "log", + "serde", +] + +[[package]] +name = "bevy_ecs" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5541a1658c5029b39e3ec59c6ecb47ad0a6d85fa083312a5ee2a95d6982a926" +dependencies = [ + "arrayvec", + "bevy_ecs_macros", + "bevy_platform", + "bevy_ptr", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "bitflags 2.13.2", + "bumpalo", + "concurrent-queue", + "derive_more", + "fixedbitset", + "indexmap", + "log", + "nonmax", + "serde", + "slotmap", + "smallvec", + "thiserror 2.0.21", + "variadics_please", +] + +[[package]] +name = "bevy_ecs_macro_logic" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5197fea7643d2733e8b6d81c3dfa99adad3a754cdfd159c00255cea157b795d7" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_ecs_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dde5305169503c9101404bf8cf17e8351f56fccf5152b8e5d7d7c32dcf1c486" +dependencies = [ + "bevy_ecs_macro_logic", + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_encase_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e4c4183c39ae8d00e53afeeab66aa71c41b08d82638149f9d485a2a62df569" +dependencies = [ + "bevy_macro_utils", + "encase_derive_impl", +] + +[[package]] +name = "bevy_extract" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08e27fc07ef8e3ab7c217fed1b171d42c90f3b6e4f7f526ffa28f9006ac49aa4" +dependencies = [ + "bevy_app", + "bevy_camera", + "bevy_derive", + "bevy_ecs", + "bevy_extract_macros", + "bevy_log", + "bevy_platform", + "bevy_reflect", + "bevy_time", + "bevy_utils", +] + +[[package]] +name = "bevy_extract_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a5c67e8c55ef35c65295e8cb614eda169780e21e2748f6df470d3bff21849a3" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "bevy_forge_cli" version = "0.1.0" @@ -479,11 +968,11 @@ name = "bevy_forge_core" version = "0.1.0" dependencies = [ "indexmap", - "ron", + "ron 0.8.1", "serde", "tempfile", "thiserror 2.0.21", - "toml", + "toml 0.8.23", ] [[package]] @@ -497,13 +986,926 @@ dependencies = [ "tempfile", ] +[[package]] +name = "bevy_forge_runtime" +version = "0.1.0" +dependencies = [ + "bevy", + "bevy_forge_core", +] + +[[package]] +name = "bevy_gizmos" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc92f635ec5eaef5733afee45e6a0452e8c65de42d818dc7269dc5265087689" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_curve", + "bevy_ecs", + "bevy_gizmos_macros", + "bevy_input", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_reflect", + "bevy_shape", + "bevy_time", + "bevy_transform", + "bevy_utils", + "bevy_window", +] + +[[package]] +name = "bevy_gizmos_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501e72c4ac31caef17ef9e627dee212d2107b95402a891ec6f0fc4107dd0cdf4" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_gizmos_render" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5be0c9f114dc9b2c929076517d088e059b971edec5dfff4d5a225df6796df" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_ecs", + "bevy_extract", + "bevy_gizmos", + "bevy_image", + "bevy_log", + "bevy_material", + "bevy_math", + "bevy_mesh", + "bevy_pbr", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_shape", + "bevy_sprite_render", + "bevy_transform", + "bevy_utils", + "bytemuck", + "tracing", +] + +[[package]] +name = "bevy_image" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb818dd8d705809d54adea08e346edce81a51ddbf06406ddf12a72ee467a3855" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_ecs", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_utils", + "bitflags 2.13.2", + "bytemuck", + "futures-lite", + "guillotiere", + "half", + "image", + "rectangle-pack", + "serde", + "thiserror 2.0.21", + "tracing", + "wgpu-types 30.0.1", +] + +[[package]] +name = "bevy_input" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261ca5cc8a0bf228bfe4e841570db18a3ab1d034ce089772b8ec49c163ae74ea" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "derive_more", + "log", + "smol_str", + "thiserror 2.0.21", +] + +[[package]] +name = "bevy_input_focus" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83c81d369d0ab3ec17ba14bab1e07da7cacf3751a3104a93b1a3cd99d887887b" +dependencies = [ + "bevy_app", + "bevy_camera", + "bevy_ecs", + "bevy_input", + "bevy_math", + "bevy_picking", + "bevy_reflect", + "bevy_window", + "log", + "thiserror 2.0.21", +] + +[[package]] +name = "bevy_internal" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ed1cb58d1761426a077a8aff55e6a2e0cdfd19e411211d4b0217409330bb84" +dependencies = [ + "bevy_a11y", + "bevy_android", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_clipboard", + "bevy_color", + "bevy_core_pipeline", + "bevy_curve", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_extract", + "bevy_gizmos", + "bevy_gizmos_render", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_light", + "bevy_log", + "bevy_material", + "bevy_math", + "bevy_mesh", + "bevy_pbr", + "bevy_picking", + "bevy_platform", + "bevy_ptr", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_shape", + "bevy_sprite", + "bevy_sprite_render", + "bevy_state", + "bevy_tasks", + "bevy_text", + "bevy_time", + "bevy_transform", + "bevy_ui", + "bevy_ui_render", + "bevy_ui_widgets", + "bevy_utils", + "bevy_window", + "bevy_winit", +] + +[[package]] +name = "bevy_light" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529d5ec7db637bbce7d016b2bfd0798362ad67118de8e9d37aaac24ecb3337cd" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_curve", + "bevy_ecs", + "bevy_gizmos", + "bevy_image", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_shape", + "bevy_transform", + "bevy_utils", + "half", + "smallvec", + "tracing", + "wgpu-types 30.0.1", +] + +[[package]] +name = "bevy_log" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a87d5152292b0e581a589cffb6b7e83e0afe345279d4126545ce7b786bd8a47a" +dependencies = [ + "android_log-sys", + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_utils", + "tracing", + "tracing-log", + "tracing-oslog", + "tracing-subscriber", + "tracing-wasm", +] + +[[package]] +name = "bevy_macro_utils" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0d03a948df65138e30917d91237797d7e38763135ebacd0f28c891e1a3b2b52" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "toml_edit 0.25.17+spec-1.1.0", +] + +[[package]] +name = "bevy_material" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be7ee4b2f4764e8d58a59afb47a3ba8bae36f955f409f08c658cace2808ef40" +dependencies = [ + "bevy_asset", + "bevy_derive", + "bevy_ecs", + "bevy_material_macros", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_shader", + "bevy_utils", + "encase", + "smallvec", + "thiserror 2.0.21", + "tracing", + "variadics_please", + "wgpu-types 30.0.1", +] + +[[package]] +name = "bevy_material_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70551fa201e1416ab44968631b7ce6320c194acfdb652a1971ec6882af122333" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_math" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598cbdcec4d0f0d966905996240ac76f77a644c583f95fb612d4f59f5f649038" +dependencies = [ + "approx", + "bevy_reflect", + "derive_more", + "glam", + "libm", + "rand", + "serde", + "thiserror 2.0.21", + "variadics_please", +] + +[[package]] +name = "bevy_mesh" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09358ac9a3ede5855568c10eaba6bcf126bf86639e786fb21b413457ebec5f26" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_derive", + "bevy_ecs", + "bevy_encase_derive", + "bevy_math", + "bevy_mikktspace", + "bevy_platform", + "bevy_reflect", + "bevy_shape", + "bevy_transform", + "bitflags 2.13.2", + "bytemuck", + "derive_more", + "encase", + "glam", + "half", + "hexasphere", + "thiserror 2.0.21", + "tracing", + "wgpu-types 30.0.1", +] + +[[package]] +name = "bevy_mikktspace" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bff34eb29ff4b8a8688bc7299f14fb6b597461ca80fec03ed7d22939ab33e48f" + +[[package]] +name = "bevy_pbr" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb760932d1e683e7c00011f5c99e4e1cc893646a3a7b7224da6daf9bfa94853d" +dependencies = [ + "arrayvec", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_extract", + "bevy_image", + "bevy_light", + "bevy_log", + "bevy_material", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_shape", + "bevy_tasks", + "bevy_transform", + "bevy_utils", + "bitflags 2.13.2", + "bytemuck", + "derive_more", + "fixedbitset", + "indexmap", + "nonmax", + "offset-allocator", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "tracing", + "wgpu-types 30.0.1", +] + +[[package]] +name = "bevy_picking" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a04fb644c62a57a4f338f5cd8b61519c569139742eafe87dccb523e4b7cf2e" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_derive", + "bevy_ecs", + "bevy_input", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_shape", + "bevy_time", + "bevy_transform", + "bevy_window", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_platform" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000bc467d1b344b6609fcf217a9b08d349d49c8e85dc4179fb7656f2b6e3db47" +dependencies = [ + "bytemuck", + "critical-section", + "foldhash 0.2.0", + "futures-channel", + "futures-lite", + "hashbrown 0.16.1", + "js-sys", + "portable-atomic", + "portable-atomic-util", + "serde", + "spin", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", + "windows-sys 0.61.2", +] + +[[package]] +name = "bevy_ptr" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41935a00c02004753bd7ed5094754397800b3f673bf15553bb007b06216872d5" + +[[package]] +name = "bevy_reflect" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eecc733575b873deedcc37639e36290092388657091b3321fa6e073e7818e6" +dependencies = [ + "assert_type_match", + "bevy_platform", + "bevy_ptr", + "bevy_reflect_derive", + "bevy_utils", + "derive_more", + "disqualified", + "downcast-rs 2.0.2", + "erased-serde", + "foldhash 0.2.0", + "glam", + "indexmap", + "inventory", + "serde", + "smallvec", + "smol_str", + "thiserror 2.0.21", + "uuid", + "variadics_please", + "wgpu-types 30.0.1", +] + +[[package]] +name = "bevy_reflect_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfaa1e315676c625ed8d30ba8a671ebf917cad6355320295b9802a8cbc06853b" +dependencies = [ + "bevy_macro_utils", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", + "uuid", +] + +[[package]] +name = "bevy_render" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f438a921fd3ef82cc09ccf5e0cd8f1214dcae6286890f9d4acba0409d9a61a8" +dependencies = [ + "async-channel", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_encase_derive", + "bevy_extract", + "bevy_extract_macros", + "bevy_image", + "bevy_log", + "bevy_material", + "bevy_material_macros", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render_macros", + "bevy_shader", + "bevy_shape", + "bevy_tasks", + "bevy_time", + "bevy_transform", + "bevy_utils", + "bevy_window", + "bitflags 2.13.2", + "bytemuck", + "derive_more", + "downcast-rs 2.0.2", + "encase", + "glam", + "image", + "indexmap", + "itertools 0.14.0", + "js-sys", + "nonmax", + "offset-allocator", + "send_wrapper", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "tracing", + "variadics_please", + "wasm-bindgen", + "weak-table", + "web-sys", + "wgpu 30.0.1", + "wgpu-types 30.0.1", +] + +[[package]] +name = "bevy_render_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119ca6d445eba5c9976c658f29bac854094b27c27624aa92c4d9668c319618d0" +dependencies = [ + "bevy_extract_macros", + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_shader" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2becafd54079da13835d6a42fc743e305d6270c27a91de613afe6e3f4b5de19" +dependencies = [ + "bevy_asset", + "bevy_platform", + "bevy_reflect", + "bevy_utils", + "serde", + "thiserror 2.0.21", + "tracing", + "wesl", + "wgpu-types 30.0.1", +] + +[[package]] +name = "bevy_shape" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8b6096ad1ec7dfe1af8e51d7cfa7de1a9e0a3bd02be58a061092c5e1ff1eb1" +dependencies = [ + "arrayvec", + "bevy_math", + "bevy_reflect", + "derive_more", + "rand", + "rand_distr", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "bevy_sprite" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24dc4f69160513952a1969a3df5b4a2c729acfe0caf773854acea4a8c71f0918" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_reflect", + "bevy_text", + "bevy_transform", + "bevy_window", + "radsort", + "tracing", + "wgpu-types 30.0.1", +] + +[[package]] +name = "bevy_sprite_render" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8918649806697740b61d3fd0559cb8c7de2ade5046aacf8abbef20e49ea1de0c" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_extract", + "bevy_image", + "bevy_material", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_shape", + "bevy_sprite", + "bevy_text", + "bevy_transform", + "bevy_utils", + "bitflags 2.13.2", + "bytemuck", + "derive_more", + "fixedbitset", + "nonmax", + "smallvec", + "static_assertions", + "tracing", +] + +[[package]] +name = "bevy_state" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09292e08e1eb34d2dd915630fb3c04ba75220c7819943c9f213d3a5f9a8be1e8" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_state_macros", + "bevy_utils", + "log", + "variadics_please", +] + +[[package]] +name = "bevy_state_macros" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fec1a8f6a253e39ddf7e447b1b9286caacb205ed8e619e6fb9a2e010d58dd04b" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_tasks" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbd8116f12dc50bde3758a72fba85e1f4c00e6c66d0ad58bdc46c0c0690829a8" +dependencies = [ + "async-channel", + "async-executor", + "async-task", + "atomic-waker", + "bevy_platform", + "concurrent-queue", + "crossbeam-queue", + "derive_more", + "futures-lite", + "heapless", + "web-task", +] + +[[package]] +name = "bevy_text" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119d2b37ce3f5ea5a5b9092a7c9eec8d3734de62592d2869bd1d925976f34d05" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_clipboard", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_log", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_utils", + "parley", + "serde", + "smallvec", + "smol_str", + "swash", + "sys-locale", + "thiserror 2.0.21", + "tracing", + "wgpu-types 30.0.1", +] + +[[package]] +name = "bevy_time" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13901174b792bb7848d8e088075f0009ea409b99179934c0d5bdd2b95622694e" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "crossbeam-channel", + "log", + "serde", +] + +[[package]] +name = "bevy_transform" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83379023d77a0739569a5bcb859eed652781a1b3271e94a86dfbb56fc5d067e0" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_math", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "derive_more", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "bevy_ui" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7926fba78f133376db0cba73a054db6931952379b5bfdc63f2fe9ba0c3b96c39" +dependencies = [ + "accesskit 0.24.1", + "bevy_a11y", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_picking", + "bevy_platform", + "bevy_reflect", + "bevy_sprite", + "bevy_text", + "bevy_time", + "bevy_transform", + "bevy_utils", + "bevy_window", + "derive_more", + "parley", + "smallvec", + "swash", + "taffy", + "thiserror 2.0.21", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_ui_render" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "620b51b51eb8e7d8b2362ba96263142829560d9aa254e2f28032570667c6b0fb" +dependencies = [ + "bevy_a11y", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_extract", + "bevy_image", + "bevy_input_focus", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_sprite", + "bevy_sprite_render", + "bevy_text", + "bevy_transform", + "bevy_ui", + "bevy_utils", + "bytemuck", + "derive_more", + "indexmap", + "smallvec", + "tracing", +] + +[[package]] +name = "bevy_ui_widgets" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6442cd402df6a54f0e51ed8c1bd004c8f9c6715d0f8045e33c68ea9aebb1ce16" +dependencies = [ + "accesskit 0.24.1", + "bevy_a11y", + "bevy_app", + "bevy_camera", + "bevy_ecs", + "bevy_input", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_picking", + "bevy_platform", + "bevy_reflect", + "bevy_text", + "bevy_time", + "bevy_ui", + "bevy_window", + "parley", + "smol_str", + "web-sys", +] + +[[package]] +name = "bevy_utils" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cbd58495039d4e63e6fcfef1242fecab33ffe62b4a5e010467e69e790879812" +dependencies = [ + "async-channel", + "bevy_platform", + "disqualified", + "indexmap", + "thread_local", +] + +[[package]] +name = "bevy_window" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc9a83f3cbaa04155b5dc9b247819c0e215aee754451a4324b82ddeb80dc53f1" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_input", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "log", + "raw-window-handle", + "serde", +] + +[[package]] +name = "bevy_winit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ef0a5a55524e793ab13536ef244229fa66a4839fae6564df2aa4b3c13956a82" +dependencies = [ + "accesskit 0.24.1", + "accesskit_winit 0.33.2", + "approx", + "bevy_a11y", + "bevy_android", + "bevy_app", + "bevy_derive", + "bevy_ecs", + "bevy_input", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_window", + "js-sys", + "tracing", + "wasm-bindgen", + "web-sys", + "winit", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec 0.9.1", ] [[package]] @@ -512,6 +1914,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + [[package]] name = "bitflags" version = "1.3.2" @@ -524,9 +1932,23 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ + "bytemuck", "serde_core", ] +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + [[package]] name = "block" version = "0.1.6" @@ -542,6 +1964,15 @@ dependencies = [ "objc2 0.5.3", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + [[package]] name = "blocking" version = "1.7.0" @@ -581,6 +2012,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -736,6 +2173,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -759,6 +2205,62 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ "crossbeam-utils", + "portable-atomic", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-fnv1a-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca" + +[[package]] +name = "const_panic" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3" +dependencies = [ + "typewit", +] + +[[package]] +name = "const_soft_float" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ca1caa64ef4ed453e68bb3db612e51cf1b2f5b871337f0fcab1c8f87cc3dff" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "constgebra" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1aaf9b65849a68662ac6c0810c8893a765c960b907dd7cfab9c4a50bf764fbc" +dependencies = [ + "const_soft_float", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", ] [[package]] @@ -801,6 +2303,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -810,6 +2321,30 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -822,12 +2357,46 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -841,6 +2410,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", + "libc", "objc2 0.6.5", ] @@ -855,6 +2426,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "disqualified" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e90a5dd66beeca277ae954f8055b02a712792b6b6b43f2bc2f92b0b7d8291614" + [[package]] name = "dlib" version = "0.5.3" @@ -879,6 +2456,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dpi" version = "0.1.2" @@ -937,7 +2520,7 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5d0306cd61ca75e29682926d71f2390160247f135965242e904a636f51c0dc" dependencies = [ - "accesskit", + "accesskit 0.19.0", "ahash", "bitflags 2.13.2", "emath", @@ -965,7 +2548,7 @@ dependencies = [ "thiserror 1.0.69", "type-map", "web-time", - "wgpu", + "wgpu 25.0.2", "winit", ] @@ -975,7 +2558,7 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f95d0a91f9cb0dc2e732d49c2d521ac8948e1f0b758f306fb7b14d6f5db3927f" dependencies = [ - "accesskit_winit", + "accesskit_winit 0.27.0", "ahash", "arboard", "bytemuck", @@ -1007,6 +2590,12 @@ dependencies = [ "winit", ] +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + [[package]] name = "emath" version = "0.32.3" @@ -1016,6 +2605,37 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "encase" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1acbc46b960480c4959213714511e22f9a2e0d361520693bcf5057b5924f53e" +dependencies = [ + "const_panic", + "encase_derive", + "thiserror 2.0.21", +] + +[[package]] +name = "encase_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e30d38c47336a0afbdfde297b442944041cf6dccbb1a79eceea16abfb27a92a" +dependencies = [ + "encase_derive_impl", +] + +[[package]] +name = "encase_derive_impl" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495d24abe406f090f2ba8fc362ecaa8a001e192522aa75eae7b144163c3235ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "endi" version = "1.1.1" @@ -1073,6 +2693,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +[[package]] +name = "erased-serde" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -1089,6 +2720,15 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -1136,6 +2776,12 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +[[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.10" @@ -1147,12 +2793,47 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontique" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "parlance", + "read-fonts", + "smallvec", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1189,6 +2870,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1288,6 +2978,26 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glam" +version = "0.33.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37bfe73dc8ec21f54d181e5a60554327ec3f1c600c455a75ccbfc65d2106a6c4" +dependencies = [ + "approx", + "bytemuck", + "encase", + "libm", + "rand", + "serde_core", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "glow" version = "0.16.0" @@ -1397,6 +3107,20 @@ dependencies = [ "windows 0.58.0", ] +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows 0.61.3", +] + [[package]] name = "gpu-descriptor" version = "0.3.2" @@ -1417,25 +3141,69 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + [[package]] name = "half" version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ + "bytemuck", "cfg-if", "crunchy", "num-traits", "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] @@ -1443,6 +3211,20 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "portable-atomic", + "stable_deref_trait", +] [[package]] name = "heck" @@ -1462,6 +3244,17 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hexasphere" +version = "19.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163385eff5a0cf606ebe2c10b3a44ab02f4306e83fbd068a64f5e1d72a3049ac" +dependencies = [ + "constgebra", + "glam", + "tinyvec", +] + [[package]] name = "hexf-parse" version = "0.2.1" @@ -1490,11 +3283,32 @@ checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" + [[package]] name = "icu_normalizer" version = "2.4.0" @@ -1544,6 +3358,8 @@ checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1551,6 +3367,29 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3" + [[package]] name = "idna" version = "1.1.0" @@ -1600,12 +3439,39 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inventory" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c" +dependencies = [ + "rustversion", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1708,6 +3574,87 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "lalrpop-util" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "364b49d1c98c07972e16fe1346463becc08bcd663da04e6e37ab1bea068dce6a" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "lexical" +version = "7.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc8a009b2ff1f419ccc62706f04fe0ca6e67b37460513964a3dfdb919bb37d6" +dependencies = [ + "lexical-core", +] + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + [[package]] name = "libc" version = "0.2.191" @@ -1742,6 +3689,12 @@ dependencies = [ "redox_syscall 0.9.5", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1781,6 +3734,38 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "logos" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +dependencies = [ + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn 2.0.119", +] + +[[package]] +name = "logos-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -1790,6 +3775,15 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.3" @@ -1859,6 +3853,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mutants" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126" + [[package]] name = "naga" version = "25.0.1" @@ -1866,10 +3866,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.8.0", "bitflags 2.13.2", "cfg_aliases", - "codespan-reporting", + "codespan-reporting 0.12.0", "half", "hashbrown 0.15.5", "hexf-parse", @@ -1878,12 +3878,51 @@ dependencies = [ "num-traits", "once_cell", "rustc-hash 1.1.0", - "spirv", + "spirv 0.3.0+sdk-1.3.268.0", "strum", "thiserror 2.0.21", "unicode-ident", ] +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting 0.13.1", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv 0.4.0+sdk-1.4.341.0", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "serde", + "thiserror 2.0.21", +] + [[package]] name = "ndk" version = "0.9.0" @@ -1923,12 +3962,48 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2002,13 +4077,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.13.2", - "block2", + "block2 0.5.1", "libc", "objc2 0.5.3", "objc2-core-data", "objc2-core-image", "objc2-foundation 0.2.2", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", ] [[package]] @@ -2031,7 +4106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.13.2", - "block2", + "block2 0.5.1", "objc2 0.5.3", "objc2-core-location", "objc2-foundation 0.2.2", @@ -2043,7 +4118,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -2055,7 +4130,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.13.2", - "block2", + "block2 0.5.1", "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -2090,10 +4165,10 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.3", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", ] [[package]] @@ -2102,7 +4177,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.3", "objc2-contacts", "objc2-foundation 0.2.2", @@ -2121,7 +4196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.13.2", - "block2", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.3", @@ -2155,7 +4230,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -2168,11 +4243,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.13.2", - "block2", + "block2 0.5.1", "objc2 0.5.3", "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-quartz-core" version = "0.2.2" @@ -2180,10 +4267,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.13.2", - "block2", + "block2 0.5.1", "objc2 0.5.3", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", ] [[package]] @@ -2203,7 +4304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.13.2", - "block2", + "block2 0.5.1", "objc2 0.5.3", "objc2-cloud-kit", "objc2-core-data", @@ -2211,7 +4312,7 @@ dependencies = [ "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", "objc2-user-notifications", @@ -2223,7 +4324,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -2235,12 +4336,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.13.2", - "block2", + "block2 0.5.1", "objc2 0.5.3", "objc2-core-location", "objc2-foundation 0.2.2", ] +[[package]] +name = "offset-allocator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e234d535da3521eb95106f40f0b73483d80bfb3aacf27c40d7e2b72f1a3e00a2" +dependencies = [ + "log", + "nonmax", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2282,6 +4393,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" @@ -2320,6 +4441,39 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "parlance" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" + +[[package]] +name = "parley" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22d2ff88bd3f7d68d1d9b09c7e6209f9a8e8c05088295140a2bcf2e9b17038c5" +dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.17.1", + "icu_normalizer", + "icu_properties", + "icu_segmenter", + "linebender_resource_handle", + "parlance", + "parley_data", + "skrifa", +] + +[[package]] +name = "parley_data" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1567535334d6ba2d3cde19221ba9a7bd0fabb3cbd99046ddfb10ae061cfcc889" +dependencies = [ + "icu_properties", +] + [[package]] name = "paste" version = "1.0.15" @@ -2332,6 +4486,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 = "pin-project" version = "1.1.13" @@ -2414,12 +4579,23 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -2495,6 +4671,38 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radsort" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019b4b213425016d7d84a153c4c73afb0946fbb4840e4eece7ba8848b9d6da22" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_distr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" +dependencies = [ + "num-traits", + "rand", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -2507,6 +4715,35 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "rectangle-pack" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d463f2884048e7153449a55166f91028d5b0ea53c79377099ce4e8cf0cf9bb" + [[package]] name = "redox_syscall" version = "0.4.1" @@ -2534,6 +4771,23 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -2552,6 +4806,20 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.2", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -2645,6 +4913,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -2708,6 +4982,24 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -2746,6 +5038,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.12" @@ -2839,6 +5141,15 @@ dependencies = [ "serde", ] +[[package]] +name = "spin" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10" +dependencies = [ + "portable-atomic", +] + [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" @@ -2848,12 +5159,30 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stackfuture" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115beb9c69db2393ff10b75a1b8587a51716e5551d015001e55320ed279d32f9" +dependencies = [ + "const_panic", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -2894,6 +5223,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + [[package]] name = "syn" version = "2.0.119" @@ -2927,6 +5267,27 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "taffy" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639627c87f43b9181c811f40a6296409e093a17bc761214cba3c15df74f86b99" +dependencies = [ + "arrayvec", + "serde", + "slotmap", + "smallvec", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2989,6 +5350,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.11.3" @@ -3035,9 +5405,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.54.1" @@ -3054,11 +5431,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_edit 0.22.27", ] +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -3085,7 +5477,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", "winnow 0.7.15", @@ -3118,6 +5510,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "tracing" version = "0.1.44" @@ -3148,6 +5546,70 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-oslog" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76902d2a8d5f9f55a81155c08971734071968c90f2d9bfe645fe700579b2950" +dependencies = [ + "cc", + "cfg-if", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[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]] @@ -3165,6 +5627,18 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typewit" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" + [[package]] name = "uds_windows" version = "1.2.1" @@ -3194,6 +5668,23 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsynn" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a" +dependencies = [ + "mutants", + "proc-macro2", + "rustc-hash 2.1.3", +] + [[package]] name = "url" version = "2.5.8" @@ -3224,11 +5715,28 @@ version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "variadics_please" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b53d0f7f2d0182759a549f1f313ce16b07d8f9ba93098157b5fa10ee3e61117f" +dependencies = [ + "quote", + "unsynn", +] + [[package]] name = "version_check" version = "0.9.5" @@ -3317,7 +5825,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", - "downcast-rs", + "downcast-rs 1.2.1", "rustix 1.1.6", "scoped-tls", "smallvec", @@ -3445,6 +5953,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "weak-table" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eba2ec791141b0bf4db542f6b038a8f9310cee33e076cb181245780b09a24ff" +dependencies = [ + "cfg-if", + "hashbrown 0.16.1", +] + [[package]] name = "web-sys" version = "0.3.106" @@ -3455,6 +5973,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-task" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdc136a53ccd64a1211f107ccc34404769fbcc0f165f1afa065f5d88ab93538" +dependencies = [ + "async-task", + "cfg-if", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -3487,6 +6017,46 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wesl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd8f9619baa4c7bde4df0899d6f626b70ee92d6e9c18c5f74473419b5bb6fa6" +dependencies = [ + "annotate-snippets", + "anstream", + "derive_more", + "glob", + "half", + "itertools 0.15.0", + "num-traits", + "serde", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "wesl-core", + "wesl-macros", + "wgsl-parse", + "wgsl-types", +] + +[[package]] +name = "wesl-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84dd3e600497eda0b47afcd21bd84c8953a9dfce7e93ed27a38ba97ac0747e03" + +[[package]] +name = "wesl-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac8767ec26e75262a8e581d5f188b1f63a288baaf885bb33c9f4f73a693e06fc" +dependencies = [ + "itertools 0.15.0", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "wgpu" version = "25.0.2" @@ -3500,7 +6070,7 @@ dependencies = [ "hashbrown 0.15.5", "js-sys", "log", - "naga", + "naga 25.0.1", "parking_lot", "portable-atomic", "profiling", @@ -3510,9 +6080,33 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "wgpu-core", - "wgpu-hal", - "wgpu-types", + "wgpu-core 25.0.2", + "wgpu-hal 25.0.2", + "wgpu-types 25.0.0", +] + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "log", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wgpu-core 30.0.1", + "wgpu-hal 30.0.1", + "wgpu-types 30.0.1", ] [[package]] @@ -3522,15 +6116,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7b882196f8368511d613c6aeec80655160db6646aebddf8328879a88d54e500" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.8.0", + "bit-vec 0.8.0", "bitflags 2.13.2", "cfg_aliases", "document-features", "hashbrown 0.15.5", "indexmap", "log", - "naga", + "naga 25.0.1", "once_cell", "parking_lot", "portable-atomic", @@ -3539,11 +6133,44 @@ dependencies = [ "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.21", - "wgpu-core-deps-apple", + "wgpu-core-deps-apple 25.0.0", "wgpu-core-deps-emscripten", - "wgpu-core-deps-windows-linux-android", - "wgpu-hal", - "wgpu-types", + "wgpu-core-deps-windows-linux-android 25.0.0", + "wgpu-hal 25.0.2", + "wgpu-types 25.0.0", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bit-vec 0.9.1", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga 30.0.1", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple 30.0.1", + "wgpu-core-deps-windows-linux-android 30.0.1", + "wgpu-hal 30.0.1", + "wgpu-naga-bridge", + "wgpu-types 30.0.1", ] [[package]] @@ -3552,7 +6179,16 @@ version = "25.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfd488b3239b6b7b185c3b045c39ca6bf8af34467a4c5de4e0b1a564135d093d" dependencies = [ - "wgpu-hal", + "wgpu-hal 25.0.2", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal 30.0.1", ] [[package]] @@ -3561,7 +6197,7 @@ version = "25.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f09ad7aceb3818e52539acc679f049d3475775586f3f4e311c30165cf2c00445" dependencies = [ - "wgpu-hal", + "wgpu-hal 25.0.2", ] [[package]] @@ -3570,7 +6206,16 @@ version = "25.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cba5fb5f7f9c98baa7c889d444f63ace25574833df56f5b817985f641af58e46" dependencies = [ - "wgpu-hal", + "wgpu-hal 25.0.2", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal 30.0.1", ] [[package]] @@ -3582,7 +6227,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.8.0", "bitflags 2.13.2", "block", "bytemuck", @@ -3592,7 +6237,7 @@ dependencies = [ "glow", "glutin_wgl_sys", "gpu-alloc", - "gpu-allocator", + "gpu-allocator 0.27.0", "gpu-descriptor", "hashbrown 0.15.5", "js-sys", @@ -3601,7 +6246,7 @@ dependencies = [ "libloading", "log", "metal", - "naga", + "naga 25.0.1", "ndk-sys 0.5.0+25.2.9519653", "objc", "ordered-float", @@ -3615,11 +6260,68 @@ dependencies = [ "thiserror 2.0.21", "wasm-bindgen", "web-sys", - "wgpu-types", + "wgpu-types 25.0.0", "windows 0.58.0", "windows-core 0.58.0", ] +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.10.0", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "gpu-allocator 0.28.0", + "hashbrown 0.17.1", + "libc", + "libloading", + "log", + "naga 30.0.1", + "naga-types", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wgpu-naga-bridge", + "wgpu-types 30.0.1", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga 30.0.1", + "wgpu-types 30.0.1", +] + [[package]] name = "wgpu-types" version = "25.0.0" @@ -3634,6 +6336,50 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "serde", + "static_assertions", + "web-sys", +] + +[[package]] +name = "wgsl-parse" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37cee524ba5cfabfd65d10a316b646f8a4c50f3663e7e787471f21b783261f47" +dependencies = [ + "annotate-snippets", + "derive_more", + "itertools 0.15.0", + "lalrpop-util", + "lexical", + "logos", + "thiserror 2.0.21", + "wgsl-types", +] + +[[package]] +name = "wgsl-types" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e119dd12e57d937397535201959ddddb5dbf26505402db5ef305ebb1ca9a7e5f" +dependencies = [ + "half", + "itertools 0.15.0", + "num-traits", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3681,11 +6427,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -3697,6 +6455,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -3723,6 +6490,19 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-future" version = "0.2.1" @@ -3731,7 +6511,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -3800,6 +6591,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -3818,6 +6619,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.1.0" @@ -3837,6 +6647,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3889,6 +6708,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3947,7 +6775,7 @@ dependencies = [ "android-activity", "atomic-waker", "bitflags 2.13.2", - "block2", + "block2 0.5.1", "bytemuck", "calloop 0.13.0", "cfg_aliases", @@ -4013,6 +6841,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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "959c7ac3b8afde864f7a7448ac16f29b2b83350b7eccbf77fb781c3999aa6848" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.6", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.4" @@ -4082,6 +6928,12 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + [[package]] name = "yoke" version = "0.8.4" @@ -4211,6 +7063,12 @@ dependencies = [ "serde", ] +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + [[package]] name = "zerocopy" version = "0.8.63" @@ -4261,6 +7119,7 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -4269,6 +7128,7 @@ version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index e15fc94..b0e443f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/bevy_forge_core", "crates/bevy_forge_editor", "crates/bevy_forge_cli", + "crates/bevy_forge_runtime", ] exclude = ["compat/bsn-smoke"] resolver = "2" diff --git a/README.md b/README.md index 2040633..05980ec 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,47 @@ the output easy to review, diff and check in. Live in-game editing (a Bevy plugin with a remote protocol) is a roadmap item, not part of this editor. +## v2 live editor (M0 spike) + +The `v2-live-editor` branch moves editing into the game as a **Bevy 0.20 plugin** with native Bevy +UI and official `bevy_ui_widgets` — no egui in the runtime crate. M0 ships the `EditorPlugin`, a +fixed editor shell, live hierarchy, reflection inspector, translate gizmo, editor camera and +deterministic screenshots at 1440×900 / 1000×640. + +```bash +cargo run -p bevy_forge_runtime --example editor_spike +``` + +See [docs/m0-spike.md](docs/m0-spike.md) for controls, architecture and M0 limits. The v1 standalone +editor below remains the current released workflow. + +### M1 Core — real scene load/save + undo + +M1 loads a real `.forge.ron` scene through `bevy_forge_core`, saves edits with Ctrl+S, adds +Transform undo/redo (Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y) and the F focus, Delete and Ctrl+D duplicate +hotkeys. + +```bash +FORGE_PROJECT=examples/demo-project \ +FORGE_SCENE=scenes/garden.forge.ron \ +cargo run -p bevy_forge_runtime --example editor_live +``` + +See [docs/m1-core.md](docs/m1-core.md) for the garden demo, hotkeys and M1 limits. + +### M2 Live — play mode, assets, export, hot reload + +M2 adds Play/Pause/Stop with transform snapshot/restore, a project asset browser, BSN/Rust export +and a `.forge.ron` transform watcher. + +```bash +FORGE_PROJECT=examples/demo-project \ +FORGE_SCENE=scenes/garden.forge.ron \ +cargo run -p bevy_forge_runtime --example editor_live +``` + +See [docs/m2-live.md](docs/m2-live.md) for controls and M2 limits. + ## Two viewports | 2D scene editing | 3D wireframe preview | diff --git a/crates/bevy_forge_runtime/Cargo.toml b/crates/bevy_forge_runtime/Cargo.toml new file mode 100644 index 0000000..4da21e8 --- /dev/null +++ b/crates/bevy_forge_runtime/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "bevy_forge_runtime" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +publish = false +description = "Live in-game scene editor plugin for Bevy 0.20 (M0/M1)" + +[dependencies] +bevy_forge_core = { path = "../bevy_forge_core" } +# Frozen M0 feature set. Do not add `tonemapping_luts` (ktx2/zstd) — cameras use +# `Tonemapping::AcesFitted` instead of the LUT-based default. +bevy = { version = "0.20", default-features = false, features = [ + "std", + "async_executor", + "multi_threaded", + "bevy_asset", + "bevy_log", + "bevy_color", + "bevy_image", + "bevy_camera", + "bevy_light", + "bevy_mesh", + "bevy_pbr", + "bevy_render", + "bevy_core_pipeline", + "bevy_ui", + "bevy_ui_render", + "bevy_ui_widgets", + "bevy_text", + "bevy_input_focus", + "bevy_window", + "bevy_winit", + "bevy_picking", + "ui_picking", + "bevy_gizmos", + "bevy_gizmos_render", + "default_font", + "png", + "x11", + "wayland", + "reflect_auto_register", +] } diff --git a/crates/bevy_forge_runtime/examples/editor_live.rs b/crates/bevy_forge_runtime/examples/editor_live.rs new file mode 100644 index 0000000..0c14e36 --- /dev/null +++ b/crates/bevy_forge_runtime/examples/editor_live.rs @@ -0,0 +1,41 @@ +//! M1 live editor: the editor plugin on top of `DefaultPlugins`, loading a real +//! `.forge.ron` scene selected through the environment. +//! +//! Run the garden demo from the repository root: +//! +//! ```text +//! FORGE_PROJECT=examples/demo-project \ +//! FORGE_SCENE=scenes/garden.forge.ron \ +//! cargo run -p bevy_forge_runtime --example editor_live +//! ``` +//! +//! `FORGE_SCENE=""` disables scene loading. With no scene (or a load failure) +//! the editor still starts with its placeholder camera and empty hierarchy. + +use std::path::PathBuf; + +use bevy::{prelude::*, window::WindowResolution}; +use bevy_forge_runtime::EditorPlugin; + +fn main() { + // Let AssetServer resolve project-relative paths (`textures/coin.png`) from the + // demo project's assets directory, not from the runtime crate's own assets. + let project = + std::env::var("FORGE_PROJECT").unwrap_or_else(|_| "examples/demo-project".to_string()); + let project = std::fs::canonicalize(&project).unwrap_or_else(|_| PathBuf::from(&project)); + if project.join("assets").is_dir() { + std::env::set_var("BEVY_ASSET_ROOT", &project); + } + + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + title: "Bevy Forge — M2 Live".into(), + resolution: WindowResolution::new(1440, 900).with_scale_factor_override(1.0), + ..default() + }), + ..default() + })) + .add_plugins(EditorPlugin) + .run(); +} diff --git a/crates/bevy_forge_runtime/examples/editor_spike.rs b/crates/bevy_forge_runtime/examples/editor_spike.rs new file mode 100644 index 0000000..1626a2d --- /dev/null +++ b/crates/bevy_forge_runtime/examples/editor_spike.rs @@ -0,0 +1,155 @@ +//! M0 spike demo: a small hardcoded 3D scene plus the Bevy Forge editor plugin. +//! +//! Run: +//! cargo run -p bevy_forge_runtime --example editor_spike +//! +//! Deterministic screenshot: +//! FORGE_SCREENSHOT=$PWD/.swarm/m0/evidence/m0-1440x900.png \ +//! FORGE_SCREENSHOT_SIZE=1440x900 \ +//! cargo run -p bevy_forge_runtime --example editor_spike + +use bevy::{prelude::*, render::view::Tonemapping, window::WindowResolution}; +use bevy_forge_runtime::{EditorPlugin, Selection}; + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + title: "Bevy Forge — M0 Spike".into(), + resolution: WindowResolution::new(1440, 900).with_scale_factor_override(1.0), + ..default() + }), + ..default() + })) + .add_plugins(EditorPlugin) + .add_systems(Startup, demo_scene) + .run(); +} + +fn demo_scene( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, + mut selection: ResMut, +) { + commands.insert_resource(GlobalAmbientLight { + color: Color::WHITE, + brightness: 600.0, + ..default() + }); + + let ground = materials.add(StandardMaterial { + base_color: Color::srgb(0.16, 0.20, 0.17), + perceptual_roughness: 0.95, + metallic: 0.0, + ..default() + }); + commands.spawn(( + Name::new("Ground"), + Mesh3d(meshes.add(Plane3d::default().mesh().size(14.0, 14.0))), + MeshMaterial3d(ground), + Transform::from_xyz(0.0, -0.02, 0.0), + )); + + let hero_mat = materials.add(StandardMaterial { + base_color: Color::srgb(0.85, 0.55, 0.25), + perceptual_roughness: 0.65, + metallic: 0.0, + ..default() + }); + let hat_mat = materials.add(StandardMaterial { + base_color: Color::srgb(0.25, 0.45, 0.75), + perceptual_roughness: 0.5, + metallic: 0.0, + ..default() + }); + let hero = commands + .spawn(( + Name::new("Hero Cube"), + Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))), + MeshMaterial3d(hero_mat), + Transform::from_xyz(0.0, 0.5, 0.0), + )) + .with_children(|parent| { + parent.spawn(( + Name::new("Hat"), + Mesh3d(meshes.add(Cuboid::new(0.55, 0.18, 0.55))), + MeshMaterial3d(hat_mat), + Transform::from_xyz(0.0, 0.62, 0.0), + )); + }) + .id(); + + let red_mat = materials.add(StandardMaterial { + base_color: Color::srgb(0.75, 0.18, 0.16), + perceptual_roughness: 0.7, + ..default() + }); + let blue_mat = materials.add(StandardMaterial { + base_color: Color::srgb(0.18, 0.35, 0.78), + perceptual_roughness: 0.7, + ..default() + }); + commands.spawn(( + Name::new("Red Cube"), + Mesh3d(meshes.add(Cuboid::new(0.8, 0.8, 0.8))), + MeshMaterial3d(red_mat), + Transform::from_xyz(-2.6, 0.4, 0.6), + )); + commands.spawn(( + Name::new("Blue Cube"), + Mesh3d(meshes.add(Cuboid::new(0.8, 0.8, 0.8))), + MeshMaterial3d(blue_mat), + Transform::from_xyz(2.6, 0.4, -0.6), + )); + + let crate_mat = materials.add(StandardMaterial { + base_color: Color::srgb(0.55, 0.38, 0.20), + perceptual_roughness: 0.9, + ..default() + }); + let stack = commands + .spawn(( + Name::new("Crate Stack"), + Transform::from_xyz(0.0, 0.0, -2.8), + Visibility::default(), + )) + .with_children(|parent| { + parent.spawn(( + Name::new("Crate A"), + Mesh3d(meshes.add(Cuboid::new(0.8, 0.8, 0.8))), + MeshMaterial3d(crate_mat.clone()), + Transform::from_xyz(0.0, 0.4, 0.0), + )); + parent.spawn(( + Name::new("Crate B"), + Mesh3d(meshes.add(Cuboid::new(0.8, 0.8, 0.8))), + MeshMaterial3d(crate_mat), + Transform::from_xyz(0.0, 1.2, 0.0), + )); + }) + .id(); + + commands.spawn(( + Name::new("Sun"), + DirectionalLight { + illuminance: 12_000.0, + ..default() + }, + Transform::from_xyz(4.0, 8.0, 4.0).looking_at(Vec3::ZERO, Vec3::Y), + )); + + // Placeholder camera. T04 adopts it for the editor orbit/pan/zoom controls. + commands.spawn(( + Name::new("Editor Camera"), + Camera3d::default(), + Tonemapping::AcesFitted, + Transform::from_xyz(6.0, 5.0, 10.0).looking_at(Vec3::new(0.0, 0.5, 0.0), Vec3::Y), + )); + + selection.0 = Some(hero); + info!( + "demo scene ready: hero={hero:?}, stack={stack:?}, selected={:?}", + selection.0 + ); +} diff --git a/crates/bevy_forge_runtime/src/assets.rs b/crates/bevy_forge_runtime/src/assets.rs new file mode 100644 index 0000000..d05e6cd --- /dev/null +++ b/crates/bevy_forge_runtime/src/assets.rs @@ -0,0 +1,1274 @@ +//! Toggleable project asset browser (T22). +//! +//! The browser lists [`AssetIndex`] entries scanned from the loaded project's +//! `assets/` directory, filters them by [`AssetFilter`] and previews the selected +//! image. Drag-into-scene is deliberately deferred to M3: the core +//! `bevy_forge_core::MaterialData` has no texture field yet, so the panel only +//! lists, filters and previews. +//! +//! The panel is created here (not in the fixed UI shell) as a child of the +//! editor root, aligned with the viewport: below the toolbar, inside the left +//! hierarchy / right inspector panels, above the status bar. +//! +//! ## Preview loading +//! +//! Images are first requested through [`AssetServer`] whenever the file lives +//! below Bevy's configured asset root. Project assets commonly live outside that +//! root (the `editor_live` example runs with `FORGE_PROJECT=examples/demo-project` +//! while Bevy's default root is `crates/bevy_forge_runtime/assets`); such files +//! are decoded from disk into `Assets` so the preview still works without +//! changing the example or the asset server configuration. + +use std::path::{Component, Path, PathBuf}; + +use bevy::{ + asset::{LoadState, RenderAssetUsages}, + ecs::query::QueryFilter, + image::{CompressedImageFormats, ImageSampler, ImageType}, + picking::hover::Hovered, + prelude::*, + text::FontSize, + ui::{ + widget::{ImageNode, NodeImageMode}, + ScrollPosition, Selected, + }, + ui_widgets::{Activate, Button as UiButton, ListBox, ListItem, ScrollArea, ValueChange}, +}; +use bevy_forge_core::{AssetEntry, AssetIndex, AssetKind}; + +use crate::{ + plugin::editor_active, + scene::SceneDocument, + ui::{EditorLayout, EditorRoot, UiTheme}, +}; + +/// Height of the asset browser panel in logical pixels. +pub const ASSET_PANEL_HEIGHT: f32 = 190.0; +/// Maximum width of the image preview in logical pixels. +pub const MAX_PREVIEW_WIDTH: f32 = 200.0; +/// Maximum height of the image preview in logical pixels. +pub const MAX_PREVIEW_HEIGHT: f32 = 140.0; +/// Width actually used by the preview image. Kept below the maximum so the path, +/// kind and status labels fit into the ~190px panel. +const PREVIEW_IMAGE_WIDTH: f32 = 200.0; +/// Height actually used by the preview image. +const PREVIEW_IMAGE_HEIGHT: f32 = 88.0; +/// Width of the preview column. +const PREVIEW_COLUMN_WIDTH: f32 = 216.0; + +/// Toggleable asset browser state. +/// +/// `selected` is an index into [`AssetIndex::entries`], not into the filtered +/// view, so a selection keeps its meaning when the kind filter changes. A +/// selection that stops matching the active filter is cleared by +/// [`AssetBrowserState::normalize_selection`]. +#[derive(Resource, Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct AssetBrowserState { + /// Whether the bottom panel is visible. + pub open: bool, + /// Kind filter applied to the asset list. + pub filter: AssetFilter, + /// Selected asset index into the full, unfiltered index. + pub selected: Option, +} + +impl AssetBrowserState { + /// Flips the panel open state. + pub fn toggle(&mut self) { + self.open = !self.open; + } + + /// The selected entry, if the index still contains it and it passes the filter. + pub fn selected_entry<'a>(&self, index: &'a AssetIndex) -> Option<&'a AssetEntry> { + let selected = self.selected?; + let entry = index.entries.get(selected)?; + if self.filter.accepts(entry.kind) { + Some(entry) + } else { + None + } + } + + /// Clears the selection when it points outside the index or no longer + /// matches the active filter. Returns `true` when the state changed. + pub fn normalize_selection(&mut self, index: &AssetIndex) -> bool { + if self.selected_entry(index).is_some() || self.selected.is_none() { + return false; + } + self.selected = None; + true + } +} + +/// Kind filter for the asset list. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum AssetFilter { + /// Show every asset kind. + #[default] + All, + /// Show images only. + Images, + /// Show meshes only. + Meshes, + /// Show audio only. + Audio, +} + +impl AssetFilter { + /// All filters in display order. + pub const ALL: [Self; 4] = [Self::All, Self::Images, Self::Meshes, Self::Audio]; + + /// Button / header label. + pub const fn label(self) -> &'static str { + match self { + Self::All => "All", + Self::Images => "Images", + Self::Meshes => "Meshes", + Self::Audio => "Audio", + } + } + + /// Whether an asset of `kind` is shown by this filter. + pub const fn accepts(self, kind: AssetKind) -> bool { + match self { + Self::All => true, + Self::Images => matches!(kind, AssetKind::Image), + Self::Meshes => matches!(kind, AssetKind::Mesh), + Self::Audio => matches!(kind, AssetKind::Audio), + } + } +} + +/// Indices into [`AssetIndex::entries`] accepted by `filter`, in index order. +pub fn filtered_indices(index: &AssetIndex, filter: AssetFilter) -> Vec { + index + .entries + .iter() + .enumerate() + .filter_map(|(position, entry)| filter.accepts(entry.kind).then_some(position)) + .collect() +} + +/// How a selected image asset can be turned into a preview handle. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PreviewSource { + /// Path relative to Bevy's configured asset root; load through [`AssetServer`]. + AssetServer(String), + /// Absolute project file outside the Bevy asset root; decode into `Assets`. + ProjectFile(PathBuf), + /// Not an image (or no preview possible). + None, +} + +/// Pure preview plan for one asset entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreviewPlan { + /// Asset path relative to the project assets directory. + pub path: String, + /// Detected asset kind. + pub kind: AssetKind, + /// Preview loading strategy. + pub source: PreviewSource, +} + +/// Plans how `entry` is previewed. +/// +/// Images below `asset_root` use [`AssetServer`] with a path relative to that +/// root. Everything else falls back to reading the project file directly, +/// because Bevy's asset root is independent of the Forge project root. +pub fn plan_preview(entry: &AssetEntry, asset_root: Option<&Path>) -> PreviewPlan { + let source = if entry.kind != AssetKind::Image { + PreviewSource::None + } else if let Some(relative) = asset_root + .and_then(|root| entry.abs_path.strip_prefix(root).ok()) + .and_then(relative_asset_path) + { + PreviewSource::AssetServer(relative) + } else { + PreviewSource::ProjectFile(entry.abs_path.clone()) + }; + PreviewPlan { + path: entry.path.clone(), + kind: entry.kind, + source, + } +} + +/// Converts a relative filesystem path to the `/`-separated asset path syntax. +fn relative_asset_path(path: &Path) -> Option { + let mut parts = Vec::new(); + for component in path.components() { + match component { + Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()), + Component::CurDir => {} + _ => return None, + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("/")) + } +} + +/// Bevy's default file asset root (`/assets`), used to decide whether an +/// asset can be requested through [`AssetServer`]. +/// +/// Mirrors Bevy's `FileAssetReader` base path resolution (which is not public in +/// 0.20): `BEVY_ASSET_ROOT`, then `CARGO_MANIFEST_DIR`, then the executable +/// directory. The default `AssetPlugin` file path is `assets`. +fn default_asset_root() -> PathBuf { + let base = std::env::var_os("BEVY_ASSET_ROOT") + .map(PathBuf::from) + .or_else(|| std::env::var_os("CARGO_MANIFEST_DIR").map(PathBuf::from)) + .or_else(|| { + std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(Path::to_path_buf)) + }) + .unwrap_or_default(); + base.join("assets") +} + +/// Decodes an image file from disk into an [`Image`]. +/// +/// Only formats enabled in the Bevy build (here PNG) can be decoded; a failing +/// decode is reported to the caller and logged once by the preview system. +fn decode_image_file(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|error| format!("cannot read: {error}"))?; + let extension = path + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if extension.is_empty() { + return Err("missing file extension".to_string()); + } + Image::from_buffer( + &bytes, + ImageType::Extension(&extension), + CompressedImageFormats::NONE, + true, + ImageSampler::Default, + RenderAssetUsages::default(), + ) + .map_err(|error| format!("cannot decode: {error}")) +} + +/// Installs the project asset browser. +pub struct AssetBrowserPlugin; + +impl Plugin for AssetBrowserPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .init_resource::() + .add_systems(PostStartup, spawn_asset_browser) + .add_systems(Update, asset_browser_system.run_if(editor_active)) + .add_systems(Update, style_asset_rows.run_if(editor_active)); + } +} + +// --------------------------------------------------------------------------- +// Internal state +// --------------------------------------------------------------------------- + +/// Scanned asset index plus the view state needed to rebuild the panel. +#[derive(Resource, Debug, Default)] +struct AssetBrowserData { + index: AssetIndex, + /// Incremented on every scan; part of the rebuild signature. + generation: u64, + /// Directory the current index was scanned from. + scanned_dir: Option, + /// Last scan error, if the assets directory could not be read. + scan_error: Option, + /// Signature of the last rendered view. + last_signature: Option, +} + +/// Everything that requires a list/preview rebuild when it changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BrowserSignature { + filter: AssetFilter, + selected: Option, + generation: u64, +} + +/// Preview loading state and the handle currently shown in the panel. +#[derive(Resource, Debug, Default)] +struct AssetPreview { + key: Option, + handle: Option>, + status: PreviewStatus, +} + +/// Identity of the loaded preview. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PreviewKey { + AssetServer(String), + ProjectFile(PathBuf), +} + +/// Status shown below the preview image. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +enum PreviewStatus { + #[default] + Empty, + NoPreview, + Loading, + Ready, + Failed(String), +} + +// --------------------------------------------------------------------------- +// UI markers +// --------------------------------------------------------------------------- + +/// Root node of the bottom asset browser panel. +#[derive(Component)] +pub struct AssetBrowserPanel; + +/// Scrollable asset list. +#[derive(Component)] +struct AssetList; + +/// One row in the asset list, pointing at an [`AssetIndex::entries`] index. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +struct AssetListRow { + entry: usize, +} + +/// One of the All/Images/Meshes/Audio filter buttons. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +struct AssetFilterButton(AssetFilter); + +/// Header text that reports the indexed / shown asset counts. +#[derive(Component)] +struct AssetCountText; + +/// Preview column root. Its children are rebuilt on selection changes. +#[derive(Component)] +struct AssetPreviewRoot; + +/// Preview status line (`preview`, `loading preview...`, ...). +#[derive(Component)] +struct AssetPreviewStatusText; + +// --------------------------------------------------------------------------- +// Startup: build the panel +// --------------------------------------------------------------------------- + +/// Builds the asset browser panel as a child of the editor root. +/// +/// Runs at `Startup` after the fixed UI shell, so [`EditorRoot`] already exists. +/// The panel starts hidden and is revealed by `B` / [`AssetBrowserState::open`]. +fn spawn_asset_browser( + mut commands: Commands, + theme: Res, + layout: Res, + roots: Query>, +) { + let Ok(root) = roots.single() else { + debug!("assets: editor root missing, asset browser not created"); + return; + }; + + let panel = commands + .spawn(( + AssetBrowserPanel, + Node { + position_type: PositionType::Absolute, + left: px(layout.left), + right: px(layout.right), + bottom: px(layout.bottom), + height: px(ASSET_PANEL_HEIGHT), + display: Display::None, + flex_direction: FlexDirection::Column, + row_gap: px(4), + padding: UiRect::all(px(6)), + border: UiRect::top(px(1)), + ..default() + }, + BackgroundColor(theme.panel_bg), + BorderColor::all(theme.border), + )) + .id(); + commands.entity(root).add_child(panel); + + commands.entity(panel).with_children(|panel| { + // Header: title, counts, deferred-drag hint. + panel + .spawn(Node { + width: percent(100), + height: px(22), + flex_shrink: 0.0, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: px(8), + ..default() + }) + .with_children(|header| { + header.spawn(asset_label("Assets", 14.0, theme.accent)); + header.spawn(( + AssetCountText, + asset_label("No project assets", 11.0, theme.text_muted), + )); + header.spawn(Node { + flex_grow: 1.0, + ..default() + }); + header.spawn(asset_label("drag: M3", 10.0, theme.text_muted)); + header.spawn(asset_label("B toggle", 10.0, theme.text_muted)); + }); + + // Filter buttons: All / Images / Meshes / Audio. + panel + .spawn(Node { + width: percent(100), + height: px(22), + flex_shrink: 0.0, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: px(4), + ..default() + }) + .with_children(|filters| { + for filter in AssetFilter::ALL { + filters + .spawn(( + UiButton, + AssetFilterButton(filter), + Node { + height: px(20), + padding: UiRect::horizontal(px(8)), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + border: UiRect::all(px(1)), + ..default() + }, + BackgroundColor(theme.panel_bg_alt), + BorderColor::all(theme.border), + )) + .observe(filter_button_activated) + .with_children(|button| { + button.spawn(asset_label(filter.label(), 11.0, theme.text_muted)); + }); + } + }); + + // Body: scrollable list on the left, preview on the right. + panel + .spawn(Node { + width: percent(100), + flex_grow: 1.0, + min_height: px(0), + flex_direction: FlexDirection::Row, + column_gap: px(8), + ..default() + }) + .with_children(|body| { + body.spawn(( + AssetList, + ListBox, + ScrollArea, + ScrollPosition::default(), + Node { + flex_grow: 1.0, + height: percent(100), + min_height: px(0), + flex_direction: FlexDirection::Column, + overflow: Overflow::scroll_y(), + ..default() + }, + BackgroundColor(theme.panel_bg_alt), + )) + .observe(asset_row_activated); + + body.spawn(( + AssetPreviewRoot, + Node { + width: px(PREVIEW_COLUMN_WIDTH), + height: percent(100), + min_height: px(0), + flex_shrink: 0.0, + flex_direction: FlexDirection::Column, + align_items: AlignItems::FlexStart, + row_gap: px(2), + padding: UiRect::all(px(4)), + ..default() + }, + BackgroundColor(theme.panel_bg_alt), + )) + .with_children(|preview| { + preview.spawn(asset_label( + "Select an image to preview", + 11.0, + theme.text_muted, + )); + }); + }); + }); + + info!("assets: browser panel ready (B toggles)"); +} + +/// Creates a plain editor label bundle. +fn asset_label(value: impl Into, size: f32, color: Color) -> impl Bundle { + ( + Text::new(value.into()), + TextFont { + font_size: FontSize::Px(size), + ..default() + }, + TextColor(color), + ) +} + +// --------------------------------------------------------------------------- +// Input / state +// --------------------------------------------------------------------------- + +/// Observer on the list: a committed row activation selects its asset index. +fn asset_row_activated( + change: On>, + rows: Query<&AssetListRow>, + mut state: ResMut, +) { + let row_entity = change.value; + let Ok(row) = rows.get(row_entity) else { + return; + }; + state.selected = Some(row.entry); +} + +/// Observer on the filter buttons. +fn filter_button_activated( + activate: On, + buttons: Query<&AssetFilterButton>, + mut state: ResMut, +) { + let Ok(button) = buttons.get(activate.entity) else { + return; + }; + state.filter = button.0; +} + +// --------------------------------------------------------------------------- +// Update: scan, rebuild, preview +// --------------------------------------------------------------------------- + +/// Main browser system: handles `B`, scans the project assets, keeps the +/// selection/filter valid and rebuilds the list/preview when the view changes. +/// +/// Exclusive because the panel contents are despawned and rebuilt in one tick; +/// this avoids a frame where the list and the selected-row markers disagree. +fn asset_browser_system(world: &mut World) { + let toggle_requested = world + .get_resource::>() + .is_some_and(|keys| keys.just_pressed(KeyCode::KeyB)); + let mut state = *world.resource::(); + if toggle_requested { + state.toggle(); + debug!( + "assets: browser {}", + if state.open { "opened" } else { "closed" } + ); + } + + let Some(panel) = find_ui_entity::>(world) else { + write_state(world, state); + return; + }; + set_panel_display(world, panel, state.open); + + if !state.open { + world.resource_mut::().last_signature = None; + write_state(world, state); + return; + } + + let mut data = world + .remove_resource::() + .unwrap_or_default(); + let scene_assets_dir = world + .get_resource::() + .map(|document| document.project.assets_dir.clone()); + + if let Some(dir) = scene_assets_dir { + if data.scanned_dir.as_ref() != Some(&dir) { + let index = AssetIndex::scan(&dir); + match &index.error { + Some(error) => warn!("assets: {error}"), + None => info!( + "assets: indexed {} files under {} ({} images)", + index.len(), + dir.display(), + index.by_kind(AssetKind::Image).len() + ), + } + data.index = index; + data.scanned_dir = Some(dir); + data.scan_error = data.index.error.clone(); + data.generation = data.generation.wrapping_add(1); + } + } else if data.scanned_dir.is_none() { + data.scan_error = Some("no scene loaded".to_string()); + } + + state.normalize_selection(&data.index); + let filtered = filtered_indices(&data.index, state.filter); + + let theme = world.resource::().clone(); + let asset_root = default_asset_root(); + let plan = state + .selected_entry(&data.index) + .map(|entry| plan_preview(entry, Some(&asset_root))); + + let mut preview = world.remove_resource::().unwrap_or_default(); + apply_preview_plan(world, &mut preview, plan.as_ref()); + refresh_preview_load_state(world, &mut preview); + let status = preview_status_text(&preview); + + let signature = BrowserSignature { + filter: state.filter, + selected: state.selected, + generation: data.generation, + }; + if data.last_signature != Some(signature) { + update_count_text(world, &data, filtered.len()); + style_filter_buttons(world, state.filter, &theme); + rebuild_list(world, &data.index, &filtered, state.selected, &theme); + rebuild_preview( + world, + plan.as_ref(), + preview.handle.clone(), + &status, + &theme, + ); + data.last_signature = Some(signature); + } else { + update_count_text(world, &data, filtered.len()); + update_preview_status_text(world, &status); + } + + world.insert_resource(data); + world.insert_resource(preview); + write_state(world, state); +} + +/// Writes the browser state back only when it changed. +fn write_state(world: &mut World, state: AssetBrowserState) { + if world.resource::() != &state { + *world.resource_mut::() = state; + } +} + +/// Finds one UI entity matching `filter`. +fn find_ui_entity(world: &mut World) -> Option { + let mut query = world.query_filtered::(); + query.iter(world).next() +} + +/// Shows or hides the panel node. +fn set_panel_display(world: &mut World, panel: Entity, open: bool) { + let display = if open { Display::Flex } else { Display::None }; + if let Some(mut node) = world.get_mut::(panel) { + if node.display != display { + node.display = display; + } + } +} + +/// Replaces the text of the single entity marked with `F`, if it exists. +fn set_marked_text(world: &mut World, value: &str) { + let Some(entity) = find_ui_entity::(world) else { + return; + }; + if let Some(mut text) = world.get_mut::(entity) { + if text.0 != value { + text.0 = value.to_string(); + } + } +} + +/// Updates the header count / scan error line. +fn update_count_text(world: &mut World, data: &AssetBrowserData, shown: usize) { + let value = if data.scanned_dir.is_none() { + "No project assets".to_string() + } else if data.scan_error.is_some() { + "assets directory unreadable".to_string() + } else { + format!("{} assets · {} shown", data.index.len(), shown) + }; + set_marked_text::>(world, &value); +} + +/// Applies active/inactive styling to the four filter buttons. +fn style_filter_buttons(world: &mut World, filter: AssetFilter, theme: &UiTheme) { + let buttons: Vec<(Entity, AssetFilter)> = { + let mut query = world.query::<(Entity, &AssetFilterButton)>(); + query + .iter(world) + .map(|(entity, button)| (entity, button.0)) + .collect() + }; + for (entity, button_filter) in buttons { + let active = button_filter == filter; + if let Some(mut background) = world.get_mut::(entity) { + let color = if active { + theme.row_selected + } else { + theme.panel_bg_alt + }; + if background.0 != color { + background.0 = color; + } + } + let color = if active { theme.text } else { theme.text_muted }; + let children: Vec = world + .get::(entity) + .map(|children| children.to_vec()) + .unwrap_or_default(); + for child in children { + if let Some(mut text_color) = world.get_mut::(child) { + if text_color.0 != color { + text_color.0 = color; + } + } + } + } +} + +/// Rebuilds the scrollable asset rows from the filtered index. +fn rebuild_list( + world: &mut World, + index: &AssetIndex, + filtered: &[usize], + selected: Option, + theme: &UiTheme, +) { + let Some(list) = find_ui_entity::>(world) else { + return; + }; + world.entity_mut(list).despawn_children(); + + for &entry_index in filtered { + let Some(entry) = index.entries.get(entry_index) else { + continue; + }; + let is_selected = selected == Some(entry_index); + let row = world + .spawn(( + ListItem, + AssetListRow { entry: entry_index }, + Node { + width: percent(100), + height: px(20), + flex_shrink: 0.0, + padding: UiRect::left(px(6)), + align_items: AlignItems::Center, + overflow: Overflow::clip(), + ..default() + }, + BackgroundColor(if is_selected { + theme.row_selected + } else { + Color::NONE + }), + Hovered::default(), + )) + .id(); + if is_selected { + world.entity_mut(row).insert(Selected); + } + let label = world + .spawn(asset_label( + format!("{} · {}", entry.path, entry.kind.label()), + 11.0, + if is_selected { + theme.text + } else { + theme.text_muted + }, + )) + .id(); + world.entity_mut(row).add_child(label); + world.entity_mut(list).add_child(row); + } +} + +/// Rebuilds the preview column: path label, image (or `no preview`), status. +fn rebuild_preview( + world: &mut World, + plan: Option<&PreviewPlan>, + handle: Option>, + status: &str, + theme: &UiTheme, +) { + let Some(root) = find_ui_entity::>(world) else { + return; + }; + world.entity_mut(root).despawn_children(); + + let Some(plan) = plan else { + let placeholder = world + .spawn(( + AssetPreviewStatusText, + asset_label("Select an image to preview", 11.0, theme.text_muted), + )) + .id(); + world.entity_mut(root).add_child(placeholder); + return; + }; + + let path = world + .spawn(asset_label(plan.path.clone(), 9.0, theme.text_muted)) + .id(); + world.entity_mut(root).add_child(path); + + if let Some(handle) = handle { + let image = world + .spawn(( + ImageNode::new(handle).with_mode(NodeImageMode::Stretch), + Node { + width: px(PREVIEW_IMAGE_WIDTH), + height: px(PREVIEW_IMAGE_HEIGHT), + flex_shrink: 0.0, + ..default() + }, + )) + .id(); + world.entity_mut(root).add_child(image); + } else { + let placeholder = world + .spawn(asset_label("no preview", 11.0, theme.text_muted)) + .id(); + world.entity_mut(root).add_child(placeholder); + } + + let status_line = world + .spawn(( + AssetPreviewStatusText, + asset_label( + format!("{} · {}", plan.kind.label(), status), + 10.0, + theme.text_muted, + ), + )) + .id(); + world.entity_mut(root).add_child(status_line); +} + +/// Chooses / loads the preview image for `plan` when the selection changed. +fn apply_preview_plan(world: &mut World, preview: &mut AssetPreview, plan: Option<&PreviewPlan>) { + let key = plan.and_then(|plan| match &plan.source { + PreviewSource::AssetServer(path) => Some(PreviewKey::AssetServer(path.clone())), + PreviewSource::ProjectFile(path) => Some(PreviewKey::ProjectFile(path.clone())), + PreviewSource::None => None, + }); + if preview.key == key { + return; + } + preview.key = key; + preview.handle = None; + preview.status = match plan.map(|plan| &plan.source) { + None | Some(PreviewSource::None) => PreviewStatus::NoPreview, + Some(PreviewSource::AssetServer(path)) => match world.get_resource::() { + Some(asset_server) => { + preview.handle = Some(asset_server.load::(path.clone())); + PreviewStatus::Loading + } + None => PreviewStatus::Failed("asset server unavailable".to_string()), + }, + Some(PreviewSource::ProjectFile(path)) => { + match ( + decode_image_file(path), + world.get_resource_mut::>(), + ) { + (Ok(image), Some(mut images)) => { + preview.handle = Some(images.add(image)); + PreviewStatus::Ready + } + (Ok(_), None) => PreviewStatus::Failed("image assets unavailable".to_string()), + (Err(error), _) => { + warn!( + "assets: cannot preview '{}': {error}", + plan.map(|plan| plan.path.as_str()).unwrap_or_default() + ); + PreviewStatus::Failed(error) + } + } + } + }; +} + +/// Promotes a loading AssetServer preview to loaded/failed. +fn refresh_preview_load_state(world: &mut World, preview: &mut AssetPreview) { + if preview.status != PreviewStatus::Loading { + return; + } + let Some(handle) = preview.handle.as_ref().map(Handle::id) else { + return; + }; + let Some(asset_server) = world.get_resource::() else { + return; + }; + match asset_server.load_state(handle) { + LoadState::Loaded => preview.status = PreviewStatus::Ready, + LoadState::Failed(error) => { + let message = error.to_string(); + warn!("assets: preview load failed: {message}"); + preview.status = PreviewStatus::Failed(message); + } + _ => {} + } +} + +/// Human-readable status line for the preview footer. +fn preview_status_text(preview: &AssetPreview) -> String { + match &preview.status { + PreviewStatus::Empty | PreviewStatus::NoPreview => "no preview".to_string(), + PreviewStatus::Loading => "loading preview...".to_string(), + PreviewStatus::Ready => "preview".to_string(), + PreviewStatus::Failed(error) => format!("preview failed: {error}"), + } +} + +/// Updates only the preview footer when the load state changes. +fn update_preview_status_text(world: &mut World, status: &str) { + set_marked_text::>(world, status); +} + +/// Hover / selected styling for list rows. +fn style_asset_rows( + theme: Res, + state: Res, + mut rows: Query<( + &AssetListRow, + &mut BackgroundColor, + Has, + &Hovered, + &Children, + )>, + mut texts: Query<&mut TextColor>, +) { + if !state.open { + return; + } + for (row, mut background, selected, hovered, children) in &mut rows { + let is_selected = selected || state.selected == Some(row.entry); + let is_hovered = hovered.get() && !is_selected; + let color = if is_selected { + theme.row_selected + } else if is_hovered { + theme.row_hover + } else { + Color::NONE + }; + if background.0 != color { + background.0 = color; + } + let text_color = if is_selected || is_hovered { + theme.text + } else { + theme.text_muted + }; + for child in children.iter() { + if let Ok(mut text) = texts.get_mut(child) { + if text.0 != text_color { + text.0 = text_color; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::sync::atomic::{AtomicU64, Ordering}; + + use bevy_forge_core::Project; + + use super::*; + + const DEMO_PROJECT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../examples/demo-project"); + + /// Creates a unique temporary project assets directory and removes it on drop. + struct TempAssets { + root: PathBuf, + } + + impl TempAssets { + fn new(tag: &str) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let root = std::env::temp_dir().join(format!( + "bevy-forge-t22-{}-{tag}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(root.join("assets")).expect("create temp assets dir"); + let root = fs::canonicalize(root).expect("canonicalize temp dir"); + Self { root } + } + + fn assets(&self) -> PathBuf { + self.root.join("assets") + } + + fn write(&self, relative: &str, bytes: &[u8]) -> PathBuf { + let path = self.assets().join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent dir"); + } + fs::write(&path, bytes).expect("write temp asset"); + path + } + } + + impl Drop for TempAssets { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + fn entry(path: &str, kind: AssetKind) -> AssetEntry { + AssetEntry { + path: path.to_string(), + abs_path: PathBuf::from("/tmp/forge-assets").join(path), + kind, + size_bytes: 1, + } + } + + fn index(paths: &[(&str, AssetKind)]) -> AssetIndex { + AssetIndex { + entries: paths + .iter() + .map(|(path, kind)| entry(path, *kind)) + .collect(), + scanned_dir: PathBuf::from("/tmp/forge-assets"), + error: None, + } + } + + fn selected_paths<'a>(index: &'a AssetIndex, indices: &[usize]) -> Vec<&'a str> { + indices + .iter() + .map(|&position| index.entries[position].path.as_str()) + .collect() + } + + #[test] + fn filter_model_covers_all_images_meshes_and_audio() { + assert_eq!(AssetFilter::default(), AssetFilter::All); + assert_eq!(AssetFilter::ALL.len(), 4); + assert_eq!(AssetFilter::All.label(), "All"); + + for kind in AssetKind::all() { + assert!(AssetFilter::All.accepts(kind)); + } + assert!(AssetFilter::Images.accepts(AssetKind::Image)); + assert!(!AssetFilter::Images.accepts(AssetKind::Mesh)); + assert!(AssetFilter::Meshes.accepts(AssetKind::Mesh)); + assert!(!AssetFilter::Meshes.accepts(AssetKind::Audio)); + assert!(AssetFilter::Audio.accepts(AssetKind::Audio)); + assert!(!AssetFilter::Audio.accepts(AssetKind::Image)); + } + + #[test] + fn filtered_indices_keep_index_order_and_skip_other_kinds() { + let index = index(&[ + ("textures/coin.png", AssetKind::Image), + ("models/robot.glb", AssetKind::Mesh), + ("audio/loop.ogg", AssetKind::Audio), + ("notes.txt", AssetKind::Other), + ("textures/background.png", AssetKind::Image), + ]); + + assert_eq!( + selected_paths(&index, &filtered_indices(&index, AssetFilter::All)), + vec![ + "textures/coin.png", + "models/robot.glb", + "audio/loop.ogg", + "notes.txt", + "textures/background.png", + ] + ); + assert_eq!( + selected_paths(&index, &filtered_indices(&index, AssetFilter::Images)), + vec!["textures/coin.png", "textures/background.png",] + ); + assert_eq!( + selected_paths(&index, &filtered_indices(&index, AssetFilter::Meshes)), + vec!["models/robot.glb",] + ); + assert_eq!( + selected_paths(&index, &filtered_indices(&index, AssetFilter::Audio)), + vec!["audio/loop.ogg",] + ); + } + + #[test] + fn selection_is_normalized_against_filter_and_bounds() { + let index = index(&[ + ("textures/coin.png", AssetKind::Image), + ("models/robot.glb", AssetKind::Mesh), + ]); + + let mut state = AssetBrowserState { + filter: AssetFilter::Images, + selected: Some(1), + ..default() + }; + assert!(state.normalize_selection(&index)); + assert_eq!(state.selected, None); + assert!(state.selected_entry(&index).is_none()); + + state.selected = Some(0); + assert!(!state.normalize_selection(&index)); + assert_eq!( + state + .selected_entry(&index) + .map(|entry| entry.path.as_str()), + Some("textures/coin.png") + ); + + state.filter = AssetFilter::Meshes; + assert!(state.normalize_selection(&index)); + assert_eq!(state.selected, None); + + state.filter = AssetFilter::All; + state.selected = Some(99); + assert!(state.normalize_selection(&index)); + assert_eq!(state.selected, None); + } + + #[test] + fn temp_assets_dir_is_indexed_with_expected_kinds() { + let temp = TempAssets::new("scan"); + temp.write("textures/coin.png", b"png-bytes"); + temp.write("models/robot.glb", b"glb"); + temp.write("audio/loop.ogg", b"ogg"); + temp.write("notes.txt", b"text"); + temp.write(".hidden.png", b"hidden"); + + let index = AssetIndex::scan(&temp.assets()); + + assert_eq!(index.error, None); + assert_eq!(index.len(), 4); + assert_eq!( + index.find("textures/coin.png").map(|entry| entry.kind), + Some(AssetKind::Image) + ); + assert_eq!( + index.find("models/robot.glb").map(|entry| entry.kind), + Some(AssetKind::Mesh) + ); + assert_eq!( + index.find("audio/loop.ogg").map(|entry| entry.kind), + Some(AssetKind::Audio) + ); + assert_eq!( + selected_paths(&index, &filtered_indices(&index, AssetFilter::Images)), + vec!["textures/coin.png"] + ); + assert_eq!( + selected_paths(&index, &filtered_indices(&index, AssetFilter::Meshes)), + vec!["models/robot.glb"] + ); + assert_eq!( + selected_paths(&index, &filtered_indices(&index, AssetFilter::Audio)), + vec!["audio/loop.ogg"] + ); + } + + #[test] + fn demo_project_assets_index_lists_texture_files() { + let project = Project::open(Path::new(DEMO_PROJECT)).expect("demo project must open"); + let index = AssetIndex::scan(&project.assets_dir); + + assert_eq!(index.error, None); + for name in [ + "textures/background.png", + "textures/coin.png", + "textures/crate.png", + "textures/hat.png", + "textures/player.png", + "textures/tile.png", + ] { + assert_eq!( + index.find(name).map(|entry| entry.kind), + Some(AssetKind::Image), + "missing image asset {name}" + ); + } + assert!(index.by_kind(AssetKind::Image).len() >= 6); + } + + #[test] + fn preview_plan_uses_asset_server_for_paths_under_the_root() { + let temp = TempAssets::new("preview-server"); + let abs_path = temp.write("textures/coin.png", b"not-decoded-here"); + let image = AssetEntry { + path: "textures/coin.png".to_string(), + abs_path, + kind: AssetKind::Image, + size_bytes: 7, + }; + + let plan = plan_preview(&image, Some(&temp.assets())); + + assert_eq!(plan.path, "textures/coin.png"); + assert_eq!(plan.kind, AssetKind::Image); + assert_eq!( + plan.source, + PreviewSource::AssetServer("textures/coin.png".to_string()) + ); + } + + #[test] + fn preview_plan_falls_back_to_project_file_outside_the_root() { + let temp = TempAssets::new("preview-local"); + let abs_path = temp.write("textures/coin.png", b"not-decoded-here"); + let image = AssetEntry { + path: "textures/coin.png".to_string(), + abs_path: abs_path.clone(), + kind: AssetKind::Image, + size_bytes: 7, + }; + + let plan = plan_preview(&image, Some(Path::new("/not/the/asset/root"))); + assert_eq!(plan.source, PreviewSource::ProjectFile(abs_path.clone())); + + let no_root = plan_preview(&image, None); + assert_eq!(no_root.source, PreviewSource::ProjectFile(abs_path)); + } + + #[test] + fn preview_plan_has_no_image_for_non_images() { + let mesh = entry("models/robot.glb", AssetKind::Mesh); + let plan = plan_preview(&mesh, None); + + assert_eq!(plan.kind, AssetKind::Mesh); + assert_eq!(plan.source, PreviewSource::None); + } + + #[test] + fn demo_png_decodes_for_local_preview() { + let path = Path::new(DEMO_PROJECT).join("assets/textures/coin.png"); + let image = decode_image_file(&path).expect("demo coin.png must decode"); + + assert!(image.width() > 0); + assert!(image.height() > 0); + } + + #[test] + fn preview_dimensions_stay_within_the_contract_limits() { + assert!(PREVIEW_IMAGE_WIDTH <= MAX_PREVIEW_WIDTH); + assert!(PREVIEW_IMAGE_HEIGHT <= MAX_PREVIEW_HEIGHT); + assert_eq!(ASSET_PANEL_HEIGHT, 190.0); + } +} diff --git a/crates/bevy_forge_runtime/src/export.rs b/crates/bevy_forge_runtime/src/export.rs new file mode 100644 index 0000000..a32e9df --- /dev/null +++ b/crates/bevy_forge_runtime/src/export.rs @@ -0,0 +1,511 @@ +//! Export the live scene to BSN or imperative Rust (T23, M2 Live). +//! +//! | Shortcut | Format | File | +//! |---|---|---| +//! | `Ctrl+E` | Bevy 0.20 BSN (`bsn!` / `bsn_list!`) | `exports/-bsn.rs` | +//! | `Ctrl+Shift+E` | Imperative `Commands::spawn` Rust | `exports/-rust.rs` | +//! +//! The generated code comes from the core exporters +//! ([`bevy_forge_core::codegen::ExportFormat`]); this module only decides when to +//! run them and where to write the result. Before generating, live ECS edits are +//! synced back into the document with [`sync_doc_from_ecs`], so an export matches +//! what the viewport currently shows (the same sync `Ctrl+S` performs). +//! +//! Files are always written below `project.root/exports/`: the slug is sanitised +//! with [`bevy_forge_core::project::slugify`] and the resolved directory is +//! canonicalised and checked against the project root before writing, so a +//! crafted scene name cannot escape the project. Exporting without a loaded +//! [`SceneDocument`] is a no-op. + +use std::{ + ffi::OsString, + fmt, io, + path::{Component, Path, PathBuf}, +}; + +use bevy::prelude::*; +use bevy_forge_core::{ + codegen::{CodegenOptions, ExportFormat}, + project::slugify, + CodegenError, +}; + +use crate::{ + plugin::{editor_active, EditorState}, + scene::{sync_doc_from_ecs, SceneDocument}, +}; + +/// Project-relative directory that receives generated files. +pub const EXPORTS_DIR: &str = "exports"; + +/// Paths of the most recent successful exports. +#[derive(Resource, Debug, Default)] +pub struct ExportState { + /// Last file written by `Ctrl+E` (BSN). + pub last_bsn: Option, + /// Last file written by `Ctrl+Shift+E` (Rust). + pub last_rust: Option, +} + +/// Errors from an export attempt. +#[derive(Debug)] +pub enum ExportError { + /// The core code generator rejected the document. + Codegen(CodegenError), + /// The generated file could not be written. + Io(io::Error), + /// The computed path would leave the project root. + OutsideProject(PathBuf), +} + +impl fmt::Display for ExportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Codegen(error) => write!(f, "code generation failed: {error}"), + Self::Io(error) => write!(f, "write failed: {error}"), + Self::OutsideProject(path) => write!( + f, + "refusing to write outside the project root: {}", + path.display() + ), + } + } +} + +impl std::error::Error for ExportError {} + +impl From for ExportError { + fn from(error: CodegenError) -> Self { + Self::Codegen(error) + } +} + +impl From for ExportError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +/// Installs the `Ctrl+E` / `Ctrl+Shift+E` export hotkeys. +pub struct ExportPlugin; + +impl Plugin for ExportPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + // `EditorPlugin` installs this too; initializing it here keeps the + // plugin self-contained for standalone apps and unit tests. + .init_resource::() + .add_systems(Update, export_shortcut.run_if(editor_active)); + } +} + +/// Resolves the export hotkey; `None` when neither chord was pressed. +fn export_request(keys: &ButtonInput) -> Option { + if !keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]) + || !keys.just_pressed(KeyCode::KeyE) + { + return None; + } + let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); + Some(if shift { + ExportFormat::Rust + } else { + ExportFormat::Bsn + }) +} + +/// `Ctrl+E` exports BSN; `Ctrl+Shift+E` exports imperative Rust. +/// +/// The live ECS is synced into the document first when a [`SceneDocument`] +/// exists; without one the shortcut does nothing. +fn export_shortcut( + keys: Res>, + document: Option>, + mut state: ResMut, + names: Query<&Name>, + transforms: Query<&Transform>, + visibilities: Query<&Visibility>, +) { + let Some(format) = export_request(&keys) else { + return; + }; + let Some(mut document) = document else { + return; + }; + + // Export what the viewport shows, not a stale document. + let synced = sync_doc_from_ecs(&mut document, &names, &transforms, &visibilities); + debug!("export: synced {synced} entities from the live ECS"); + + match write_export(&document, format) { + Ok((path, bytes)) => { + info!("export: wrote {} ({bytes} bytes)", path.display()); + match format { + ExportFormat::Bsn => state.last_bsn = Some(path), + ExportFormat::Rust => state.last_rust = Some(path), + } + } + Err(error) => warn!("export: failed: {error}"), + } +} + +/// Generates `format` from the document and writes it below `/exports/`. +/// +/// Returns the written path and the number of bytes written. +pub fn write_export( + document: &SceneDocument, + format: ExportFormat, +) -> Result<(PathBuf, usize), ExportError> { + let code = format.generate(&document.doc, &CodegenOptions::default())?; + let path = export_path(&document.project.root, &document.doc.name, format)?; + let Some(directory) = path.parent() else { + return Err(ExportError::OutsideProject(path)); + }; + std::fs::create_dir_all(directory)?; + + // Canonicalise the directory before writing so an in-project symlink cannot + // redirect the export outside the root. + let root = document.project.root.canonicalize()?; + let directory = directory.canonicalize()?; + if !directory.starts_with(&root) { + return Err(ExportError::OutsideProject(directory)); + } + let Some(file_name) = path.file_name().map(OsString::from) else { + return Err(ExportError::OutsideProject(path)); + }; + let path = directory.join(file_name); + std::fs::write(&path, code.as_bytes())?; + Ok((path, code.len())) +} + +/// Full path of the generated file for `scene_name` inside `project_root`. +/// +/// The name is slugified, so separators and `..` cannot survive into the file +/// path; the result is additionally normalized and checked against the root. +pub fn export_path( + project_root: &Path, + scene_name: &str, + format: ExportFormat, +) -> Result { + let directory = project_root.join(EXPORTS_DIR); + let path = normalize_lexical(&directory.join(export_file_name(scene_name, format))); + if !path.starts_with(project_root) { + return Err(ExportError::OutsideProject(path)); + } + Ok(path) +} + +/// File name (without the `exports/` directory) for one export format. +pub fn export_file_name(scene_name: &str, format: ExportFormat) -> String { + let tag = match format { + ExportFormat::Bsn => "bsn", + ExportFormat::Rust => "rust", + }; + format!("{}-{tag}.rs", slugify(scene_name)) +} + +/// Lexically removes `.` / `..` components without touching the filesystem. +fn normalize_lexical(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + out +} + +#[cfg(test)] +mod tests { + use std::{ + collections::HashMap, + path::Path, + sync::atomic::{AtomicU64, Ordering}, + }; + + use super::*; + use crate::scene::{SceneDocument, DEFAULT_SCENE}; + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + /// A throwaway Bevy project on disk, removed on drop. + struct TempProject { + root: PathBuf, + } + + impl TempProject { + fn new(label: &str) -> Self { + let unique = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "forge-t23-export-{label}-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("create temp project dir"); + std::fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"forge-t23-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n", + ) + .expect("write temp Cargo.toml"); + Self { root } + } + + fn open(&self) -> bevy_forge_core::Project { + bevy_forge_core::Project::open(&self.root).expect("temp project must open") + } + } + + impl Drop for TempProject { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + fn demo_project_root() -> PathBuf { + PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../examples/demo-project" + )) + } + + fn garden_document(project: bevy_forge_core::Project) -> SceneDocument { + let demo = bevy_forge_core::Project::open(demo_project_root()).expect("demo project"); + let doc = demo + .load_scene(demo.root.join(DEFAULT_SCENE)) + .expect("garden scene must load"); + SceneDocument { + doc, + project, + scene_path: PathBuf::from(DEFAULT_SCENE), + save_path: PathBuf::from("scenes/out.forge.ron"), + entities: HashMap::new(), + loaded: true, + } + } + + /// A one-root document named like the garden demo, so the generated BSN + /// uses `bsn!` and the output path is `garden-demo-{bsn,rust}.rs`. + fn single_root_document(project: bevy_forge_core::Project) -> SceneDocument { + use bevy_forge_core::{ComponentData, Mesh3dData, SceneDoc}; + + let mut doc = SceneDoc::new("Garden Demo"); + let cube = doc.add_entity("Export Cube", None); + doc.add_component(cube, ComponentData::Mesh3d(Mesh3dData::default())); + SceneDocument { + doc, + project, + scene_path: PathBuf::from(DEFAULT_SCENE), + save_path: PathBuf::from("scenes/out.forge.ron"), + entities: HashMap::new(), + loaded: true, + } + } + + fn export_app(document: Option) -> App { + let mut app = App::new(); + app.init_resource::>() + .init_resource::(); + if let Some(document) = document { + app.insert_resource(document); + } + app.add_plugins(ExportPlugin); + app + } + + /// Runs one frame and clears `just_pressed`, matching `InputPlugin`. + fn tick(app: &mut App) { + app.update(); + app.world_mut() + .resource_mut::>() + .clear(); + } + + fn press(app: &mut App, keys: impl IntoIterator) { + let mut input = app.world_mut().resource_mut::>(); + input.reset_all(); + for key in keys { + input.press(key); + } + } + + #[test] + fn generated_bsn_contains_bsn_macro_and_rust_contains_commands() { + // A single-root scene is the case where the core exporter emits `bsn!`; + // multi-root scenes use `bsn_list!` (covered by the garden test below). + let temp = TempProject::new("markers"); + let document = single_root_document(temp.open()); + + let bsn = ExportFormat::Bsn + .generate(&document.doc, &CodegenOptions::default()) + .expect("BSN generation must succeed"); + assert!(bsn.contains("bsn!"), "BSN export must use the bsn! macro"); + assert!(bsn.contains("Export Cube")); + + let rust = ExportFormat::Rust + .generate(&document.doc, &CodegenOptions::default()) + .expect("Rust generation must succeed"); + assert!(rust.contains("Commands"), "Rust export must take Commands"); + assert!(rust.contains(".spawn("), "Rust export must spawn entities"); + assert!(rust.contains("Export Cube")); + } + + #[test] + fn garden_bsn_uses_list_macro_for_multiple_roots() { + let demo = bevy_forge_core::Project::open(demo_project_root()).expect("demo project"); + let doc = demo + .load_scene(demo.root.join(DEFAULT_SCENE)) + .expect("garden scene must load"); + assert!(doc.roots.len() > 1, "the garden scene has several roots"); + + let bsn = ExportFormat::Bsn + .generate(&doc, &CodegenOptions::default()) + .expect("BSN generation must succeed"); + assert!( + bsn.contains("bsn_list!"), + "multi-root scenes must use bsn_list!" + ); + assert!(bsn.contains("Name(\"Camera\")")); + assert!(bsn.contains("Name(\"Ground\")")); + } + + #[test] + fn slug_and_path_safety_keep_exports_inside_the_project() { + let root = Path::new("/tmp/forge-t23/project"); + + let bsn = export_path(root, "Garden Demo", ExportFormat::Bsn).expect("safe path"); + assert_eq!(bsn, root.join("exports/garden-demo-bsn.rs")); + + let rust = export_path(root, "Garden Demo", ExportFormat::Rust).expect("safe path"); + assert_eq!(rust, root.join("exports/garden-demo-rust.rs")); + + // Separators, dots and unicode never reach the file name. + for name in [ + "../../etc/passwd", + "..\\..\\windows\\system32", + "My Scene!!", + "***", + " ", + "héllo/wörld", + ] { + let path = export_path(root, name, ExportFormat::Bsn).expect("slug must be safe"); + assert!( + path.starts_with(root), + "{name:?} must stay below the project root: {path:?}" + ); + assert_eq!( + path.parent(), + Some(root.join("exports").as_path()), + "{name:?} must stay in exports/" + ); + let file_name = path.file_name().unwrap().to_string_lossy(); + assert!( + file_name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.'), + "unsafe characters in {file_name:?}" + ); + assert!( + !file_name.contains(".."), + "dot segments leaked into {path:?}" + ); + } + + assert_eq!(export_file_name("***", ExportFormat::Bsn), "scene-bsn.rs"); + assert_eq!( + export_file_name("../../evil Scene", ExportFormat::Rust), + "evil-scene-rust.rs" + ); + } + + #[test] + fn ctrl_e_writes_bsn_and_ctrl_shift_e_writes_rust() { + let temp = TempProject::new("write"); + let project = temp.open(); + let mut app = export_app(Some(single_root_document(project))); + + press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyE]); + tick(&mut app); + + let bsn_path = { + let state = app.world().resource::(); + let path = state + .last_bsn + .clone() + .expect("Ctrl+E must record the BSN path"); + assert!(state.last_rust.is_none()); + path + }; + assert!(bsn_path.starts_with(&temp.root)); + assert!(bsn_path.ends_with("exports/garden-demo-bsn.rs")); + let bsn = std::fs::read_to_string(&bsn_path).expect("BSN export file must exist"); + assert!(bsn.contains("bsn!")); + assert_eq!( + bsn.len(), + std::fs::metadata(&bsn_path).unwrap().len() as usize + ); + + press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyE]); + tick(&mut app); + // Without Shift the second press overwrites the BSN file. + let state = app.world().resource::(); + assert_eq!(state.last_bsn.as_deref(), Some(bsn_path.as_path())); + + press( + &mut app, + [KeyCode::ControlLeft, KeyCode::ShiftLeft, KeyCode::KeyE], + ); + tick(&mut app); + + let state = app.world().resource::(); + let rust_path = state + .last_rust + .clone() + .expect("Ctrl+Shift+E must record the Rust path"); + assert!(rust_path.starts_with(&temp.root)); + assert!(rust_path.ends_with("exports/garden-demo-rust.rs")); + let rust = std::fs::read_to_string(&rust_path).expect("Rust export file must exist"); + assert!(rust.contains("Commands")); + assert!(rust.contains(".spawn(")); + + // The garden source scene itself is untouched. + let demo = bevy_forge_core::Project::open(demo_project_root()).expect("demo project"); + assert!(demo.root.join(DEFAULT_SCENE).is_file()); + } + + #[test] + fn export_without_a_document_is_a_no_op() { + let mut app = export_app(None); + + press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyE]); + tick(&mut app); + press( + &mut app, + [KeyCode::ControlLeft, KeyCode::ShiftLeft, KeyCode::KeyE], + ); + tick(&mut app); + + let state = app.world().resource::(); + assert!(state.last_bsn.is_none()); + assert!(state.last_rust.is_none()); + } + + #[test] + fn export_is_ignored_while_the_editor_is_disabled() { + let temp = TempProject::new("disabled"); + let project = temp.open(); + let mut app = export_app(Some(garden_document(project))); + app.world_mut().resource_mut::().enabled = false; + + press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyE]); + tick(&mut app); + + assert!(app.world().resource::().last_bsn.is_none()); + assert!( + !temp.root.join("exports/garden-demo-bsn.rs").exists(), + "disabled editor must not write files" + ); + } +} diff --git a/crates/bevy_forge_runtime/src/gizmo.rs b/crates/bevy_forge_runtime/src/gizmo.rs new file mode 100644 index 0000000..3af71db --- /dev/null +++ b/crates/bevy_forge_runtime/src/gizmo.rs @@ -0,0 +1,799 @@ +//! Translate gizmo and editor camera. +//! +//! M0 scope: +//! - adopt the host app's placeholder `Camera3d` (or spawn one) and drive it with +//! right-drag orbit, middle-drag pan and wheel zoom; +//! - draw world-aligned RGB axes on the selected entity's `GlobalTransform`; +//! - pick an axis in screen space, then drag it with a ray/plane calculation and +//! write the result back to the selected entity's `Transform.translation`. +//! +//! M0 limit: the world-space drag delta is converted into parent-local space +//! with the parent's *inverse rotation* only. Parent scale is ignored, so a +//! scaled parent makes the gizmo travel at the wrong world-space rate. Generic +//! component editing, local-space gizmos and snapping are M1+. + +use bevy::{ + input::mouse::{MouseMotion, MouseScrollUnit, MouseWheel}, + prelude::*, + render::view::Tonemapping, + window::PrimaryWindow, +}; + +use crate::{ + history::{History, TransformEdit}, + plugin::{editor_active, EditorState, Selection}, + ui::EditorLayout, +}; + +/// Screen-space distance (logical pixels) within which a click hits a gizmo axis. +pub const AXIS_PICK_RADIUS_PX: f32 = 12.0; + +const GIZMO_LENGTH_PER_DISTANCE: f32 = 0.18; +const GIZMO_MIN_LENGTH: f32 = 0.8; +const GIZMO_MAX_LENGTH: f32 = 3.0; + +const MIN_CAMERA_DISTANCE: f32 = 2.0; +const MAX_CAMERA_DISTANCE: f32 = 40.0; +const DEFAULT_CAMERA_DISTANCE: f32 = 12.5; +const MAX_CAMERA_PITCH: f32 = 85.0 * std::f32::consts::PI / 180.0; + +const ORBIT_SENSITIVITY: f32 = 0.005; +const PAN_SENSITIVITY: f32 = 0.0015; +const ZOOM_STEP: f32 = 0.9; + +const RAY_EPSILON: f32 = 1e-6; + +/// Orbit/pan/zoom state of the one editor camera. +/// +/// The camera position is derived from `focus`, `yaw`, `pitch` and `distance` +/// instead of being stored directly so the controls stay a simple orbit model. +#[derive(Component, Debug, Clone, Copy, PartialEq)] +pub struct EditorCamera { + /// World-space point the camera orbits around and looks at. + pub focus: Vec3, + /// Rotation around `Vec3::Y`, radians. + pub yaw: f32, + /// Elevation above the focus plane, radians, clamped to ±85°. + pub pitch: f32, + /// Distance from `focus` to the camera, clamped to `2.0..=40.0`. + pub distance: f32, +} + +impl Default for EditorCamera { + /// The M0 placeholder pose from the demo scene: eye `(6, 5, 10)`, looking + /// at `(0, 0.5, 0)`. + fn default() -> Self { + let placeholder = + Transform::from_xyz(6.0, 5.0, 10.0).looking_at(Vec3::new(0.0, 0.5, 0.0), Vec3::Y); + editor_camera_from_transform(&placeholder) + } +} + +/// One of the three translate axes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GizmoAxis { + /// World +X. + X, + /// World +Y. + Y, + /// World +Z. + Z, +} + +impl GizmoAxis { + /// All axes in RGB order. + pub const ALL: [Self; 3] = [Self::X, Self::Y, Self::Z]; + + /// Unit world-space direction of this axis. + pub fn direction(self) -> Vec3 { + match self { + Self::X => Vec3::X, + Self::Y => Vec3::Y, + Self::Z => Vec3::Z, + } + } +} + +/// Active translate-gizmo drag. Reset to `Default` when no drag is running. +#[derive(Resource, Default, Debug, Clone)] +pub struct GizmoDrag { + /// Whether a drag is currently in progress. + pub active: bool, + /// The entity being moved. + pub entity: Option, + /// The axis being dragged. + pub axis: Option, + /// `Transform.translation` of the entity when the drag began (parent-local). + pub start_translation: Vec3, + /// Full `Transform` of the entity when the drag began, used to record one + /// undo entry on release. `None` outside a drag. + pub start_transform: Option, + /// First ray/plane hit in world space. + pub start_world_hit: Vec3, + /// World-space gizmo origin when the drag began. + pub origin: Vec3, + /// Unit world-space direction of the dragged axis. + pub axis_world: Vec3, + /// Normal of the drag plane (perpendicular to the axis). + pub plane_normal: Vec3, +} + +impl GizmoDrag { + /// Ends the current drag and clears all stored state. + pub fn clear(&mut self) { + *self = Self::default(); + } +} + +/// Adds the editor camera controls and the translate gizmo. +pub struct GizmoPlugin; + +impl Plugin for GizmoPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + // `PostStartup` deliberately runs after the host app's `Startup` scene + // so the placeholder camera exists and can be adopted. + .add_systems(PostStartup, ensure_editor_camera) + .add_systems( + Update, + ( + editor_camera_control, + log_selection, + begin_gizmo_drag, + update_gizmo_drag, + draw_gizmo, + ) + .chain() + .run_if(editor_active), + ) + // Always clean up, even if the editor was disabled mid-drag. + .add_systems(Update, end_gizmo_drag); + } +} + +/// Adopts the host app's placeholder `Camera3d`, or spawns one if there is none. +/// +/// Adoption never touches the existing `Transform`, so the initial framing is +/// preserved; only the orbit state is derived from it. A spawned camera uses the +/// M0 placeholder pose and `Tonemapping::AcesFitted` (no LUT assets). +fn ensure_editor_camera( + mut commands: Commands, + existing: Query<(), With>, + cameras: Query<(Entity, &Transform), With>, +) { + if !existing.is_empty() { + info!("gizmo: camera ready"); + return; + } + + if let Some((entity, transform)) = cameras.iter().next() { + commands + .entity(entity) + .insert(editor_camera_from_transform(transform)); + debug!("gizmo: adopted camera {entity}"); + info!("gizmo: camera ready"); + return; + } + + let editor = EditorCamera::default(); + let transform = camera_transform(&editor); + commands.spawn(( + Name::new("Editor Camera"), + Camera3d::default(), + Tonemapping::AcesFitted, + transform, + GlobalTransform::from(transform), + editor, + )); + debug!("gizmo: spawned camera"); + info!("gizmo: camera ready"); +} + +/// Derives orbit state from a camera transform by looking along its forward ray. +/// +/// The distance is estimated as the camera's distance from the world origin, +/// which keeps the placeholder framing stable while giving the orbit controls a +/// sensible pivot. Returns the default distance for a camera at the origin. +pub fn editor_camera_from_transform(transform: &Transform) -> EditorCamera { + let position = transform.translation; + let mut distance = position.length(); + if !distance.is_finite() || distance < 0.01 { + distance = DEFAULT_CAMERA_DISTANCE; + } + + let focus = position + *transform.forward() * distance; + let offset = position - focus; + let horizontal = Vec2::new(offset.x, offset.z).length(); + let yaw = offset.x.atan2(offset.z); + let pitch = offset + .y + .atan2(horizontal) + .clamp(-MAX_CAMERA_PITCH, MAX_CAMERA_PITCH); + + EditorCamera { + focus, + yaw, + pitch, + distance, + } +} + +/// Builds a camera transform from orbit state. `looking_at` keeps roll at zero. +pub fn camera_transform(camera: &EditorCamera) -> Transform { + let (sin_yaw, cos_yaw) = camera.yaw.sin_cos(); + let (sin_pitch, cos_pitch) = camera.pitch.sin_cos(); + let offset = Vec3::new(cos_pitch * sin_yaw, sin_pitch, cos_pitch * cos_yaw) * camera.distance; + + Transform::from_translation(camera.focus + offset).looking_at(camera.focus, Vec3::Y) +} + +/// Gizmo axis length for a camera distance: roughly screen-constant, clamped. +pub fn gizmo_length(camera_distance: f32) -> f32 { + (camera_distance * GIZMO_LENGTH_PER_DISTANCE).clamp(GIZMO_MIN_LENGTH, GIZMO_MAX_LENGTH) +} + +/// Right-drag orbit, middle-drag pan, wheel zoom while the cursor is in the viewport. +fn editor_camera_control( + windows: Query<&Window, With>, + layout: Res, + mouse: Res>, + mut motion: MessageReader, + mut wheel: MessageReader, + mut cameras: Query<(&mut EditorCamera, &mut Transform)>, +) { + // Consume input every frame so a dragged-then-released button does not leave + // stale deltas queued for the next drag. + let motion_delta = motion + .read() + .fold(Vec2::ZERO, |total, event| total + event.delta); + let wheel_delta: f32 = wheel + .read() + .map(|event| match event.unit { + MouseScrollUnit::Line => event.y, + MouseScrollUnit::Pixel => event.y / 100.0, + }) + .sum(); + + let Ok(window) = windows.single() else { + return; + }; + let in_viewport = window + .cursor_position() + .is_some_and(|cursor| layout.viewport_rect(window).contains(cursor)); + if !in_viewport { + return; + } + + let Ok((mut camera, mut transform)) = cameras.single_mut() else { + return; + }; + let mut changed = false; + + if mouse.pressed(MouseButton::Right) && motion_delta != Vec2::ZERO { + camera.yaw -= motion_delta.x * ORBIT_SENSITIVITY; + camera.pitch = (camera.pitch + motion_delta.y * ORBIT_SENSITIVITY) + .clamp(-MAX_CAMERA_PITCH, MAX_CAMERA_PITCH); + changed = true; + } else if mouse.pressed(MouseButton::Middle) && motion_delta != Vec2::ZERO { + let right = *transform.right(); + let up = *transform.up(); + let scale = camera.distance * PAN_SENSITIVITY; + camera.focus += (-right * motion_delta.x + up * motion_delta.y) * scale; + changed = true; + } + + if wheel_delta != 0.0 { + camera.distance = (camera.distance * ZOOM_STEP.powf(wheel_delta)) + .clamp(MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE); + changed = true; + } + + if changed { + *transform = camera_transform(&camera); + } +} + +/// Logs `gizmo: selected {entity}` once per selection, not once per frame. +fn log_selection(selection: Res, mut last_logged: Local>) { + if *last_logged == selection.0 { + return; + } + *last_logged = selection.0; + if let Some(entity) = selection.0 { + info!("gizmo: selected {entity}"); + } +} + +/// Draws the world-aligned axes at the selected entity's global position. +fn draw_gizmo( + mut gizmos: Gizmos, + selection: Res, + entities: Query<&GlobalTransform>, + cameras: Query<&EditorCamera>, +) { + let Some(entity) = selection.0 else { + return; + }; + let Ok(global) = entities.get(entity) else { + return; + }; + + let distance = cameras + .iter() + .next() + .map_or(DEFAULT_CAMERA_DISTANCE, |camera| camera.distance); + let length = gizmo_length(distance); + gizmos.axes(Transform::from_translation(global.translation()), length); +} + +/// Picks the nearest axis on left press inside the viewport and starts a drag. +fn begin_gizmo_drag( + mouse: Res>, + windows: Query<&Window, With>, + layout: Res, + selection: Res, + targets: Query<(&Transform, &GlobalTransform)>, + cameras: Query<(&Camera, &GlobalTransform, &EditorCamera)>, + mut drag: ResMut, +) { + if !mouse.just_pressed(MouseButton::Left) { + return; + } + + let Ok(window) = windows.single() else { + return; + }; + let Some(cursor) = window.cursor_position() else { + return; + }; + if !layout.viewport_rect(window).contains(cursor) { + return; + } + + let Some(entity) = selection.0 else { + return; + }; + let Ok((target_transform, target_global)) = targets.get(entity) else { + return; + }; + let Ok((camera, camera_global, editor_camera)) = cameras.single() else { + return; + }; + + let origin = target_global.translation(); + let length = gizmo_length(editor_camera.distance); + let Ok(origin_screen) = camera.world_to_viewport(camera_global, origin) else { + return; + }; + + let mut endpoints = [(GizmoAxis::X, Vec2::ZERO); 3]; + let mut endpoint_count = 0; + for axis in GizmoAxis::ALL { + let world_end = origin + axis.direction() * length; + if let Ok(screen_end) = camera.world_to_viewport(camera_global, world_end) { + endpoints[endpoint_count] = (axis, screen_end); + endpoint_count += 1; + } + } + + let Some(axis) = pick_axis( + cursor, + origin_screen, + &endpoints[..endpoint_count], + AXIS_PICK_RADIUS_PX, + ) else { + return; + }; + + let axis_world = axis.direction(); + let normal = axis_plane_normal(axis_world, *camera_global.forward()); + let Ok(ray) = camera.viewport_to_world(camera_global, cursor) else { + return; + }; + let Some(start_world_hit) = ray_plane_hit(ray.origin, *ray.direction, origin, normal) else { + return; + }; + + *drag = GizmoDrag { + active: true, + entity: Some(entity), + axis: Some(axis), + start_translation: target_transform.translation, + start_transform: Some(*target_transform), + start_world_hit, + origin, + axis_world, + plane_normal: normal, + }; +} + +/// Updates the dragged translation while the left button stays held. +fn update_gizmo_drag( + mouse: Res>, + windows: Query<&Window, With>, + layout: Res, + cameras: Query<(&Camera, &GlobalTransform)>, + parents: Query<&ChildOf>, + globals: Query<&GlobalTransform>, + mut transforms: Query<&mut Transform>, + mut drag: ResMut, +) { + if !drag.active { + return; + } + if !mouse.pressed(MouseButton::Left) { + // The release itself is handled by `end_gizmo_drag`, which records the + // single edit for the whole drag. A press state lost without a release + // event (for example a lost window focus) just cancels the stale drag. + if !mouse.just_released(MouseButton::Left) { + drag.clear(); + } + return; + } + let (Some(entity), Some(_axis)) = (drag.entity, drag.axis) else { + drag.clear(); + return; + }; + + let Ok(window) = windows.single() else { + return; + }; + let Some(cursor) = window.cursor_position() else { + return; + }; + if !layout.viewport_rect(window).contains(cursor) { + return; + } + let Ok((camera, camera_global)) = cameras.single() else { + return; + }; + let Ok(ray) = camera.viewport_to_world(camera_global, cursor) else { + return; + }; + let Some(hit) = ray_plane_hit(ray.origin, *ray.direction, drag.origin, drag.plane_normal) + else { + return; + }; + + let parent_rotation = parent_rotation_of(entity, &parents, &globals); + let translation = dragged_translation( + drag.start_translation, + drag.start_world_hit, + hit, + drag.axis_world, + parent_rotation, + ); + + let Ok(mut transform) = transforms.get_mut(entity) else { + drag.clear(); + return; + }; + transform.translation = translation; +} + +/// Builds the single history edit for a finished gizmo drag. +/// +/// The translate gizmo only edits `translation`, so a press/release without +/// movement records nothing; a changed drag records one `before`/`after` edit. +fn finished_drag_edit( + entity: Entity, + before: Transform, + after: Transform, +) -> Option { + (before.translation != after.translation).then_some(TransformEdit { + entity, + before, + after, + }) +} + +/// Ends the drag on left release and records one transform edit if the entity +/// actually moved. Runs even when the editor is disabled (the drag is always +/// cleaned up, but history is only recorded while the editor is enabled). +fn end_gizmo_drag( + mouse: Res>, + state: Res, + mut history: ResMut, + transforms: Query<&Transform>, + mut drag: ResMut, +) { + if !drag.active || !mouse.just_released(MouseButton::Left) { + return; + } + + if state.enabled { + if let (Some(entity), Some(before)) = (drag.entity, drag.start_transform) { + if let Ok(after) = transforms.get(entity) { + if let Some(edit) = finished_drag_edit(entity, before, *after) { + history.push(edit); + } + } + } + } + + drag.clear(); +} + +/// Reads the parent's global rotation for a selected entity, or identity. +fn parent_rotation_of( + entity: Entity, + parents: &Query<&ChildOf>, + globals: &Query<&GlobalTransform>, +) -> Quat { + parents + .get(entity) + .ok() + .and_then(|child_of| globals.get(child_of.parent()).ok()) + .map(GlobalTransform::rotation) + .unwrap_or(Quat::IDENTITY) +} + +/// Screen-space distance from `point` to the segment `start..end`. +pub fn point_segment_distance(point: Vec2, start: Vec2, end: Vec2) -> f32 { + let segment = end - start; + let length_squared = segment.length_squared(); + if length_squared <= f32::EPSILON { + return point.distance(start); + } + let t = ((point - start).dot(segment) / length_squared).clamp(0.0, 1.0); + point.distance(start + segment * t) +} + +/// Picks the nearest axis segment within `max_distance_px`, independent of order. +pub fn pick_axis( + cursor: Vec2, + origin: Vec2, + endpoints: &[(GizmoAxis, Vec2)], + max_distance_px: f32, +) -> Option { + let mut best: Option<(GizmoAxis, f32)> = None; + for &(axis, endpoint) in endpoints { + let distance = point_segment_distance(cursor, origin, endpoint); + if distance > max_distance_px { + continue; + } + if best.is_none_or(|(_, best_distance)| distance < best_distance) { + best = Some((axis, distance)); + } + } + best.map(|(axis, _)| axis) +} + +/// Returns the drag-plane normal for an axis: camera forward projected onto the +/// plane perpendicular to the axis. Falls back to any perpendicular vector when +/// the view direction and the axis are (nearly) parallel. +pub fn axis_plane_normal(axis: Vec3, camera_forward: Vec3) -> Vec3 { + let axis = axis.normalize_or_zero(); + let projected = camera_forward - axis * camera_forward.dot(axis); + if projected.length_squared() > RAY_EPSILON * RAY_EPSILON { + projected.normalize() + } else { + axis.any_orthonormal_vector() + } +} + +/// Intersects a ray with a plane. Returns `None` for parallel rays and hits +/// behind the ray origin. +pub fn ray_plane_hit( + ray_origin: Vec3, + ray_direction: Vec3, + plane_origin: Vec3, + plane_normal: Vec3, +) -> Option { + let denominator = ray_direction.dot(plane_normal); + if denominator.abs() <= RAY_EPSILON { + return None; + } + let t = (plane_origin - ray_origin).dot(plane_normal) / denominator; + if t < 0.0 { + return None; + } + Some(ray_origin + ray_direction * t) +} + +/// Scalar distance a plane hit moved along `axis`. +pub fn axis_delta(start_hit: Vec3, current_hit: Vec3, axis: Vec3) -> f32 { + (current_hit - start_hit).dot(axis.normalize_or_zero()) +} + +/// Converts a world-space delta into the parent's local space. +/// +/// M0 limit: only the parent's inverse rotation is applied; parent scale is +/// intentionally ignored. A scaled parent therefore changes the effective +/// world-space speed of the drag. +pub fn world_delta_to_local(world_delta: Vec3, parent_rotation: Quat) -> Vec3 { + parent_rotation.inverse() * world_delta +} + +/// Computes the new parent-local translation for a drag from two plane hits. +pub fn dragged_translation( + start_translation: Vec3, + start_hit: Vec3, + current_hit: Vec3, + axis_world: Vec3, + parent_rotation: Quat, +) -> Vec3 { + let scalar = axis_delta(start_hit, current_hit, axis_world); + let world_delta = axis_world * scalar; + start_translation + world_delta_to_local(world_delta, parent_rotation) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_approx(actual: f32, expected: f32) { + assert!( + (actual - expected).abs() < 1e-4, + "expected {expected}, got {actual}" + ); + } + + fn assert_vec3_approx(actual: Vec3, expected: Vec3) { + assert!( + actual.abs_diff_eq(expected, 1e-4), + "expected {expected:?}, got {actual:?}" + ); + } + + #[test] + fn point_segment_distance_inside_and_outside() { + let start = Vec2::new(0.0, 0.0); + let end = Vec2::new(10.0, 0.0); + + // Perpendicular projection lands inside the segment. + assert_approx(point_segment_distance(Vec2::new(5.0, 3.0), start, end), 3.0); + // Outside the segment, nearest point is the start cap. + assert_approx( + point_segment_distance(Vec2::new(-4.0, 3.0), start, end), + 5.0, + ); + // Outside the segment, nearest point is the end cap. + assert_approx( + point_segment_distance(Vec2::new(14.0, -3.0), start, end), + 5.0, + ); + // Degenerate segment. + assert_approx( + point_segment_distance(Vec2::new(3.0, 4.0), start, start), + 5.0, + ); + } + + #[test] + fn nearest_axis_is_picked_within_the_screen_threshold() { + let origin = Vec2::new(100.0, 100.0); + let endpoints = [ + (GizmoAxis::X, Vec2::new(160.0, 100.0)), + (GizmoAxis::Y, Vec2::new(100.0, 40.0)), + (GizmoAxis::Z, Vec2::new(40.0, 100.0)), + ]; + + assert_eq!( + pick_axis(Vec2::new(140.0, 104.0), origin, &endpoints, 12.0), + Some(GizmoAxis::X) + ); + assert_eq!( + pick_axis(Vec2::new(100.0, 44.0), origin, &endpoints, 12.0), + Some(GizmoAxis::Y) + ); + // Far away from every axis segment. + assert_eq!( + pick_axis(Vec2::new(100.0, 130.0), origin, &endpoints, 12.0), + None + ); + } + + #[test] + fn ray_plane_hit_returns_expected_point_and_rejects_parallel() { + let hit = ray_plane_hit(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, Vec3::ZERO, Vec3::Y) + .expect("downward ray should hit the XZ plane"); + assert_vec3_approx(hit, Vec3::ZERO); + + // Parallel ray never intersects the plane. + assert_eq!( + ray_plane_hit(Vec3::ZERO, Vec3::X, Vec3::new(0.0, 1.0, 0.0), Vec3::Y), + None + ); + + // A hit behind the ray origin is not a valid hit. + assert_eq!( + ray_plane_hit(Vec3::new(0.0, 1.0, 0.0), Vec3::Y, Vec3::ZERO, Vec3::Y), + None + ); + } + + #[test] + fn axis_delta_projects_a_plane_hit_onto_the_axis() { + let start = Vec3::new(0.0, 0.0, 0.0); + let current = Vec3::new(2.5, 9.0, -4.0); + + assert_approx(axis_delta(start, current, Vec3::X), 2.5); + assert_approx(axis_delta(start, current, Vec3::Y), 9.0); + // Non-normalized axis directions are normalized before projecting. + assert_approx(axis_delta(start, current, Vec3::Z * 2.0), -4.0); + } + + #[test] + fn world_delta_to_local_uses_the_inverse_parent_rotation() { + // Parent rotated +90° around Y: a world +X delta is local +Z. + let parent_rotation = Quat::from_rotation_y(std::f32::consts::FRAC_PI_2); + let local = world_delta_to_local(Vec3::new(2.0, 0.0, 0.0), parent_rotation); + assert_vec3_approx(local, Vec3::new(0.0, 0.0, 2.0)); + } + + #[test] + fn drag_helper_changes_transform_by_expected_axis_delta() { + let start_translation = Vec3::new(1.0, 2.0, 3.0); + let start_hit = Vec3::ZERO; + let current_hit = Vec3::new(2.0, 7.0, -3.0); + + let translation = dragged_translation( + start_translation, + start_hit, + current_hit, + Vec3::X, + Quat::IDENTITY, + ); + let mut transform = Transform::from_translation(start_translation); + transform.translation = translation; + assert_vec3_approx(transform.translation, Vec3::new(3.0, 2.0, 3.0)); + + // Under a parent rotated +90° around Y, the same world delta becomes +2 on local Z. + let parent_rotation = Quat::from_rotation_y(std::f32::consts::FRAC_PI_2); + let translation = dragged_translation( + start_translation, + start_hit, + current_hit, + Vec3::X, + parent_rotation, + ); + let mut transform = Transform::from_translation(start_translation); + transform.translation = translation; + assert_vec3_approx(transform.translation, Vec3::new(1.0, 2.0, 5.0)); + } + + #[test] + fn gizmo_length_scales_with_distance_and_clamps() { + assert_approx(gizmo_length(2.0), GIZMO_MIN_LENGTH); + assert_approx(gizmo_length(12.5), 2.25); + assert_approx(gizmo_length(40.0), GIZMO_MAX_LENGTH); + } + + #[test] + fn adopted_camera_keeps_its_position() { + let transform = + Transform::from_xyz(6.0, 5.0, 10.0).looking_at(Vec3::new(0.0, 0.5, 0.0), Vec3::Y); + let camera = editor_camera_from_transform(&transform); + let rebuilt = camera_transform(&camera); + + assert_vec3_approx(rebuilt.translation, transform.translation); + assert_vec3_approx(*rebuilt.forward(), *transform.forward()); + } + + #[test] + fn drag_state_starts_without_a_start_transform() { + let drag = GizmoDrag::default(); + assert!(!drag.active); + assert!(drag.start_transform.is_none()); + } + + #[test] + fn finished_drag_edit_only_records_translation_changes() { + let entity = Entity::PLACEHOLDER; + let before = Transform::from_xyz(1.0, 2.0, 3.0); + + assert!( + finished_drag_edit(entity, before, before).is_none(), + "a press without movement must not record an edit" + ); + + let mut moved = before; + moved.translation.x += 0.5; + let recorded = finished_drag_edit(entity, before, moved) + .expect("a drag that changed translation records one edit"); + assert_eq!(recorded.entity, entity); + assert_eq!(recorded.before, before); + assert_eq!(recorded.after, moved); + } +} diff --git a/crates/bevy_forge_runtime/src/hierarchy.rs b/crates/bevy_forge_runtime/src/hierarchy.rs new file mode 100644 index 0000000..db1144a --- /dev/null +++ b/crates/bevy_forge_runtime/src/hierarchy.rs @@ -0,0 +1,510 @@ +//! Live hierarchy panel. +//! +//! Collects the scene tree from `ChildOf` / `Children`, renders it as an +//! indented, scrollable [`ListBox`] inside the left [`HierarchyPanel`] and binds +//! row activation to [`Selection`]. +//! +//! The panel is rebuilt only when the scene snapshot changes. Editor-owned UI +//! entities, observers and window entities are not part of the scene snapshot, +//! so rebuilding the panel can never feed back into itself. + +use std::collections::HashSet; + +use bevy::{ + picking::hover::Hovered, + prelude::*, + text::FontSize, + ui::{ScrollPosition, Selected}, + ui_widgets::{ListBox, ListItem, ScrollArea, ValueChange}, +}; + +use crate::{ + plugin::{editor_active, Selection}, + ui::{HierarchyPanel, UiTheme}, +}; + +/// Largest depth a hierarchy row may have. Deeper descendants are not listed. +const MAX_DEPTH: usize = 64; + +/// One row of the hierarchy tree, in render order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HierarchyRowData { + /// Entity this row represents. + pub entity: Entity, + /// Display name: the entity's `Name`, or `Entity#{index}` when missing. + pub name: String, + /// Zero-based depth. Roots have depth `0`. + pub depth: usize, +} + +/// Marker component on a list row, mapping it to the scene entity it selects. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +pub struct HierarchyRow { + /// Scene entity selected when this row is activated. + pub target: Entity, +} + +/// Marker for the single list entity inside the hierarchy panel. +#[derive(Component)] +struct HierarchyList; + +/// One collected tree row including its parent, used for snapshot comparison. +#[derive(Debug, Clone, PartialEq, Eq)] +struct TreeRow { + entity: Entity, + name: String, + parent: Option, + depth: usize, +} + +/// Snapshot of the last rendered tree: `(entity, name, parent, depth)` in render order. +#[derive(Resource, Default)] +struct HierarchyTreeState { + signature: Vec<(Entity, String, Option, usize)>, + rows: usize, + roots: usize, +} + +/// Adds the live hierarchy panel. +pub struct HierarchyPlugin; + +impl Plugin for HierarchyPlugin { + fn build(&self, app: &mut App) { + app.init_resource::().add_systems( + Update, + (rebuild_hierarchy, sync_selected_rows, style_hierarchy_rows) + .chain() + .run_if(editor_active), + ); + } +} + +/// Collects the full world tree: roots first, children in `Children` order. +/// +/// Roots are entities without `ChildOf`, sorted by [`Entity`]. Unnamed entities +/// fall back to `Entity#{index}`. A visited set prevents duplicate rows and a +/// depth cap of [`MAX_DEPTH`] prevents pathological trees from stalling the UI. +pub fn collect_rows(world: &mut World) -> Vec { + collect_tree(world, false) + .into_iter() + .map(|row| HierarchyRowData { + entity: row.entity, + name: row.name, + depth: row.depth, + }) + .collect() +} + +/// Collects the scene tree: only named, non-UI spatial entities may be roots. +/// +/// Children of a scene root are listed regardless of whether they have a +/// `Name`, so unnamed scene children still show up with the index fallback. +fn collect_scene_rows(world: &World) -> Vec { + collect_tree(world, true) +} + +/// Shared tree walk. +/// +/// When `scene_only` is set, roots additionally require `Name` and reject +/// `Node`, which keeps the editor UI, global observers and window entities out +/// of the hierarchy (and out of the rebuild snapshot). +fn collect_tree(world: &World, scene_only: bool) -> Vec { + let mut roots: Vec = world + .iter_entities() + .filter_map(|entity| { + let id = entity.id(); + if entity.get::().is_some() { + return None; + } + if scene_only && !is_scene_root(world, id) { + return None; + } + Some(id) + }) + .collect(); + roots.sort(); + + let mut rows = Vec::new(); + let mut visited = HashSet::new(); + for root in roots { + push_subtree(world, root, None, 0, &mut visited, &mut rows); + } + rows +} + +/// Whether an entity is a root of the editable scene rather than of the editor +/// or of an engine-internal helper (UI nodes, observers, window and gizmo +/// renderer entities, ...). +fn is_scene_root(world: &World, entity: Entity) -> bool { + world.get::(entity).is_some() + && world.get::(entity).is_some() + && world.get::(entity).is_none() +} + +/// Depth-first, in-order push of one entity and its descendants. +fn push_subtree( + world: &World, + entity: Entity, + parent: Option, + depth: usize, + visited: &mut HashSet, + rows: &mut Vec, +) { + if depth > MAX_DEPTH || !visited.insert(entity) { + return; + } + + let name = world + .get::(entity) + .map(|name| name.as_str().to_owned()) + .unwrap_or_else(|| format!("Entity#{}", entity.index())); + let children: Vec = world + .get::(entity) + .map(|children| children.to_vec()) + .unwrap_or_default(); + + rows.push(TreeRow { + entity, + name, + parent, + depth, + }); + + if depth >= MAX_DEPTH { + return; + } + for child in children { + push_subtree(world, child, Some(entity), depth + 1, visited, rows); + } +} + +/// Rebuilds the panel contents when the scene tree snapshot changes. +/// +/// This is an exclusive system so the old list is despawned and the new one is +/// spawned in the same tick: `sync_selected_rows` never observes stale rows and +/// a rebuild can never run twice for one snapshot. +fn rebuild_hierarchy(world: &mut World) { + let rows = collect_scene_rows(world); + let root_count = rows.iter().filter(|row| row.depth == 0).count(); + let signature: Vec<(Entity, String, Option, usize)> = rows + .iter() + .map(|row| (row.entity, row.name.clone(), row.parent, row.depth)) + .collect(); + + let previous = { + let state = world.resource::(); + (state.signature.clone(), state.rows, state.roots) + }; + if previous.0 == signature { + return; + } + + let panel = world + .query_filtered::>() + .iter(world) + .next(); + let Some(panel) = panel else { + return; + }; + + let theme = world.resource::().clone(); + let selection = *world.resource::(); + + // Drop the previous list (and its rows) before re-populating the panel. + let mut list_query = world.query_filtered::>(); + let old_lists: Vec = list_query.iter(world).collect(); + drop(list_query); + for list in old_lists { + world.despawn(list); + } + // The shell's placeholder ("Waiting for scene...") is replaced by the list. + world.entity_mut(panel).despawn_children(); + + let header = world + .spawn(hierarchy_label("Hierarchy", 14.0, theme.text)) + .id(); + world.entity_mut(panel).add_child(header); + + let list = world + .spawn(( + HierarchyList, + ListBox, + ScrollArea, + ScrollPosition::default(), + Node { + width: percent(100), + flex_grow: 1.0, + flex_direction: FlexDirection::Column, + overflow: Overflow::scroll_y(), + ..default() + }, + BackgroundColor(Color::NONE), + )) + .id(); + world.entity_mut(list).observe(hierarchy_row_activated); + world.entity_mut(panel).add_child(list); + + for row in &rows { + let is_selected = selection.0 == Some(row.entity); + let row_entity = world + .spawn(( + ListItem, + HierarchyRow { target: row.entity }, + Node { + width: percent(100), + height: px(22.0), + flex_shrink: 0.0, + padding: UiRect::left(px(6.0 + row.depth as f32 * 14.0)), + align_items: AlignItems::Center, + ..default() + }, + BackgroundColor(if is_selected { + theme.row_selected + } else { + Color::NONE + }), + Hovered::default(), + )) + .id(); + if is_selected { + world.entity_mut(row_entity).insert(Selected); + } + + let text = world + .spawn(hierarchy_label( + row.name.clone(), + 13.0, + if is_selected { + theme.text + } else { + theme.text_muted + }, + )) + .id(); + world.entity_mut(row_entity).add_child(text); + world.entity_mut(list).add_child(row_entity); + } + + let count_changed = previous.1 != rows.len() || previous.2 != root_count; + *world.resource_mut::() = HierarchyTreeState { + signature, + rows: rows.len(), + roots: root_count, + }; + if count_changed || previous.1 == 0 { + info!("hierarchy: {} rows, {} roots", rows.len(), root_count); + debug!( + "hierarchy rows: {:?}", + rows.iter().map(|row| row.name.as_str()).collect::>() + ); + } +} + +/// Creates a plain editor label bundle. +fn hierarchy_label(value: impl Into, size: f32, color: Color) -> impl Bundle { + ( + Text::new(value.into()), + TextFont { + font: FontSource::default(), + font_size: FontSize::Px(size), + ..default() + }, + TextColor(color), + ) +} + +/// Observer on the list entity: a committed row activation selects its target. +fn hierarchy_row_activated( + change: On>, + rows: Query<&HierarchyRow>, + mut selection: ResMut, +) { + let value = change.event().value; + let Ok(row) = rows.get(value) else { + return; + }; + if selection.0 != Some(row.target) { + selection.0 = Some(row.target); + } +} + +/// Keeps the widget's `Selected` marker in sync with [`Selection`]. +fn sync_selected_rows( + mut commands: Commands, + selection: Res, + rows: Query<(Entity, &HierarchyRow, Has)>, +) { + for (entity, row, selected) in &rows { + let should_select = selection.0 == Some(row.target); + if should_select && !selected { + commands.entity(entity).insert(Selected); + } else if !should_select && selected { + commands.entity(entity).remove::(); + } + } +} + +/// Styles rows from [`UiTheme`] using the `Selected` marker and pointer hover. +fn style_hierarchy_rows( + theme: Res, + selection: Res, + mut rows: Query<( + &HierarchyRow, + &mut BackgroundColor, + Has, + &Hovered, + &Children, + )>, + mut texts: Query<&mut TextColor>, +) { + for (row, mut background, selected, hovered, children) in &mut rows { + let is_selected = selected || selection.0 == Some(row.target); + let is_hovered = hovered.get() && !is_selected; + let background_color = if is_selected { + theme.row_selected + } else if is_hovered { + theme.row_hover + } else { + Color::NONE + }; + if background.0 != background_color { + background.0 = background_color; + } + + let text_color = if is_selected || is_hovered { + theme.text + } else { + theme.text_muted + }; + for child in children.iter() { + if let Ok(mut color) = texts.get_mut(child) { + if color.0 != text_color { + color.0 = text_color; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Keeps only the rows for entities spawned by a test. + /// + /// A bare [`World`] can contain internal entities (for example the reserved + /// index-zero entity); they are valid hierarchy roots but not part of these + /// focused ordering tests. + fn rows_for(world: &mut World, entities: &[Entity]) -> Vec { + collect_rows(world) + .into_iter() + .filter(|row| entities.contains(&row.entity)) + .collect() + } + + #[test] + fn collect_rows_walks_three_levels_in_children_order() { + let mut world = World::new(); + let root = world.spawn(Name::new("Root")).id(); + let child = world.spawn((Name::new("Child"), ChildOf(root))).id(); + let grandchild = world.spawn((Name::new("Grandchild"), ChildOf(child))).id(); + let sibling = world.spawn((Name::new("Sibling"), ChildOf(root))).id(); + + let rows = rows_for(&mut world, &[root, child, grandchild, sibling]); + let order: Vec<(&str, usize)> = rows + .iter() + .map(|row| (row.name.as_str(), row.depth)) + .collect(); + assert_eq!( + order, + vec![("Root", 0), ("Child", 1), ("Grandchild", 2), ("Sibling", 1),] + ); + assert_eq!(rows[0].entity, root); + assert_eq!(rows[1].entity, child); + assert_eq!(rows[2].entity, grandchild); + assert_eq!(rows[3].entity, sibling); + } + + #[test] + fn collect_rows_falls_back_to_entity_index() { + let mut world = World::new(); + let root = world.spawn(Name::new("Root")).id(); + let unnamed_child = world.spawn(ChildOf(root)).id(); + let unnamed_root = world.spawn_empty().id(); + + let rows = rows_for(&mut world, &[root, unnamed_child, unnamed_root]); + assert_eq!(rows.len(), 3); + let row = |entity: Entity| { + rows.iter() + .find(|row| row.entity == entity) + .expect("row for entity") + }; + assert_eq!(row(root).name, "Root"); + assert_eq!(row(root).depth, 0); + assert_eq!( + row(unnamed_child).name, + format!("Entity#{}", unnamed_child.index()) + ); + assert_eq!(row(unnamed_child).depth, 1); + assert_eq!( + row(unnamed_root).name, + format!("Entity#{}", unnamed_root.index()) + ); + assert_eq!(row(unnamed_root).depth, 0); + } + + #[test] + fn collect_rows_caps_depth_at_64() { + let mut world = World::new(); + let mut parent = world.spawn(Name::new("0")).id(); + let mut chain = vec![parent]; + for depth in 1..70 { + parent = world + .spawn((Name::new(format!("{depth}")), ChildOf(parent))) + .id(); + chain.push(parent); + } + + let rows = rows_for(&mut world, &chain); + assert_eq!(rows.len(), MAX_DEPTH + 1); + assert_eq!(rows.last().expect("rows").depth, MAX_DEPTH); + assert!(rows.iter().all(|row| row.depth <= MAX_DEPTH)); + } + + #[test] + fn value_change_selects_the_row_target() { + let mut world = World::new(); + world.init_resource::(); + + let list = world.spawn(HierarchyList).id(); + let target = world.spawn(Name::new("Target")).id(); + let row = world.spawn((ListItem, HierarchyRow { target })).id(); + world.entity_mut(list).observe(hierarchy_row_activated); + + world.trigger(ValueChange:: { + source: list, + value: row, + is_final: true, + }); + + assert_eq!(world.resource::().0, Some(target)); + } + + #[test] + fn value_change_ignores_entities_without_a_row() { + let mut world = World::new(); + world.init_resource::(); + + let list = world.spawn(HierarchyList).id(); + let stranger = world.spawn(Name::new("Stranger")).id(); + world.entity_mut(list).observe(hierarchy_row_activated); + + world.trigger(ValueChange:: { + source: list, + value: stranger, + is_final: true, + }); + + assert_eq!(world.resource::().0, None); + } +} diff --git a/crates/bevy_forge_runtime/src/history.rs b/crates/bevy_forge_runtime/src/history.rs new file mode 100644 index 0000000..7607e17 --- /dev/null +++ b/crates/bevy_forge_runtime/src/history.rs @@ -0,0 +1,397 @@ +//! Transform undo/redo history (T13). +//! +//! M1 undo/redo is Transform-only: the inspector sliders and the translate gizmo +//! record one [`TransformEdit`] per interaction, and `Ctrl+Z` / `Ctrl+Shift+Z` / +//! `Ctrl+Y` apply the recorded `before` / `after` transforms back to the live +//! entities. +//! +//! The history is intentionally a plain two-stack undo model: +//! - [`History::push`] clears the redo stack and caps the undo stack at +//! [`HISTORY_LIMIT`] entries (the oldest edit is dropped); +//! - [`History::undo`] moves one edit from the undo to the redo stack; +//! - [`History::redo`] moves it back. +//! +//! Applying an edit to a missing entity is a no-op (the entry still moves +//! between the stacks), so undo/redo cannot panic after entities are despawned. + +use bevy::prelude::*; + +use crate::plugin::editor_active; + +/// Maximum number of undo entries kept. Pushing past the cap drops the oldest. +pub const HISTORY_LIMIT: usize = 128; + +/// One recorded Transform edit of a single entity. +#[derive(Debug, Clone)] +pub struct TransformEdit { + /// Entity whose `Transform` was edited. + pub entity: Entity, + /// `Transform` before the interaction started. + pub before: Transform, + /// `Transform` after the interaction finished. + pub after: Transform, +} + +impl TransformEdit { + /// Writes the pre-edit transform into `target` (undo direction). + pub fn apply_before(&self, target: &mut Transform) { + *target = self.before; + } + + /// Writes the post-edit transform into `target` (redo direction). + pub fn apply_after(&self, target: &mut Transform) { + *target = self.after; + } +} + +/// Two-stack Transform undo/redo history, added by [`HistoryPlugin`]. +#[derive(Resource, Debug, Default)] +pub struct History { + undo: Vec, + redo: Vec, +} + +impl History { + /// Records one edit, clears the redo stack and enforces [`HISTORY_LIMIT`]. + /// + /// Logs `history: recorded {entity}` at info level — once per edit, never + /// per frame. + pub fn push(&mut self, edit: TransformEdit) { + info!("history: recorded {}", edit.entity); + self.redo.clear(); + self.undo.push(edit); + if self.undo.len() > HISTORY_LIMIT { + // Amortized cost is irrelevant here: the cap is exceeded by exactly + // one entry per push, i.e. at most one shift per recorded edit. + self.undo.remove(0); + } + } + + /// Pops the newest undo entry and moves it onto the redo stack. + pub fn undo(&mut self) -> Option { + let edit = self.undo.pop()?; + self.redo.push(edit.clone()); + Some(edit) + } + + /// Pops the newest redo entry and moves it back onto the undo stack. + pub fn redo(&mut self) -> Option { + let edit = self.redo.pop()?; + self.undo.push(edit.clone()); + Some(edit) + } + + /// Forgets every undo and redo entry. + pub fn clear(&mut self) { + self.undo.clear(); + self.redo.clear(); + } +} + +/// Installs the transform history and its undo/redo hotkeys. +pub struct HistoryPlugin; + +impl Plugin for HistoryPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Update, history_hotkeys.run_if(editor_active)); + } +} + +/// Which history operation the current keyboard state requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HistoryAction { + /// `Ctrl+Z`. + Undo, + /// `Ctrl+Shift+Z` or `Ctrl+Y`. + Redo, +} + +/// Maps the keyboard state to a history action; `None` when no hotkey fired. +fn history_action(keys: &ButtonInput) -> Option { + let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight); + if !ctrl { + return None; + } + let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight); + + if keys.just_pressed(KeyCode::KeyZ) { + return Some(if shift { + HistoryAction::Redo + } else { + HistoryAction::Undo + }); + } + if keys.just_pressed(KeyCode::KeyY) { + return Some(HistoryAction::Redo); + } + None +} + +/// Applies one undo/redo entry to the live ECS. +/// +/// A missing entity (or one without a `Transform`) is skipped; the entry has +/// already been moved between stacks by [`History::undo`] / [`History::redo`]. +fn apply_history_entry( + transforms: &mut Query<&mut Transform>, + edit: &TransformEdit, + action: HistoryAction, +) { + let Ok(mut transform) = transforms.get_mut(edit.entity) else { + debug!( + "history: {} skipped missing entity {}", + action_name(action), + edit.entity + ); + return; + }; + + match action { + HistoryAction::Undo => { + edit.apply_before(&mut transform); + info!("history: undo {}", edit.entity); + } + HistoryAction::Redo => { + edit.apply_after(&mut transform); + info!("history: redo {}", edit.entity); + } + } +} + +fn action_name(action: HistoryAction) -> &'static str { + match action { + HistoryAction::Undo => "undo", + HistoryAction::Redo => "redo", + } +} + +/// `Ctrl+Z` undo, `Ctrl+Shift+Z` / `Ctrl+Y` redo while the editor is enabled. +/// +/// Undo/redo never record new edits: they only move entries between the stacks. +fn history_hotkeys( + keys: Res>, + mut history: ResMut, + mut transforms: Query<&mut Transform>, +) { + let Some(action) = history_action(&keys) else { + return; + }; + + let edit = match action { + HistoryAction::Undo => history.undo(), + HistoryAction::Redo => history.redo(), + }; + if let Some(edit) = edit { + apply_history_entry(&mut transforms, &edit, action); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn transform_at(x: f32, y: f32, z: f32) -> Transform { + Transform::from_xyz(x, y, z) + } + + fn edit(entity: Entity, before: Transform, after: Transform) -> TransformEdit { + TransformEdit { + entity, + before, + after, + } + } + + #[test] + fn undo_pops_edits_in_reverse_order_and_redo_replays_them() { + let entity = Entity::PLACEHOLDER; + let mut history = History::default(); + let first = edit( + entity, + transform_at(0.0, 0.0, 0.0), + transform_at(1.0, 0.0, 0.0), + ); + let second = edit( + entity, + transform_at(1.0, 0.0, 0.0), + transform_at(2.0, 0.0, 0.0), + ); + + history.push(first.clone()); + history.push(second.clone()); + + let undone = history.undo().expect("second edit should undo first"); + assert_eq!(undone.after.translation, Vec3::new(2.0, 0.0, 0.0)); + let undone = history.undo().expect("first edit should undo second"); + assert_eq!(undone.after.translation, Vec3::new(1.0, 0.0, 0.0)); + assert!(history.undo().is_none()); + + let redone = history.redo().expect("first edit should redo first"); + assert_eq!(redone.after.translation, Vec3::new(1.0, 0.0, 0.0)); + let redone = history.redo().expect("second edit should redo second"); + assert_eq!(redone.after.translation, Vec3::new(2.0, 0.0, 0.0)); + assert!(history.redo().is_none()); + } + + #[test] + fn undo_and_redo_on_empty_history_return_none() { + let mut history = History::default(); + assert!(history.undo().is_none()); + assert!(history.redo().is_none()); + } + + #[test] + fn push_clears_the_redo_stack() { + let entity = Entity::PLACEHOLDER; + let mut history = History::default(); + history.push(edit( + entity, + transform_at(0.0, 0.0, 0.0), + transform_at(1.0, 0.0, 0.0), + )); + history.undo(); + assert_eq!(history.redo.len(), 1); + + history.push(edit( + entity, + transform_at(5.0, 0.0, 0.0), + transform_at(6.0, 0.0, 0.0), + )); + + assert!(history.redo.is_empty()); + assert_eq!(history.undo.len(), 1); + assert!(history.redo().is_none()); + } + + #[test] + fn clear_drops_undo_and_redo() { + let entity = Entity::PLACEHOLDER; + let mut history = History::default(); + history.push(edit( + entity, + transform_at(0.0, 0.0, 0.0), + transform_at(1.0, 0.0, 0.0), + )); + history.push(edit( + entity, + transform_at(1.0, 0.0, 0.0), + transform_at(2.0, 0.0, 0.0), + )); + history.undo(); + + history.clear(); + + assert!(history.undo.is_empty()); + assert!(history.redo.is_empty()); + assert!(history.undo().is_none()); + assert!(history.redo().is_none()); + } + + #[test] + fn push_caps_history_at_the_limit_and_drops_the_oldest() { + let entity = Entity::PLACEHOLDER; + let mut history = History::default(); + for index in 0..HISTORY_LIMIT + 2 { + let value = index as f32; + history.push(edit( + entity, + transform_at(value, 0.0, 0.0), + transform_at(value + 1.0, 0.0, 0.0), + )); + } + + assert_eq!(history.undo.len(), HISTORY_LIMIT); + // The oldest two entries (before 0.0 and 1.0) were dropped; undoing to + // the end leaves the edit whose `before` is 2.0. + let mut last_undone = None; + while let Some(entry) = history.undo() { + last_undone = Some(entry); + } + assert_eq!(history.redo.len(), HISTORY_LIMIT); + assert_eq!( + last_undone + .expect("history should contain entries") + .before + .translation, + Vec3::new(2.0, 0.0, 0.0) + ); + } + + #[test] + fn before_and_after_helpers_apply_to_a_real_transform() { + let mut world = World::new(); + let entity = world.spawn(transform_at(9.0, 9.0, 9.0)).id(); + let recorded = edit( + entity, + transform_at(1.0, 2.0, 3.0), + transform_at(4.0, 5.0, 6.0), + ); + + let mut transform = world + .get_mut::(entity) + .expect("spawned entity has a Transform"); + recorded.apply_before(&mut transform); + assert_eq!(transform.translation, Vec3::new(1.0, 2.0, 3.0)); + recorded.apply_after(&mut transform); + assert_eq!(transform.translation, Vec3::new(4.0, 5.0, 6.0)); + } + + #[test] + fn undo_and_redo_on_a_missing_entity_move_the_entry_without_panicking() { + let mut app = App::new(); + app.init_resource::(); + app.init_resource::>(); + app.insert_resource(crate::plugin::EditorState::default()); + app.add_plugins(HistoryPlugin); + + let missing = Entity::PLACEHOLDER; + app.world_mut().resource_mut::().push(edit( + missing, + transform_at(0.0, 0.0, 0.0), + transform_at(1.0, 0.0, 0.0), + )); + + // Ctrl+Z: the entry leaves undo, but there is no entity to update. + { + let mut keys = app.world_mut().resource_mut::>(); + keys.press(KeyCode::ControlLeft); + keys.press(KeyCode::KeyZ); + } + app.update(); + assert!(app.world().resource::().undo.is_empty()); + assert_eq!(app.world().resource::().redo.len(), 1); + + // Ctrl+Shift+Z moves it back; still no entity and no panic. + { + let mut keys = app.world_mut().resource_mut::>(); + // Without `InputPlugin` nothing clears `pressed` between frames. + keys.reset_all(); + keys.press(KeyCode::ControlLeft); + keys.press(KeyCode::ShiftLeft); + keys.press(KeyCode::KeyZ); + } + app.update(); + assert_eq!(app.world().resource::().undo.len(), 1); + assert!(app.world().resource::().redo.is_empty()); + } + + #[test] + fn hotkey_mapping_requires_ctrl_and_distinguishes_redo() { + let mut keys = ButtonInput::::default(); + assert_eq!(history_action(&keys), None); + + keys.press(KeyCode::KeyZ); + assert_eq!(history_action(&keys), None, "Ctrl is required"); + + keys.press(KeyCode::ControlLeft); + assert_eq!(history_action(&keys), Some(HistoryAction::Undo)); + + keys.press(KeyCode::ShiftLeft); + assert_eq!(history_action(&keys), Some(HistoryAction::Redo)); + + keys.clear(); + keys.press(KeyCode::ControlRight); + keys.press(KeyCode::KeyY); + assert_eq!(history_action(&keys), Some(HistoryAction::Redo)); + } +} diff --git a/crates/bevy_forge_runtime/src/inspector.rs b/crates/bevy_forge_runtime/src/inspector.rs new file mode 100644 index 0000000..56c3170 --- /dev/null +++ b/crates/bevy_forge_runtime/src/inspector.rs @@ -0,0 +1,1005 @@ +//! Reflection-driven inspector for the selected entity. +//! +//! M0 inspects three known components (`Name`, `Transform`, `Visibility`): they are +//! fetched with typed queries and then walked with `bevy_reflect`. Generic discovery +//! of arbitrary component types through `TypeRegistry` is explicitly **M1** work and +//! is not implemented here; this panel only shows the three M0 components the brief +//! promises. + +use bevy::{ + prelude::*, + reflect::{enums::VariantType, PartialReflect, ReflectMut, ReflectRef}, + text::FontSize, + ui_widgets::{ + Slider, SliderDragState, SliderRange, SliderStep, SliderThumb, SliderValue, TrackClick, + ValueChange, + }, +}; + +use crate::{ + history::{History, TransformEdit}, + plugin::{editor_active, Selection}, + ui::{InspectorPanel, UiTheme}, +}; + +/// Minimum / maximum value of the translation sliders. +const SLIDER_MIN: f32 = -20.0; +const SLIDER_MAX: f32 = 20.0; +/// Step used by keyboard interaction and by the widget's step notifications. +const SLIDER_STEP: f32 = 0.1; +/// Above this delta the slider value is considered stale and is re-synced. +const SYNC_TOLERANCE: f32 = 0.001; +/// Maximum reflection nesting depth for the field summary. +const MAX_SUMMARY_DEPTH: usize = 4; + +/// One flattened, displayable reflection field. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReflectedField { + /// Dotted display label, e.g. `translation.x`. + pub label: String, + /// Formatted value, e.g. `1.5`. + pub value: String, + /// Path of field names (or tuple indices) below the summarized root. + pub path: Vec, + /// Whether this leaf is a scalar numeric value a generic editor could edit. + pub editable: bool, +} + +/// Flattens the reflected field tree below `value` into display rows. +/// +/// Named structs, tuple structs, enums and scalar leaves are handled. The walk is +/// depth-limited so opaque wrappers (for example the hash wrapper inside [`Name`]) +/// cannot recurse forever. +pub fn summarize_reflect(value: &dyn Reflect) -> Vec { + // `Name` wraps a pre-hashed `Hashed>`, whose reflection is opaque and + // whose Display output is a debug dump. Present the readable name instead; the + // value still comes from a reflected downcast, not from a hard-coded string. + if let Some(name) = value.downcast_ref::() { + return vec![ReflectedField { + label: "Name".to_string(), + value: name.as_str().to_string(), + path: vec!["name".to_string()], + editable: false, + }]; + } + + let mut fields = Vec::new(); + collect_fields(value.as_partial_reflect(), &mut Vec::new(), 0, &mut fields); + fields +} + +/// Writes `value` into an `f32` field at `path`, e.g. `["translation", "x"]`. +/// +/// Returns `false` (without panicking) when a path segment does not exist or when +/// the addressed field is not an `f32`. +pub fn apply_f32_path(root: &mut dyn Reflect, path: &[&str], value: f32) -> bool { + apply_f32_in(root.as_partial_reflect_mut(), path, value) +} + +/// Slider attached to one translation axis of the selected entity. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +pub struct InspectorSlider { + /// Entity whose `Transform` is edited by this slider. + pub entity: Entity, + /// Translation axis edited by this slider. + pub axis: TransformAxis, +} + +/// Translation axis edited by an [`InspectorSlider`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransformAxis { + /// X axis. + X, + /// Y axis. + Y, + /// Z axis. + Z, +} + +impl TransformAxis { + const ALL: [Self; 3] = [Self::X, Self::Y, Self::Z]; + + fn index(self) -> usize { + match self { + Self::X => 0, + Self::Y => 1, + Self::Z => 2, + } + } + + fn name(self) -> &'static str { + match self { + Self::X => "x", + Self::Y => "y", + Self::Z => "z", + } + } +} + +/// Adds the editor inspector panel behavior. +pub struct InspectorPlugin; + +impl Plugin for InspectorPlugin { + fn build(&self, app: &mut App) { + app.init_resource::().add_systems( + Update, + // Sync the current sliders before a selection change rebuilds/despawns + // them. The reverse order queues a SliderValue insert for an entity that + // the rebuild has already despawned, which panics in Bevy's command queue. + (sync_inspector_from_transform, rebuild_inspector) + .chain() + .run_if(editor_active), + ); + } +} + +/// Value label next to one translation slider. +#[derive(Component)] +struct InspectorValueLabel { + axis: TransformAxis, +} + +/// Tracks what the panel currently displays so it only rebuilds on selection change. +#[derive(Resource, Default)] +struct InspectorBuildState { + initialized: bool, + selection: Option, +} + +/// Owned description of the selected entity, built once per selection change. +struct InspectorView { + name: String, + translation: [f32; 3], + rotation: String, + scale: String, + visibility: String, + has_transform: bool, + has_visibility: bool, +} + +/// Rebuilds the inspector body when the selection changes. +fn rebuild_inspector( + mut commands: Commands, + panels: Query<(Entity, Option<&Children>), With>, + selection: Res, + names: Query<&Name>, + transforms: Query<&Transform>, + visibilities: Query<&Visibility>, + theme: Res, + mut built: ResMut, +) { + if built.initialized && built.selection == selection.0 { + return; + } + let Some((panel_entity, children)) = panels.iter().next() else { + return; + }; + + built.initialized = true; + built.selection = selection.0; + + if let Some(children) = children { + for child in children.iter() { + commands.entity(child).despawn(); + } + } + + let selected = selection.0; + let view = selected.map(|entity| build_view(entity, &names, &transforms, &visibilities)); + + if let Some(entity) = selected { + let name = view + .as_ref() + .map(|view| view.name.as_str()) + .unwrap_or(""); + info!("inspector: selected {entity} ({name})"); + } + + commands.entity(panel_entity).with_children(|panel| { + panel.spawn(label("Inspector", 14.0, theme.text)); + + let Some(view) = &view else { + panel.spawn(label( + "Select an entity in the hierarchy", + 11.0, + theme.text_muted, + )); + return; + }; + + spawn_read_only_row(panel, "Name", &view.name, &theme); + panel.spawn(label("Transform", 11.0, theme.accent)); + + if view.has_transform { + if let Some(entity) = selected { + for axis in TransformAxis::ALL { + spawn_slider_row(panel, entity, axis, view.translation[axis.index()], &theme); + } + } + spawn_read_only_row(panel, "Rotation", &view.rotation, &theme); + spawn_read_only_row(panel, "Scale", &view.scale, &theme); + } else { + panel.spawn(label("No Transform component", 11.0, theme.text_muted)); + } + + if view.has_visibility { + spawn_read_only_row(panel, "Visibility", &view.visibility, &theme); + } else { + panel.spawn(label("No Visibility component", 11.0, theme.text_muted)); + } + }); +} + +/// Pending pre-edit `Transform` for one inspector slider. +/// +/// The first event of a drag has `is_final == false`; the last one has +/// `is_final == true`. A click/snap may deliver a single `is_final == true` +/// event, in which case the current transform is captured before applying it. +/// +/// Bevy UI's `TrackClick::Snap` currently reports a plain track click only as a +/// non-final event, so [`on_slider_pointer_release`] finalizes that case when +/// the pointer is released without a drag. A real drag ends with a final +/// `ValueChange` from the widget's `PointerDragEnd` handler instead. +/// +/// The pending state is a component on the slider entity, so selection changes +/// that rebuild the panel also discard any half-finished interaction. +#[derive(Component, Default)] +struct InspectorPendingEdit { + before: Option, +} + +impl InspectorPendingEdit { + /// Returns the transform to record as `before`, remembering it for the rest + /// of the interaction. + fn capture_before(&mut self, current: Transform) -> Transform { + *self.before.get_or_insert(current) + } + + /// Finishes the interaction after the value was applied: returns one edit + /// only on the final event and only when the transform actually changed. + fn finish( + &mut self, + entity: Entity, + before: Transform, + after: Transform, + is_final: bool, + ) -> Option { + if !is_final { + return None; + } + self.before = None; + (before != after).then_some(TransformEdit { + entity, + before, + after, + }) + } + + /// Finalizes a click that the widget reported without a final event. + /// + /// Returns one edit when a pending interaction exists and the target + /// transform changed since it started. + fn finish_click(&mut self, entity: Entity, current: Transform) -> Option { + let before = self.before.take()?; + (before != current).then_some(TransformEdit { + entity, + before, + after: current, + }) + } +} + +/// Applies a slider edit to the selected entity's `Transform` through reflection +/// and records the completed interaction in the transform [`History`]. +fn on_slider_value_change( + change: On>, + mut sliders: Query<(&InspectorSlider, &mut InspectorPendingEdit)>, + mut transforms: Query<&mut Transform>, + mut history: ResMut, + mut commands: Commands, +) { + let Ok((slider, mut pending)) = sliders.get_mut(change.source) else { + return; + }; + let Ok(mut transform) = transforms.get_mut(slider.entity) else { + return; + }; + + // Capture before applying the value: for a drag this happens on the first + // non-final event, for a click/snap on the single final event. + let before = pending.capture_before(*transform); + let path = ["translation", slider.axis.name()]; + if apply_f32_path(transform.as_reflect_mut(), &path, change.value) { + // Keep the widget's own state in step with the edit; the gizmo-facing + // sync system below handles external Transform changes. + commands + .entity(change.source) + .insert(SliderValue(change.value)); + } + let after = *transform; + + if let Some(edit) = pending.finish(slider.entity, before, after, change.is_final) { + history.push(edit); + } +} + +/// Finalizes a click that produced no final `ValueChange` event. +/// +/// While a slider drag is running the widget emits its own final event from the +/// drag-end handler, so this observer must not record a second edit; it only +/// handles releases where no drag is in progress. +fn on_slider_pointer_release( + release: On, + mut sliders: Query<( + &InspectorSlider, + &mut InspectorPendingEdit, + Option<&SliderDragState>, + )>, + transforms: Query<&Transform>, + mut history: ResMut, +) { + if release.button != PointerButton::Primary { + return; + } + let Ok((slider, mut pending, drag)) = sliders.get_mut(release.entity) else { + return; + }; + if drag.is_some_and(|drag| drag.dragging) { + return; + } + let Ok(transform) = transforms.get(slider.entity) else { + return; + }; + if let Some(edit) = pending.finish_click(slider.entity, *transform) { + history.push(edit); + } +} + +/// Copies the selected entity's `Transform.translation` into slider state and labels. +/// +/// This runs every frame while the editor is active. The small tolerance prevents a +/// slider drag (which already applied the value to the `Transform`) from being +/// overwritten by a slightly stale widget value. +fn sync_inspector_from_transform( + mut commands: Commands, + selection: Res, + transforms: Query<&Transform>, + sliders: Query<(Entity, &InspectorSlider, &SliderValue, &SliderRange)>, + children: Query<&Children>, + mut thumbs: Query<&mut Node, With>, + mut labels: Query<(&InspectorValueLabel, &mut Text)>, +) { + let Some(entity) = selection.0 else { + return; + }; + let Ok(transform) = transforms.get(entity) else { + return; + }; + let translation = transform.translation; + let values = [translation.x, translation.y, translation.z]; + + for (slider_entity, slider, current, range) in &sliders { + let target = range.clamp(values[slider.axis.index()]); + if (current.0 - target).abs() > SYNC_TOLERANCE { + commands.entity(slider_entity).insert(SliderValue(target)); + } + + let thumb_percent = percent(range.thumb_position(target) * 100.0); + for descendant in children.iter_descendants(slider_entity) { + if let Ok(mut thumb) = thumbs.get_mut(descendant) { + if thumb.left != thumb_percent { + thumb.left = thumb_percent; + } + break; + } + } + } + + for (label, mut text) in &mut labels { + let formatted = format!("{:.1}", values[label.axis.index()]); + if text.0 != formatted { + text.0 = formatted; + } + } +} + +/// Builds the owned display model for one entity with reflection traversal. +fn build_view( + entity: Entity, + names: &Query<&Name>, + transforms: &Query<&Transform>, + visibilities: &Query<&Visibility>, +) -> InspectorView { + let mut view = InspectorView { + name: format!("Entity#{}", entity.index()), + translation: [0.0; 3], + rotation: "-".to_string(), + scale: "-".to_string(), + visibility: "-".to_string(), + has_transform: false, + has_visibility: false, + }; + + if let Ok(name) = names.get(entity) { + let fields = summarize_reflect(name); + view.name = fields + .first() + .map(|field| field.value.clone()) + .unwrap_or_else(|| name.as_str().to_string()); + } + + if let Ok(transform) = transforms.get(entity) { + let fields = summarize_reflect(transform); + for axis in TransformAxis::ALL { + view.translation[axis.index()] = field_f32(&fields, &["translation", axis.name()]) + .unwrap_or_else(|| translation_component(transform.translation, axis)); + } + view.rotation = joined_component_values(&fields, "rotation"); + view.scale = joined_component_values(&fields, "scale"); + view.has_transform = true; + } + + if let Ok(visibility) = visibilities.get(entity) { + let fields = summarize_reflect(visibility); + view.visibility = fields + .first() + .map(|field| field.value.clone()) + .unwrap_or_else(|| format!("{visibility:?}")); + view.has_visibility = true; + } + + view +} + +fn translation_component(translation: Vec3, axis: TransformAxis) -> f32 { + match axis { + TransformAxis::X => translation.x, + TransformAxis::Y => translation.y, + TransformAxis::Z => translation.z, + } +} + +/// Spawns one `X`/`Y`/`Z` slider row with its value label. +fn spawn_slider_row( + parent: &mut ChildSpawnerCommands, + entity: Entity, + axis: TransformAxis, + value: f32, + theme: &UiTheme, +) { + parent + .spawn(Node { + width: percent(100), + height: px(24), + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: px(6), + ..default() + }) + .with_children(|row| { + row.spawn(label(axis.name(), 12.0, theme.text_muted)); + + row.spawn(( + Node { + flex_grow: 1.0, + height: px(12), + display: Display::Flex, + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Center, + align_items: AlignItems::Stretch, + ..default() + }, + Slider { + track_click: TrackClick::Snap, + ..default() + }, + SliderValue(value), + SliderRange::new(SLIDER_MIN, SLIDER_MAX), + SliderStep(SLIDER_STEP), + InspectorSlider { entity, axis }, + InspectorPendingEdit::default(), + )) + .observe(on_slider_value_change) + .observe(on_slider_pointer_release) + .with_children(|slider| { + // Background rail. + slider.spawn(( + Node { + height: px(6), + border_radius: BorderRadius::all(px(3)), + ..default() + }, + BackgroundColor(theme.panel_bg_alt), + )); + + // Invisible track, inset by the thumb width so percent placement works. + slider + .spawn(Node { + display: Display::Flex, + position_type: PositionType::Absolute, + left: px(0), + right: px(12), + top: px(0), + bottom: px(0), + ..default() + }) + .with_children(|track| { + track.spawn(( + SliderThumb, + Node { + display: Display::Flex, + width: px(12), + height: px(12), + position_type: PositionType::Absolute, + left: percent(slider_thumb_percent(value, SLIDER_MIN, SLIDER_MAX)), + border_radius: BorderRadius::MAX, + ..default() + }, + BackgroundColor(theme.accent), + )); + }); + }); + + row.spawn(( + InspectorValueLabel { axis }, + Text::new(format!("{value:.1}")), + TextFont { + font: FontSource::default(), + font_size: FontSize::Px(11.0), + ..default() + }, + TextColor(theme.text_muted), + Node { + width: px(44), + ..default() + }, + )); + }); +} + +/// Spawns a label/value row for read-only data. +fn spawn_read_only_row( + parent: &mut ChildSpawnerCommands, + name: &str, + value: &str, + theme: &UiTheme, +) { + parent + .spawn(Node { + width: percent(100), + min_height: px(18), + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::SpaceBetween, + align_items: AlignItems::Center, + column_gap: px(8), + ..default() + }) + .with_children(|row| { + row.spawn(label(name, 12.0, theme.text_muted)); + row.spawn(label(value, 12.0, theme.text)); + }); +} + +/// Builds a UI label bundle with the split `Text` / `TextFont` / `TextColor` API. +fn label(value: impl Into, size: f32, color: Color) -> impl Bundle { + ( + Text::new(value.into()), + TextFont { + font: FontSource::default(), + font_size: FontSize::Px(size), + ..default() + }, + TextColor(color), + ) +} + +fn slider_thumb_percent(value: f32, min: f32, max: f32) -> f32 { + if max <= min { + return 0.0; + } + ((value - min) / (max - min) * 100.0).clamp(0.0, 100.0) +} + +// --------------------------------------------------------------------------- +// Reflection helpers +// --------------------------------------------------------------------------- + +fn collect_fields( + value: &dyn PartialReflect, + path: &mut Vec, + depth: usize, + out: &mut Vec, +) { + if depth > MAX_SUMMARY_DEPTH { + push_leaf(value, path, out); + return; + } + + match value.reflect_ref() { + ReflectRef::Struct(struct_value) => { + let field_count = struct_value.field_len(); + if field_count == 0 { + push_leaf(value, path, out); + return; + } + for index in 0..field_count { + let Some(field) = struct_value.field_at(index) else { + continue; + }; + let field_name = struct_value.name_at(index).unwrap_or("?"); + path.push(field_name.to_string()); + collect_fields(field, path, depth + 1, out); + path.pop(); + } + } + ReflectRef::TupleStruct(tuple_struct) => { + let field_count = tuple_struct.field_len(); + if field_count == 0 { + push_leaf(value, path, out); + return; + } + for index in 0..field_count { + let Some(field) = tuple_struct.field(index) else { + continue; + }; + path.push(index.to_string()); + collect_fields(field, path, depth + 1, out); + path.pop(); + } + } + ReflectRef::Enum(enum_value) => match enum_value.variant_type() { + VariantType::Unit => { + let label = if path.is_empty() { + short_type_label(value) + } else { + path.join(".") + }; + out.push(ReflectedField { + label, + value: enum_value.variant_name().to_string(), + path: path.clone(), + editable: false, + }); + } + VariantType::Tuple | VariantType::Struct => { + for index in 0..enum_value.field_len() { + let Some(field) = enum_value.field_at(index) else { + continue; + }; + let field_name = enum_value + .name_at(index) + .map(str::to_string) + .unwrap_or_else(|| index.to_string()); + path.push(field_name); + collect_fields(field, path, depth + 1, out); + path.pop(); + } + } + }, + _ => push_leaf(value, path, out), + } +} + +fn push_leaf(value: &dyn PartialReflect, path: &[String], out: &mut Vec) { + let label = if path.is_empty() { + short_type_label(value) + } else { + path.join(".") + }; + out.push(ReflectedField { + label, + value: display_value(value), + path: path.to_vec(), + editable: is_numeric(value), + }); +} + +fn apply_f32_in(value: &mut dyn PartialReflect, path: &[&str], new_value: f32) -> bool { + let Some((head, tail)) = path.split_first() else { + return false; + }; + let Some(field) = reflected_field_mut(value, head) else { + return false; + }; + if tail.is_empty() { + match field.try_downcast_mut::() { + Some(slot) => { + *slot = new_value; + true + } + None => false, + } + } else { + apply_f32_in(field, tail, new_value) + } +} + +fn reflected_field_mut<'a>( + value: &'a mut dyn PartialReflect, + name: &str, +) -> Option<&'a mut dyn PartialReflect> { + match value.reflect_mut() { + ReflectMut::Struct(struct_value) => struct_value.field_mut(name), + ReflectMut::TupleStruct(tuple_struct) => name + .parse::() + .ok() + .and_then(|index| tuple_struct.field_mut(index)), + ReflectMut::Enum(enum_value) => enum_value.field_mut(name), + _ => None, + } +} + +fn field_f32(fields: &[ReflectedField], path: &[&str]) -> Option { + field_by_path(fields, path).and_then(|field| field.value.parse::().ok()) +} + +fn field_by_path<'a>(fields: &'a [ReflectedField], path: &[&str]) -> Option<&'a ReflectedField> { + fields.iter().find(|field| { + field + .path + .iter() + .map(String::as_str) + .eq(path.iter().copied()) + }) +} + +fn joined_component_values(fields: &[ReflectedField], group: &str) -> String { + let values: Vec<&str> = fields + .iter() + .filter(|field| field.path.first().map(String::as_str) == Some(group)) + .map(|field| field.value.as_str()) + .collect(); + if values.is_empty() { + "-".to_string() + } else { + values.join(", ") + } +} + +fn short_type_label(value: &dyn PartialReflect) -> String { + value + .reflect_type_path() + .rsplit("::") + .next() + .unwrap_or("value") + .to_string() +} + +fn is_numeric(value: &dyn PartialReflect) -> bool { + value.try_downcast_ref::().is_some() || value.try_downcast_ref::().is_some() +} + +fn display_value(value: &dyn PartialReflect) -> String { + if let Some(value) = value.try_downcast_ref::() { + return format_f32(*value); + } + if let Some(value) = value.try_downcast_ref::() { + return format_f64(*value); + } + if let Some(value) = value.try_downcast_ref::() { + return value.to_string(); + } + if let Some(value) = value.try_downcast_ref::() { + return value.clone(); + } + value.to_string() +} + +fn format_f32(value: f32) -> String { + format_number(value as f64) +} + +fn format_f64(value: f64) -> String { + format_number(value) +} + +fn format_number(value: f64) -> String { + let rounded = (value * 1000.0).round() / 1000.0; + if rounded == 0.0 { + "0.0".to_string() + } else if rounded.fract() == 0.0 { + format!("{rounded:.1}") + } else { + format!("{rounded}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Reflect)] + struct Pair(f32, bool); + + #[derive(Reflect)] + struct Point { + x: f32, + y: f32, + } + + fn field<'a>(fields: &'a [ReflectedField], path: &[&str]) -> &'a ReflectedField { + field_by_path(fields, path) + .unwrap_or_else(|| panic!("missing field {path:?} in {fields:?}")) + } + + #[test] + fn transform_summary_contains_translation_axes() { + let transform = Transform::from_xyz(1.5, -2.0, 3.25); + let fields = summarize_reflect(&transform); + + assert_eq!(field(&fields, &["translation", "x"]).label, "translation.x"); + assert_eq!(field(&fields, &["translation", "x"]).value, "1.5"); + assert_eq!(field(&fields, &["translation", "y"]).value, "-2.0"); + assert_eq!(field(&fields, &["translation", "z"]).value, "3.25"); + assert!(field(&fields, &["translation", "x"]).editable); + assert!(field_by_path(&fields, &["rotation", "w"]).is_some()); + assert!(field_by_path(&fields, &["scale", "y"]).is_some()); + } + + #[test] + fn name_summary_contains_the_name() { + let fields = summarize_reflect(&Name::new("Cube")); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].value, "Cube"); + assert!(!fields[0].editable); + } + + #[test] + fn visibility_summary_is_non_empty_and_contains_variant() { + let fields = summarize_reflect(&Visibility::Visible); + assert!(!fields.is_empty()); + assert!(fields.iter().any(|field| field.value.contains("Visible"))); + } + + #[test] + fn tuple_struct_summary_uses_index_paths() { + let fields = summarize_reflect(&Pair(1.25, true)); + assert_eq!(field(&fields, &["0"]).value, "1.25"); + assert_eq!(field(&fields, &["1"]).value, "true"); + } + + #[test] + fn named_struct_summary_flattens_fields() { + let fields = summarize_reflect(&Point { x: 2.0, y: -0.5 }); + assert_eq!(field(&fields, &["x"]).value, "2.0"); + assert_eq!(field(&fields, &["y"]).value, "-0.5"); + } + + #[test] + fn apply_f32_path_changes_translation_x() { + let mut transform = Transform::from_xyz(0.0, 0.0, 0.0); + assert!(apply_f32_path(&mut transform, &["translation", "x"], 2.5)); + assert_eq!(transform.translation, Vec3::new(2.5, 0.0, 0.0)); + } + + #[test] + fn apply_f32_path_rejects_wrong_path_without_panicking() { + let mut transform = Transform::default(); + assert!(!apply_f32_path(&mut transform, &[], 1.0)); + assert!(!apply_f32_path(&mut transform, &["translation"], 1.0)); + assert!(!apply_f32_path(&mut transform, &["translation", "w"], 1.0)); + assert!(!apply_f32_path( + &mut transform, + &["translation", "x", "extra"], + 1.0 + )); + assert!(!apply_f32_path(&mut transform, &["nonexistent"], 1.0)); + assert_eq!(transform, Transform::default()); + } + + #[test] + fn slider_tracker_records_one_edit_for_a_whole_drag() { + let entity = Entity::PLACEHOLDER; + let mut tracker = InspectorPendingEdit::default(); + let start = Transform::from_xyz(0.0, 0.0, 0.0); + let middle = Transform::from_xyz(1.0, 0.0, 0.0); + let end = Transform::from_xyz(2.0, 0.0, 0.0); + + // First non-final event captures the pre-drag transform. + let before = tracker.capture_before(start); + assert!(tracker.finish(entity, before, middle, false).is_none()); + + // Later non-final events reuse the original `before`. + let before = tracker.capture_before(middle); + assert_eq!(before, start); + assert!(tracker.finish(entity, before, end, false).is_none()); + + // The final event records exactly one edit and resets the tracker. + let before = tracker.capture_before(end); + assert_eq!(before, start); + let recorded = tracker + .finish(entity, before, end, true) + .expect("a changed drag should record one edit"); + assert_eq!(recorded.entity, entity); + assert_eq!(recorded.before, start); + assert_eq!(recorded.after, end); + assert!(tracker.before.is_none()); + } + + #[test] + fn slider_tracker_records_a_single_final_click_edit() { + let entity = Entity::PLACEHOLDER; + let mut tracker = InspectorPendingEdit::default(); + let before = Transform::from_xyz(0.0, 0.0, 0.0); + let after = Transform::from_xyz(3.5, 0.0, 0.0); + + let captured = tracker.capture_before(before); + let recorded = tracker + .finish(entity, captured, after, true) + .expect("a snap/click with is_final=true should record one edit"); + + assert_eq!(recorded.before, before); + assert_eq!(recorded.after, after); + assert!(tracker.before.is_none()); + } + + #[test] + fn slider_tracker_does_not_record_no_op_edits() { + let entity = Entity::PLACEHOLDER; + let mut tracker = InspectorPendingEdit::default(); + let unchanged = Transform::from_xyz(1.0, 2.0, 3.0); + + let captured = tracker.capture_before(unchanged); + assert!(tracker.finish(entity, captured, unchanged, true).is_none()); + assert!(tracker.before.is_none()); + } + + #[test] + fn slider_tracker_pushes_only_one_entry_into_history() { + let entity = Entity::PLACEHOLDER; + let mut tracker = InspectorPendingEdit::default(); + let mut history = History::default(); + let start = Transform::from_xyz(0.0, 0.0, 0.0); + let middle = Transform::from_xyz(1.0, 0.0, 0.0); + let end = Transform::from_xyz(2.0, 0.0, 0.0); + + for (current, after, is_final) in [ + (start, middle, false), + (middle, end, false), + (end, end, true), + ] { + let before = tracker.capture_before(current); + if let Some(edit) = tracker.finish(entity, before, after, is_final) { + history.push(edit); + } + } + + assert_eq!(history.undo().expect("one recorded edit").after, end); + assert!(history.undo().is_none()); + } + + #[test] + fn pending_click_is_finalized_on_pointer_release() { + let entity = Entity::PLACEHOLDER; + let mut pending = InspectorPendingEdit::default(); + let before = Transform::from_xyz(0.0, 0.0, 0.0); + let after = Transform::from_xyz(1.25, 0.0, 0.0); + + // A `TrackClick::Snap` click arrives as a non-final event and updates + // the transform; no final event follows, so the release finalizes it. + let captured = pending.capture_before(before); + assert!(pending.finish(entity, captured, after, false).is_none()); + + let recorded = pending + .finish_click(entity, after) + .expect("click release should record the pending edit"); + assert_eq!(recorded.before, before); + assert_eq!(recorded.after, after); + assert!(pending.finish_click(entity, after).is_none()); + + // A no-op click records nothing. + let captured = pending.capture_before(after); + assert!(pending.finish(entity, captured, after, false).is_none()); + assert!(pending.finish_click(entity, after).is_none()); + } + + #[test] + fn click_release_without_a_pending_edit_is_a_no_op() { + let entity = Entity::PLACEHOLDER; + let mut pending = InspectorPendingEdit::default(); + + assert!(pending + .finish_click(entity, Transform::from_xyz(1.0, 0.0, 0.0)) + .is_none()); + } +} diff --git a/crates/bevy_forge_runtime/src/lib.rs b/crates/bevy_forge_runtime/src/lib.rs new file mode 100644 index 0000000..c543f52 --- /dev/null +++ b/crates/bevy_forge_runtime/src/lib.rs @@ -0,0 +1,45 @@ +//! Bevy Forge live editor runtime (v2 live editor, M1 core). +//! +//! [`EditorPlugin`] installs the editor resources, the fixed Bevy UI shell +//! (toolbar, hierarchy panel, inspector panel, status bar), the scene bridge that +//! loads a `.forge.ron` scene into the live ECS and the module plugins that +//! provide hierarchy, inspector, gizmo and screenshot behavior. +//! +//! The plugin is designed to be added after `DefaultPlugins`: +//! +//! ```ignore +//! App::new() +//! .add_plugins(DefaultPlugins) +//! .add_plugins(EditorPlugin) +//! .run(); +//! ``` + +pub mod assets; +pub mod export; +pub mod gizmo; +pub mod hierarchy; +pub mod history; +pub mod inspector; +pub mod play; +pub mod plugin; +pub mod scene; +pub mod screenshot; +pub mod shortcuts; +pub mod ui; +pub mod watcher; + +pub use assets::AssetBrowserPlugin; +pub use export::ExportPlugin; +pub use gizmo::GizmoPlugin; +pub use hierarchy::HierarchyPlugin; +pub use history::HistoryPlugin; +pub use inspector::InspectorPlugin; +pub use play::PlayPlugin; +pub use plugin::{editor_active, EditorPlugin, EditorState, InspectorSource, Selection}; +pub use scene::{SceneConfig, SceneDocument, ScenePlugin}; +pub use screenshot::ScreenshotPlugin; +pub use shortcuts::ShortcutsPlugin; +pub use ui::{ + EditorLayout, EditorRoot, HierarchyPanel, InspectorPanel, StatusBar, Toolbar, UiTheme, +}; +pub use watcher::WatcherPlugin; diff --git a/crates/bevy_forge_runtime/src/play.rs b/crates/bevy_forge_runtime/src/play.rs new file mode 100644 index 0000000..bb7f9a2 --- /dev/null +++ b/crates/bevy_forge_runtime/src/play.rs @@ -0,0 +1,968 @@ +//! Play/pause/stop and the scene validation summary (T21, M2 Live). +//! +//! Play mode is a snapshot/restore demo, not a full game play mode: +//! +//! - entering play or pause from editing snapshots every tracked +//! [`Transform`] once; +//! - while playing, tracked mesh entities rotate around their local Y axis; +//! - pause freezes the scene; +//! - stop restores every snapshotted transform exactly and clears the snapshot, +//! so no live edit is lost. +//! +//! The overlay lives in the top-right corner of the 3D viewport, clear of the +//! inspector. Offsets come from [`EditorLayout`], so it also stays inside the +//! viewport when the window is resized. +//! +//! Shortcuts: `P` toggles play/pause, `Shift+P` or `Escape` stops and restores. + +use bevy::{ + prelude::*, + text::FontSize, + ui_widgets::{Activate, Button as UiButton}, +}; +use bevy_forge_core::{validate_scene, Severity}; + +use crate::{ + gizmo::EditorCamera, + plugin::EditorState, + scene::SceneDocument, + ui::{EditorLayout, UiTheme}, +}; + +/// Radians per second applied to tracked meshes while playing. +const PLAY_SPIN_SPEED: f32 = 1.0; + +/// Margin between the overlay and the viewport edges, in logical pixels. +const OVERLAY_MARGIN: f32 = 10.0; + +/// Width of the overlay panel, in logical pixels. +const OVERLAY_WIDTH: f32 = 230.0; + +/// The current play mode. +#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PlayMode { + /// Normal editing: nothing animates. + #[default] + Editing, + /// The scene animates. + Playing, + /// The scene is frozen at its current pose. + Paused, +} + +impl PlayMode { + /// Short label used by the overlay. + pub const fn label(self) -> &'static str { + match self { + Self::Editing => "Editing", + Self::Playing => "Playing", + Self::Paused => "Paused", + } + } +} + +/// Play mode plus the pre-play transform snapshot. +/// +/// The snapshot is taken once, when entering play or pause from +/// [`PlayMode::Editing`], and is kept across play/pause switches. It is only +/// cleared by stop (after restoring it). +#[derive(Resource, Debug, Default)] +pub struct PlayState { + /// Current mode. + pub mode: PlayMode, + /// Pre-play transform snapshot, entity to transform. + pub snapshot: Vec<(Entity, Transform)>, +} + +/// Counts from the last [`validate_scene`] run for the loaded document. +#[derive(Resource, Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct ValidationSummary { + /// Number of error-severity issues. + pub errors: usize, + /// Number of warning-severity issues. + pub warnings: usize, + /// Number of info-severity issues. + pub infos: usize, +} + +/// Root of the play overlay panel. +#[derive(Component)] +pub struct PlayOverlay; + +/// Text node displaying the current [`PlayMode`]. +#[derive(Component)] +pub struct PlayModeText; + +/// Text node displaying the [`ValidationSummary`] counts. +#[derive(Component)] +pub struct ValidationText; + +/// Overlay button that starts or resumes play. +#[derive(Component)] +pub struct PlayButton; + +/// Overlay button that pauses the scene. +#[derive(Component)] +pub struct PauseButton; + +/// Overlay button that stops and restores the scene. +#[derive(Component)] +pub struct StopButton; + +/// Installs play/pause/stop controls, the scene animation and validation. +pub struct PlayPlugin; + +impl Plugin for PlayPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + // `EditorUiPlugin` installs these too. Initializing them here keeps + // the plugin self-contained for standalone apps and unit tests. + .init_resource::() + .init_resource::() + .add_systems(Startup, build_play_overlay) + .add_systems( + Update, + ( + play_shortcuts, + animate_playing_scene, + validate_scene_document, + update_play_overlay, + sync_play_overlay_visibility, + ) + .chain(), + ); + } +} + +// --------------------------------------------------------------------------- +// Transitions +// --------------------------------------------------------------------------- + +/// Handles `P` (toggle play/pause) and `Shift+P` / `Escape` (stop). +fn play_shortcuts( + keys: Res>, + document: Option>, + mut play: ResMut, + mut transforms: Query<&mut Transform>, +) { + let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); + + if keys.just_pressed(KeyCode::Escape) || (shift && keys.just_pressed(KeyCode::KeyP)) { + stop_play(&mut play, &mut transforms); + return; + } + + if !keys.just_pressed(KeyCode::KeyP) { + return; + } + + match play.mode { + PlayMode::Playing => pause_play(&mut play, document.as_deref(), &mut transforms), + PlayMode::Editing | PlayMode::Paused => { + start_or_resume_play(&mut play, document.as_deref(), &mut transforms); + } + } +} + +/// Play button: enters play from editing (snapshotting) or resumes from pause. +fn on_play_activate( + _activate: On, + document: Option>, + mut play: ResMut, + mut transforms: Query<&mut Transform>, +) { + start_or_resume_play(&mut play, document.as_deref(), &mut transforms); +} + +/// Pause button: freezes the scene, snapshotting first when editing. +fn on_pause_activate( + _activate: On, + document: Option>, + mut play: ResMut, + mut transforms: Query<&mut Transform>, +) { + pause_play(&mut play, document.as_deref(), &mut transforms); +} + +/// Stop button: restores the snapshot and returns to editing. +fn on_stop_activate( + _activate: On, + mut play: ResMut, + mut transforms: Query<&mut Transform>, +) { + stop_play(&mut play, &mut transforms); +} + +/// Enters [`PlayMode::Playing`] from editing or paused. +/// +/// Entering from editing snapshots every alive tracked transform. Switching +/// from paused to playing keeps the existing snapshot; pressing play while +/// already playing is a no-op. +fn start_or_resume_play( + play: &mut PlayState, + document: Option<&SceneDocument>, + transforms: &mut Query<&mut Transform>, +) { + let Some(document) = document else { + return; + }; + match play.mode { + PlayMode::Playing => {} + PlayMode::Paused => { + play.mode = PlayMode::Playing; + info!("play: resumed"); + } + PlayMode::Editing => { + play.snapshot = snapshot_transforms(document, transforms); + play.mode = PlayMode::Playing; + info!( + "play: playing ({} transforms snapshotted)", + play.snapshot.len() + ); + } + } +} + +/// Enters [`PlayMode::Paused`], snapshotting first when coming from editing. +/// +/// Switching from playing to paused keeps the existing snapshot. +fn pause_play( + play: &mut PlayState, + document: Option<&SceneDocument>, + transforms: &mut Query<&mut Transform>, +) { + let Some(document) = document else { + return; + }; + match play.mode { + PlayMode::Paused => {} + PlayMode::Editing => { + play.snapshot = snapshot_transforms(document, transforms); + play.mode = PlayMode::Paused; + info!( + "play: paused ({} transforms snapshotted)", + play.snapshot.len() + ); + } + PlayMode::Playing => { + play.mode = PlayMode::Paused; + info!("play: paused"); + } + } +} + +/// Restores every snapshotted transform, clears the snapshot and goes back to +/// editing. +/// +/// Entities that were despawned while playing are skipped. When the scene is +/// already in editing mode with no snapshot, this is a no-op. +fn stop_play(play: &mut PlayState, transforms: &mut Query<&mut Transform>) { + if play.mode == PlayMode::Editing && play.snapshot.is_empty() { + return; + } + + let mut restored = 0; + for (entity, transform) in std::mem::take(&mut play.snapshot) { + if let Ok(mut current) = transforms.get_mut(entity) { + *current = transform; + restored += 1; + } + } + play.mode = PlayMode::Editing; + info!("play: stopped (restored {restored} transforms)"); +} + +/// Copies every alive tracked transform, sorted by entity for determinism. +fn snapshot_transforms( + document: &SceneDocument, + transforms: &Query<&mut Transform>, +) -> Vec<(Entity, Transform)> { + let mut snapshot: Vec<(Entity, Transform)> = document + .entities + .values() + .filter_map(|entity| { + transforms + .get(*entity) + .ok() + .map(|transform| (*entity, *transform)) + }) + .collect(); + snapshot.sort_by_key(|(entity, _)| *entity); + snapshot +} + +// --------------------------------------------------------------------------- +// Animation +// --------------------------------------------------------------------------- + +/// Rotates tracked mesh entities around their local Y axis while playing. +/// +/// The ground plane, cameras (scene or editor) and lights are skipped. Entities +/// that are not mapped from [`SceneDocument::entities`] are skipped too, so +/// editor-owned meshes can never be animated. Editing and pause animate +/// nothing. +fn animate_playing_scene( + time: Res